Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
897c4fa
Add collapsible filter
c-harding Jun 15, 2025
ec5dd81
Add filters and filter reset button
c-harding Jun 15, 2025
344ba40
Add button to save filters
c-harding Jun 15, 2025
7c21728
Invert appearance of buttons to match segments
c-harding Jun 15, 2025
7d17cc6
Restrict clicking on white space near starred filter
c-harding Jun 15, 2025
b17706a
Adapt UI vertical tab logic in preparation for groups
c-harding Jun 15, 2025
a681463
Rearrange UI for min and max
c-harding Jun 16, 2025
5b209da
Add prefix to filter summary
c-harding Jun 16, 2025
1f1545e
Add suffix to range UI
c-harding Jun 16, 2025
a5c9335
Add heading for filter group
c-harding Jun 16, 2025
03e8b30
Use details and simplify UI for header
c-harding Jun 19, 2025
df4cdd4
Add filter help and hidden filters
c-harding Jun 19, 2025
e551e96
Update style of starred button to match dropdown
c-harding Jun 19, 2025
a0ca6ab
Add gear filter
c-harding Jun 19, 2025
3a0b75f
Divide gear by type
c-harding Jun 20, 2025
72d3b98
Hide gear filter for routes
c-harding Jun 20, 2025
0a19441
Add reset button for filters
c-harding Jul 31, 2025
f67f65a
Move summary items logic to UIVerticalTab
c-harding Jul 31, 2025
ac8c686
Collapse date filter
c-harding Jul 31, 2025
f62f2b7
Add gear filter to filter summary
c-harding Oct 31, 2025
e233a2e
Add filter for private activities
c-harding Jan 1, 2026
bef6ec8
Fix saving gear filter
c-harding Jan 1, 2026
d86f8f9
Fix saving private filter
c-harding Jan 1, 2026
13fbdd2
Add filter for device
c-harding Jan 1, 2026
17f195c
Update star filter to match visibility
c-harding Jan 19, 2026
377fe3c
Add filter for isCommute
c-harding Feb 1, 2026
22dd235
Extract grouping to new tab
c-harding Jun 19, 2025
3bdda50
Add grouping section header
c-harding Jun 16, 2025
53f0e27
Move date filter to below load button
c-harding Aug 2, 2025
a4f6740
Use arrays for watch parameters
c-harding Aug 2, 2025
2340bed
Add ghost layer of activities when showing routes
c-harding Jul 31, 2025
19efd45
Add selection mode and selection focusing
c-harding Aug 2, 2025
18b5521
Use separate focused selections for routes and activities
c-harding Aug 2, 2025
a797f02
Fix clear button
c-harding Aug 2, 2025
673bbe4
Repeat summary in expanded view for selection block
c-harding Aug 2, 2025
4a12b37
Add option to repeat summary in expanded view for UI vertical tabs
c-harding Aug 2, 2025
f77e1cf
Rename selectionMode to multiSelectionMode
c-harding Aug 2, 2025
be6362b
Add selection checkbox for groups
c-harding Aug 2, 2025
f9587d8
Use focused activities for background map items
c-harding Aug 2, 2025
f2280ce
Prevent scrolling to selection in multi selection mode
c-harding Aug 2, 2025
13b6d78
Add explanation of why to select multiple items
c-harding Aug 2, 2025
b922c28
Prevent rezooming when deselecting items in the sidebar
c-harding Aug 2, 2025
1d013ad
Improve lazy handling of filters
c-harding Jan 5, 2026
1fa2fb9
Add calendar endpoint
c-harding May 5, 2022
37bc552
Fix seconds with decimal places in calendar
c-harding May 5, 2022
a254ada
Fix case of stat labels
c-harding May 5, 2022
8f04e4e
Add redirect to show the calendar
c-harding May 5, 2022
42f62fa
Serve the response natively
c-harding May 5, 2022
46576d8
Add elapsed time
c-harding May 10, 2022
5df35e8
Fix timezones
c-harding May 10, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions client/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,7 @@ defineExpose({ mapItems: activityStore.mapItems });
/>
</CollapsibleSidebar>
<Suspense>
<MapView
ref="map"
v-model:center="center"
v-model:zoom="zoom"
:bounds="geolocation"
:mapItems="activityStore.mapItems"
/>
<MapView ref="map" v-model:center="center" v-model:zoom="zoom" :bounds="geolocation" />
</Suspense>
</div>
</template>
Expand Down Expand Up @@ -130,6 +124,7 @@ a[href] {
--background-error: hsl(0 90% 30%);
--bold-color: #fc4c02;
--link-color: lightblue;
color-scheme: dark;
}
}

Expand Down
100 changes: 53 additions & 47 deletions client/src/components/map/MapView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ import { useMapStyle } from '@/MapStyle';
import { addLayersToMap, applyMapItems, MapSourceLayer, useMapSelection } from '@/utils/map';
import Viewport from '@/Viewport';

const { mapItems, bounds } = defineProps<{
mapItems: readonly MapItem[];
const { bounds } = defineProps<{
bounds?: LngLatBoundsLike;
}>();

Expand Down Expand Up @@ -93,19 +92,17 @@ onMounted(() => {
map.resize();
});

watch(
() => mapItems,
(mapItems) => {
applyMapItems(map, mapItems, MapSourceLayer.LINES);
},
);
watch([() => selectionStore.visibleItems], ([mapItems]) => {
applyMapItems(map, mapItems, MapSourceLayer.LINES);
});

watch(
() => selectionStore.selectedItems,
(selectedMapItems) => {
applyMapItems(map, selectedMapItems, MapSourceLayer.SELECTED);
},
);
watch([() => selectionStore.visibleBackgroundItems], ([backgroundMapItems]) => {
applyMapItems(map, backgroundMapItems, MapSourceLayer.BACKGROUND);
});

watch([() => selectionStore.selectedItems], ([selectedMapItems]) => {
applyMapItems(map, selectedMapItems, MapSourceLayer.SELECTED);
});

watch(mapStyleUrl, (style) => {
map.setStyle(style + '?optimize=true');
Expand Down Expand Up @@ -133,30 +130,23 @@ const onTerrain = () => {
watch(terrain, onTerrain);

/**
* Calculate best way of zooming to fit the activity while avoiding the controls in the corners.
* Calculate all ways of zooming to fit the activity while avoiding the controls in the corners.
*
* This works by considering the visual aspect ratio of the route, and for each corner control,
* considering either placing the route strictly horizontally offset from the control or strictly
* vertically offset from the control.
*
* It compares the area of the bounding box of the activity in each case.
*/
function optimiseViewport(map: MapboxMap, bounds: LngLatBounds) {
function getViewports() {
const padding = 10;

const { width, height } = map.getCanvas().getBoundingClientRect();

const northWest = mapboxgl.MercatorCoordinate.fromLngLat(bounds.getNorthWest());
const southEast = mapboxgl.MercatorCoordinate.fromLngLat(bounds.getSouthEast());

const aspectRatio = (northWest.y - southEast.y) / (northWest.x - southEast.x);

const topLeft = container.value?.querySelector('.mapboxgl-ctrl-top-left');
const topRight = container.value?.querySelector('.mapboxgl-ctrl-top-right');
const bottomLeft = container.value?.querySelector('.mapboxgl-ctrl-bottom-left');
const bottomRight = container.value?.querySelector('.mapboxgl-ctrl-bottom-right');

const viewports = [
return [
// Padding is given here as well as at the end so that 2 × padding is maintained from the edges,
// and 1 × padding is maintained from the controls
new Viewport(width, height, { left: padding, top: padding, bottom: padding, right: padding }),
Expand All @@ -182,12 +172,37 @@ function optimiseViewport(map: MapboxMap, bounds: LngLatBounds) {
viewport.withOffset({ right: bottomRight?.clientWidth ?? 0 }),
])
.map((viewport) => viewport.withPadding(padding));
}

/**
* Given a list of viewports, find the one with the largest area.
*/
function getOptimalViewport(viewports: Viewport[], bounds: LngLatBounds) {
const northWest = mapboxgl.MercatorCoordinate.fromLngLat(bounds.getNorthWest());
const southEast = mapboxgl.MercatorCoordinate.fromLngLat(bounds.getSouthEast());

const aspectRatio = (northWest.y - southEast.y) / (northWest.x - southEast.x);

return viewports.reduce((best, current) =>
best.screenArea(aspectRatio) > current.screenArea(aspectRatio) ? best : current,
);
}

function checkBoundsForViewport(viewport: Viewport, bounds: LngLatBounds) {
const screenNorthEast = map.unproject([
viewport.width - (viewport.offsets.right ?? 0),
viewport.offsets.top ?? 0,
]);
const screenSouthWest = map.unproject([
viewport.offsets.left ?? 0,
viewport.height - (viewport.offsets.bottom ?? 0),
]);
const screenBounds = new mapboxgl.LngLatBounds(screenSouthWest, screenNorthEast);
return (
screenBounds.contains(bounds.getSouthWest()) && screenBounds.contains(bounds.getNorthEast())
);
}

function flyTo(mapItems: readonly MapItem[], zoom = false): void {
if (mapItems.length === 0) return;
const coordinates = mapItems.flatMap(({ map: line }) =>
Expand All @@ -198,30 +213,20 @@ function flyTo(mapItems: readonly MapItem[], zoom = false): void {
new mapboxgl.LngLatBounds(coordinates[0], coordinates[0]),
);

const viewport = optimiseViewport(map, bounds);

const screenNorthEast = map.unproject([
viewport.width - (viewport.offsets.right ?? 0),
viewport.offsets.top ?? 0,
]);
const screenSouthWest = map.unproject([
viewport.offsets.left ?? 0,
viewport.height - (viewport.offsets.bottom ?? 0),
]);
const screenBounds = new mapboxgl.LngLatBounds(screenSouthWest, screenNorthEast);
const viewports = getViewports();

if (
zoom ||
!screenBounds.contains(bounds.getSouthWest()) ||
!screenBounds.contains(bounds.getNorthEast())
) {
const maxZoom = zoom ? 30 : map.getZoom();
map.fitBounds(bounds, {
padding: viewport.offsets,
linear: true,
maxZoom,
});
if (!zoom && viewports.some((viewport) => checkBoundsForViewport(viewport, bounds))) {
// If one of the viewports fits on the screen, there is no need to rezoom
return;
}

const viewport = getOptimalViewport(viewports, bounds);

map.fitBounds(bounds, {
padding: viewport.offsets,
linear: true,
maxZoom: zoom ? 30 : map.getZoom(),
});
}

function zoomToSelection(): void {
Expand All @@ -244,7 +249,8 @@ function mapLoaded(map: MapboxMap): void {
addLayersToMap(map, mapStyle.value);
onTerrain();

applyMapItems(map, mapItems, MapSourceLayer.LINES);
applyMapItems(map, selectionStore.visibleBackgroundItems, MapSourceLayer.BACKGROUND);
applyMapItems(map, selectionStore.visibleItems, MapSourceLayer.LINES);
applyMapItems(map, selectionStore.selectedItems, MapSourceLayer.SELECTED);
}

Expand Down
11 changes: 9 additions & 2 deletions client/src/components/segmented-control/SegmentedControl.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ export interface SegmentedControlItemContext {
selected: boolean;
select: () => void;
disabled: boolean;
deselect?: () => void;
}

const { disabled = false } = defineProps<{ disabled?: boolean }>();
const { disabled = false, deselectValue } = defineProps<{
disabled?: boolean;
deselectValue?: [T];
}>();

const model = defineModel<T>();
const model = defineModel<T>({ required: true });

defineSlots<{
default(props: { option: (value: T) => SegmentedControlItemContext }): unknown;
Expand All @@ -18,6 +22,9 @@ function option(value: T): SegmentedControlItemContext {
selected: model.value === value,
select: () => (model.value = value),
disabled,

// This conversion is sound because allowDeselect can only be true if T includes undefined
deselect: deselectValue ? () => (model.value = deselectValue[0]) : undefined,
};
}
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ const { option, disabled: disabledProp = false } = defineProps<{
}>();

const disabled = computed(() => option.disabled || disabledProp);

function select() {
if (!option.selected) {
option.select();
} else if (option.deselect) {
option.deselect();
}
}
</script>

<template>
Expand All @@ -19,7 +27,7 @@ const disabled = computed(() => option.disabled || disabledProp);
disabled && $style.disabled,
]"
>
<button :disabled @click.prevent="option.select()">
<button :disabled @click.prevent="select()">
<div :class="[$style.buttonContents, $style.normal]">
<slot />
</div>
Expand Down
Loading