diff --git a/website/src/lib/components/map/gpx-layer/gpx-layer.ts b/website/src/lib/components/map/gpx-layer/gpx-layer.ts index c6fcf558a..2c99f168e 100644 --- a/website/src/lib/components/map/gpx-layer/gpx-layer.ts +++ b/website/src/lib/components/map/gpx-layer/gpx-layer.ts @@ -449,7 +449,7 @@ export class GPXLayer { } } - layerOnClick(e: any) { + layerOnClick(e: mapboxgl.MapMouseEvent) { if ( get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) @@ -457,8 +457,8 @@ export class GPXLayer { return; } - let trackIndex = e.features[0].properties.trackIndex; - let segmentIndex = e.features[0].properties.segmentIndex; + let trackIndex = e.features![0].properties!.trackIndex; + let segmentIndex = e.features![0].properties!.segmentIndex; if ( get(currentTool) === Tool.SCISSORS && @@ -466,6 +466,11 @@ export class GPXLayer { new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) ) ) { + if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) { + // Clicked on split control, ignoring + return; + } + fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng, diff --git a/website/src/lib/components/toolbar/tools/scissors/split-controls.ts b/website/src/lib/components/toolbar/tools/scissors/split-controls.ts index edeff9694..df0068ea6 100644 --- a/website/src/lib/components/toolbar/tools/scissors/split-controls.ts +++ b/website/src/lib/components/toolbar/tools/scissors/split-controls.ts @@ -1,5 +1,3 @@ -import { TrackPoint, TrackSegment } from 'gpx'; -import mapboxgl from 'mapbox-gl'; import { ListTrackSegmentItem } from '$lib/components/file-list/file-list'; import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors'; @@ -9,20 +7,41 @@ import { gpxStatistics } from '$lib/logic/statistics'; import { get } from 'svelte/store'; import { fileStateCollection } from '$lib/logic/file-state'; import { fileActions } from '$lib/logic/file-actions'; +import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; export class SplitControls { - active: boolean = false; map: mapboxgl.Map; - controls: ControlWithMarker[] = []; - shownControls: ControlWithMarker[] = []; unsubscribes: Function[] = []; - toggleControlsForZoomLevelAndBoundsBinded: () => void = - this.toggleControlsForZoomLevelAndBounds.bind(this); + layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this); + layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this); + layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this); constructor(map: mapboxgl.Map) { this.map = map; + if (!this.map.hasImage('split-control')) { + let icon = new Image(100, 100); + icon.onload = () => { + if (!this.map.hasImage('split-control')) { + this.map.addImage('split-control', icon); + } + }; + + // Lucide icons are SVG files with a 24x24 viewBox + // Create a new SVG with a 32x32 viewBox and center the icon in a circle + icon.src = + 'data:image/svg+xml,' + + encodeURIComponent(` + + + + ${Scissors.replace('stroke="currentColor"', 'stroke="black"')} + + + `); + } + this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this))); @@ -31,29 +50,18 @@ export class SplitControls { addIfNeeded() { let scissors = get(currentTool) === Tool.SCISSORS; if (!scissors) { - if (this.active) { - this.remove(); - } + this.remove(); return; } - if (this.active) { - this.updateControls(); - } else { - this.add(); - } - } - - add() { - this.active = true; - - this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded); - this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded); + this.updateControls(); } updateControls() { - // Update the markers when the files change - let controlIndex = 0; + let data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [], + }; selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { let file = fileStateCollection.getFile(fileId); @@ -64,30 +72,23 @@ export class SplitControls { new ListTrackSegmentItem(fileId, trackIndex, segmentIndex) ) ) { - for (let point of segment.trkpt.slice(1, -1)) { - // Update the existing controls (could be improved by matching the existing controls with the new ones?) + for (let i = 1; i < segment.trkpt.length - 1; i++) { + let point = segment.trkpt[i]; if (point._data.anchor) { - if (controlIndex < this.controls.length) { - this.controls[controlIndex].fileId = fileId; - this.controls[controlIndex].point = point; - this.controls[controlIndex].segment = segment; - this.controls[controlIndex].trackIndex = trackIndex; - this.controls[controlIndex].segmentIndex = segmentIndex; - this.controls[controlIndex].marker.setLngLat( - point.getCoordinates() - ); - } else { - this.controls.push( - this.createControl( - point, - segment, - fileId, - trackIndex, - segmentIndex - ) - ); - } - controlIndex++; + data.features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [point.getLongitude(), point.getLatitude()], + }, + properties: { + fileId: fileId, + trackIndex: trackIndex, + segmentIndex: segmentIndex, + pointIndex: i, + minZoom: point._data.zoom, + }, + }); } } } @@ -95,86 +96,77 @@ export class SplitControls { } }, false); - while (controlIndex < this.controls.length) { - // Remove the extra controls - this.controls.pop()?.marker.remove(); - } + try { + let source = this.map.getSource('split-controls') as mapboxgl.GeoJSONSource | undefined; + if (source) { + source.setData(data); + } else { + this.map.addSource('split-controls', { + type: 'geojson', + data: data, + }); + } - this.toggleControlsForZoomLevelAndBounds(); + if (!this.map.getLayer('split-controls')) { + this.map.addLayer({ + id: 'split-controls', + type: 'symbol', + source: 'split-controls', + layout: { + 'icon-image': 'split-control', + 'icon-size': 0.25, + 'icon-padding': 0, + }, + filter: ['<=', ['get', 'minZoom'], ['zoom']], + }); + + this.map.on('mouseenter', 'split-controls', this.layerOnMouseEnterBinded); + this.map.on('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded); + this.map.on('click', 'split-controls', this.layerOnClickBinded); + } + + this.map.moveLayer('split-controls'); + } catch (e) { + // No reliable way to check if the map is ready to add sources and layers + } } remove() { - this.active = false; + this.map.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded); + this.map.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded); + this.map.off('click', 'split-controls', this.layerOnClickBinded); - for (let control of this.controls) { - control.marker.remove(); - } - this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded); - this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded); - } - - toggleControlsForZoomLevelAndBounds() { - // Show markers only if they are in the current zoom level and bounds - this.shownControls.splice(0, this.shownControls.length); - - let southWest = this.map.unproject([0, this.map.getCanvas().height]); - let northEast = this.map.unproject([this.map.getCanvas().width, 0]); - let bounds = new mapboxgl.LngLatBounds(southWest, northEast); - - let zoom = this.map.getZoom(); - this.controls.forEach((control) => { - control.inZoom = control.point._data.zoom <= zoom; - if (control.inZoom && bounds.contains(control.marker.getLngLat())) { - control.marker.addTo(this.map); - this.shownControls.push(control); - } else { - control.marker.remove(); + try { + if (this.map.getLayer('split-controls')) { + this.map.removeLayer('split-controls'); } - }); + + if (this.map.getSource('split-controls')) { + this.map.removeSource('split-controls'); + } + } catch (e) { + // No reliable way to check if the map is ready to remove sources and layers + } } - createControl( - point: TrackPoint, - segment: TrackSegment, - fileId: string, - trackIndex: number, - segmentIndex: number - ): ControlWithMarker { - let element = document.createElement('div'); - element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`; - element.innerHTML = Scissors.replace('width="24"', '') - .replace('height="24"', '') - .replace('stroke="currentColor"', 'stroke="black"'); + layerOnMouseEnter(e: any) { + mapCursor.notify(MapCursorState.SPLIT_CONTROL, true); + } - let marker = new mapboxgl.Marker({ - draggable: true, - className: 'z-10', - element, - }).setLngLat(point.getCoordinates()); + layerOnMouseLeave() { + mapCursor.notify(MapCursorState.SPLIT_CONTROL, false); + } - let control = { - point, - segment, - fileId, - trackIndex, - segmentIndex, - marker, - inZoom: false, - }; - - marker.getElement().addEventListener('click', (e) => { - e.stopPropagation(); - fileActions.split( - get(splitAs), - control.fileId, - control.trackIndex, - control.segmentIndex, - control.point.getCoordinates(), - control.point._data.index - ); - }); - - return control; + layerOnClick(e: mapboxgl.MapMouseEvent) { + let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates; + fileActions.split( + get(splitAs), + e.features![0].properties!.fileId, + e.features![0].properties!.trackIndex, + e.features![0].properties!.segmentIndex, + { lon: coordinates[0], lat: coordinates[1] }, + e.features![0].properties!.pointIndex + ); } destroy() { @@ -182,16 +174,3 @@ export class SplitControls { this.unsubscribes.forEach((unsubscribe) => unsubscribe()); } } - -type Control = { - segment: TrackSegment; - fileId: string; - trackIndex: number; - segmentIndex: number; - point: TrackPoint; -}; - -type ControlWithMarker = Control & { - marker: mapboxgl.Marker; - inZoom: boolean; -}; diff --git a/website/src/lib/logic/map-cursor.ts b/website/src/lib/logic/map-cursor.ts index 2aaf53ebd..b93541ffe 100644 --- a/website/src/lib/logic/map-cursor.ts +++ b/website/src/lib/logic/map-cursor.ts @@ -8,6 +8,7 @@ export enum MapCursorState { TRACKPOINT_DRAGGING, TOOL_WITH_CROSSHAIR, SCISSORS, + SPLIT_CONTROL, MAPILLARY_HOVER, STREET_VIEW_CROSSHAIR, } @@ -20,6 +21,7 @@ const cursorStyles = { [MapCursorState.TRACKPOINT_DRAGGING]: 'grabbing', [MapCursorState.TOOL_WITH_CROSSHAIR]: 'crosshair', [MapCursorState.SCISSORS]: scissorsCursor, + [MapCursorState.SPLIT_CONTROL]: 'pointer', [MapCursorState.MAPILLARY_HOVER]: 'pointer', [MapCursorState.STREET_VIEW_CROSSHAIR]: 'crosshair', };