diff --git a/client/src/App.vue b/client/src/App.vue index 3bbd7a5e..a667321a 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -62,13 +62,7 @@ defineExpose({ mapItems: activityStore.mapItems }); /> - + @@ -130,6 +124,7 @@ a[href] { --background-error: hsl(0 90% 30%); --bold-color: #fc4c02; --link-color: lightblue; + color-scheme: dark; } } diff --git a/client/src/components/map/MapView.vue b/client/src/components/map/MapView.vue index 76f34d39..72999d0e 100644 --- a/client/src/components/map/MapView.vue +++ b/client/src/components/map/MapView.vue @@ -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; }>(); @@ -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'); @@ -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 }), @@ -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 }) => @@ -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 { @@ -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); } diff --git a/client/src/components/segmented-control/SegmentedControl.vue b/client/src/components/segmented-control/SegmentedControl.vue index 87a3448c..d633b783 100644 --- a/client/src/components/segmented-control/SegmentedControl.vue +++ b/client/src/components/segmented-control/SegmentedControl.vue @@ -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(); +const model = defineModel({ required: true }); defineSlots<{ default(props: { option: (value: T) => SegmentedControlItemContext }): unknown; @@ -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, }; } diff --git a/client/src/components/segmented-control/SegmentedControlItem.vue b/client/src/components/segmented-control/SegmentedControlItem.vue index 08202b1c..5673666c 100644 --- a/client/src/components/segmented-control/SegmentedControlItem.vue +++ b/client/src/components/segmented-control/SegmentedControlItem.vue @@ -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(); + } +}