diff --git a/src/components/Map/MapContainer.jsx b/src/components/Map/MapContainer.jsx index 13b6752..d465007 100644 --- a/src/components/Map/MapContainer.jsx +++ b/src/components/Map/MapContainer.jsx @@ -16,7 +16,6 @@ import ActiveToolIndicator from './ActiveToolIndicator'; import LoadingOverlay from './LoadingOverlay'; import PlacementPreview from './PlacementPreview'; import EdgeMarkers from './EdgeMarkers'; -import TextAnnotationEditor, { AnnotationActionPill } from './TextAnnotationEditor'; import { useMapViewState } from '../../hooks/useMapViewState'; import { useRotationControls } from '../../hooks/useRotationControls'; import { useCameraRotation } from '../../hooks/useCameraRotation'; @@ -26,7 +25,7 @@ import { rotateRectanglePolygonMercator, normalizeAngle } from '../../utils/obje import ViewportInset from './ViewportInset'; import { useGlobalKeymap } from '../../hooks/useGlobalKeymap'; -const DEBUG = false; // Set to true to enable MapContainer debug logs +const DEBUG = false; const MapContainer = forwardRef(({ map, @@ -66,7 +65,6 @@ const MapContainer = forwardRef(({ const mapContainerRef = useRef(null); const [noteEditingObject, setNoteEditingObject] = useState(null); const [dimensionsEditingObject, setDimensionsEditingObject] = useState(null); - const [textEditorFeatureId, setTextEditorFeatureId] = useState(null); const [annotationsTrigger, setAnnotationsTrigger] = useState(0); const [rectMovingId, setRectMovingId] = useState(null); const subFocusArmedRef = useRef(false); @@ -74,12 +72,8 @@ const MapContainer = forwardRef(({ const arrowIconId = 'annotation-arrowhead'; const previewSourceId = 'draw-preview'; const { selectedObjectId, selectedKind, select, clearSelection } = useDroppedObjects(); - // Track current arrow overlay so we can reliably close it on key/delete or draw events const arrowOverlayRef = useRef(null); - // Track current annotations popup (MapLibre Popup) so global handlers can close it - const annotationPopupRef = useRef(null); - // Helper function to update cursor for subfocus mode const updateSubFocusCursor = useCallback((isSubFocus) => { try { if (!map) return; @@ -88,15 +82,9 @@ const MapContainer = forwardRef(({ if (!canvas && !container) return; if (isSubFocus) { - // MapboxDraw controls cursor via CSS classes on the map container - // The CSS rule is: .mapboxgl-map.mouse-add .mapboxgl-canvas-container.mapboxgl-interactive { cursor: crosshair; } - // We need to add the mouse-add class to the .mapboxgl-map element if (container) { container.classList.add('mouse-add'); - // Also set inline style as fallback container.style.cursor = 'crosshair'; - - // Find the canvas container element and set cursor there too const canvasContainer = container.querySelector('.mapboxgl-canvas-container'); if (canvasContainer) { canvasContainer.style.cursor = 'crosshair'; @@ -106,12 +94,10 @@ const MapContainer = forwardRef(({ canvas.style.cursor = 'crosshair'; } } else { - // Reset cursor when not in subfocus mode (unless other modes set it) if (!placementMode) { if (container) { container.classList.remove('mouse-add'); container.style.cursor = ''; - const canvasContainer = container.querySelector('.mapboxgl-canvas-container'); if (canvasContainer) { canvasContainer.style.cursor = ''; @@ -125,14 +111,7 @@ const MapContainer = forwardRef(({ } catch (_) {} }, [map, placementMode]); - // Map view state (single source of truth for pitch/bearing/zoom/viewType) const view = useMapViewState(map); - const suppressRotateSnapRef = useRef(false); - const lastDiscreteBearingRef = useRef(null); - // Track last area orientation and whether we were in isometric view - const lastThetaRef = useRef(null); - const lastIsoRef = useRef(null); - // Compute a stable area-bearing from the focused area for alignment const areaBearingDeg = useMemo(() => { try { const g = (permitAreas?.hasSubFocus ? permitAreas?.subFocusArea?.geometry : permitAreas?.focusedArea?.geometry); @@ -141,14 +120,11 @@ const MapContainer = forwardRef(({ } catch (_) { return 0; } }, [permitAreas?.focusedArea?.geometry, permitAreas?.subFocusArea?.geometry, permitAreas?.hasSubFocus, view?.pitch, map]); - // Compass / camera state const [bearing, setBearing] = useState(0); const [pitch, setPitch] = useState(0); - // Zone Creator: mandatory in intersections mode, always wire interactions there useZoneCreator(map, 'intersections'); - // Derive camera for compass/projection toggle from view hook useEffect(() => { try { setBearing(view?.bearing || 0); @@ -156,31 +132,17 @@ const MapContainer = forwardRef(({ } catch (_) {} }, [view?.bearing, view?.pitch]); - // no-op - - // Force immediate label refresh on external annotation change events useEffect(() => { const bump = () => setAnnotationsTrigger(v => v + 1); window.addEventListener('annotations:changed', bump); - // Open text editor when requested by draw tools - const onOpenText = (e) => { - try { - const id = e?.detail?.featureId; - if (id) setTextEditorFeatureId(id); - } catch (_) {} - }; - window.addEventListener('ui:open-text-editor', onOpenText); return () => window.removeEventListener('annotations:changed', bump); }, []); - // Hide the rectangle Edit/✕ popup while a rectangle is being dragged/resized useEffect(() => { const onRectMoveTick = (e) => { try { const id = e && e.detail && e.detail.id; if (id) setRectMovingId(id); - // Close any annotation popup immediately so it doesn't trail the rect - try { if (annotationPopupRef.current) { annotationPopupRef.current.remove(); annotationPopupRef.current = null; } } catch (_) {} } catch (_) {} }; const onRectMoveEnd = () => { try { setRectMovingId(null); } catch (_) {} }; @@ -192,7 +154,6 @@ const MapContainer = forwardRef(({ }; }, []); - // Listen for sub-focus arming/disarming events useEffect(() => { const arm = () => { subFocusArmedRef.current = true; @@ -204,7 +165,6 @@ const MapContainer = forwardRef(({ }; window.addEventListener('subfocus:arm', arm); window.addEventListener('subfocus:disarm', disarm); - // Apply event: geometry comes from draw tools; compute and set subfocus without persisting draw feature const apply = (e) => { try { const geom = e?.detail?.geometry; @@ -222,26 +182,20 @@ const MapContainer = forwardRef(({ }; }, [updateSubFocusCursor, permitAreas]); - // Set cursor to pin icon when in sub-area drawing mode useEffect(() => { if (!map) return; - const isSubFocusMode = drawTools?.activeTool === 'subfocus' || subFocusArmedRef.current; updateSubFocusCursor(isSubFocusMode); - - // Also listen for map container mouse events to ensure cursor stays updated const onMouseMove = () => { const currentIsSubFocus = drawTools?.activeTool === 'subfocus' || subFocusArmedRef.current; updateSubFocusCursor(currentIsSubFocus); }; - try { const container = map.getContainer(); if (container) { container.addEventListener('mousemove', onMouseMove); } } catch (_) {} - return () => { try { const container = map.getContainer(); @@ -249,7 +203,6 @@ const MapContainer = forwardRef(({ container.removeEventListener('mousemove', onMouseMove); } } catch (_) {} - // Reset cursor on cleanup try { const canvas = map.getCanvas(); if (canvas && !placementMode) { @@ -259,15 +212,11 @@ const MapContainer = forwardRef(({ }; }, [map, drawTools?.activeTool, placementMode, updateSubFocusCursor]); - // Build derived features (text points, shape labels, arrow lines, and arrowheads) from Draw features const derivedAnnotations = useMemo(() => { try { - // Defensive: if Draw is not yet initialized, return empty to avoid stale render const fc = drawTools?.draw?.current && drawTools.draw.current.getAll ? drawTools.draw.current.getAll() : null; const features = fc && Array.isArray(fc.features) ? fc.features : []; - const texts = []; const shapeLabels = []; - const arrows = []; const arrowheads = []; const mapBearing = (() => { try { return ((Number(view?.bearing || 0) % 360) + 360) % 360; } catch (_) { return 0; } @@ -275,38 +224,51 @@ const MapContainer = forwardRef(({ (features || []).forEach((f) => { if (!f || !f.geometry) return; const props = f.properties || {}; - // 1) Explicit text annotations - if (props.type === 'text' && f.geometry.type === 'Point' && props.label) { - // Flip by 180° when map bearing would make the text upside-down in viewport - const flip = (mapBearing > 90 && mapBearing < 270) ? 180 : 0; - texts.push({ type: 'Feature', geometry: f.geometry, properties: { sourceId: f.id, type: 'text', label: props.label, textSize: props.textSize || 14, textColor: props.textColor || '#111827', halo: props.halo !== false, textRotate: flip } }); - return; - } - // 2) Arrow annotations: always derive line + arrowhead (even when labeled) - if (props.type === 'arrow' && f.geometry.type === 'LineString') { + if (f.geometry.type === 'LineString') { const coords = f.geometry.coordinates || []; if (coords.length >= 2) { - const a = coords[coords.length - 2]; - const b = coords[coords.length - 1]; - arrows.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: [a, b] }, properties: { sourceId: f.id } }); - // Compute compass bearing (0°=north, clockwise) consistent with Maplibre icon-rotate - const theta = Math.atan2(b[0] - a[0], b[1] - a[1]); - let bearingDeg = (theta * 180) / Math.PI; - if (bearingDeg < 0) bearingDeg += 360; - arrowheads.push({ type: 'Feature', geometry: { type: 'Point', coordinates: b }, properties: { sourceId: f.id, bearing: bearingDeg, size: f.properties?.arrowSize || 1 } }); - // Optional label for arrow: midpoint text if label present + if (props.arrowEnd || props.type === 'arrow') { + const a = coords[coords.length - 2]; + const b = coords[coords.length - 1]; + const theta = Math.atan2(b[0] - a[0], b[1] - a[1]); + let bearingDeg = (theta * 180) / Math.PI; + if (bearingDeg < 0) bearingDeg += 360; + arrowheads.push({ type: 'Feature', geometry: { type: 'Point', coordinates: b }, properties: { sourceId: f.id, bearing: bearingDeg, size: props.arrowSize || 1 } }); + } + if (props.arrowStart) { + const a = coords[1]; + const b = coords[0]; + const theta = Math.atan2(b[0] - a[0], b[1] - a[1]); + let bearingDeg = (theta * 180) / Math.PI; + if (bearingDeg < 0) bearingDeg += 360; + arrowheads.push({ type: 'Feature', geometry: { type: 'Point', coordinates: b }, properties: { sourceId: f.id, bearing: bearingDeg, size: props.arrowSize || 1 } }); + } if (typeof props.label === 'string' && props.label.trim()) { - const mid = [ (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 ]; - // Base rotation follows the arrow direction in map space; flip by 180° if the resultant viewport angle would be upside-down - let textRotate = bearingDeg; - const viewportAngle = ((mapBearing + textRotate) % 360 + 360) % 360; - if (viewportAngle > 90 && viewportAngle < 270) textRotate = (textRotate + 180) % 360; + let mid; + if (coords.length === 2) { + mid = [ (coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2 ]; + } else { + let sumLng = 0, sumLat = 0; + coords.forEach(c => { sumLng += c[0]; sumLat += c[1]; }); + mid = [sumLng / coords.length, sumLat / coords.length]; + } + let textRotate = 0; + if (props.arrowEnd || props.type === 'arrow') { + const a = coords[coords.length - 2]; + const b = coords[coords.length - 1]; + const theta = Math.atan2(b[0] - a[0], b[1] - a[1]); + textRotate = (theta * 180) / Math.PI; + if (textRotate < 0) textRotate += 360; + const viewportAngle = ((mapBearing + textRotate) % 360 + 360) % 360; + if (viewportAngle > 90 && viewportAngle < 270) textRotate = (textRotate + 180) % 360; + } else { + textRotate = (mapBearing > 90 && mapBearing < 270) ? 180 : 0; + } shapeLabels.push({ type: 'Feature', geometry: { type: 'Point', coordinates: mid }, properties: { sourceId: f.id, label: props.label, textSize: props.textSize || 14, textColor: props.textColor || '#111827', halo: props.halo !== false, textRotate } }); } } return; } - // 3) Generic non-text shapes with a label: derive a label point (centroid/midpoint) if (typeof props.label === 'string' && props.label.trim()) { const g = f.geometry; let center = null; @@ -340,11 +302,10 @@ const MapContainer = forwardRef(({ } } }); - return { type: 'FeatureCollection', features: [...texts, ...shapeLabels, ...arrows, ...arrowheads] }; + return { type: 'FeatureCollection', features: [...shapeLabels, ...arrowheads] }; } catch (_) { return { type: 'FeatureCollection', features: [] }; } - }, [drawTools?.draw?.current, clickToPlace.objectUpdateTrigger, annotationsTrigger, view?.bearing]); + }, [drawTools?.draw?.current, clickToPlace.objectUpdateTrigger, annotationsTrigger, view?.renderTick]); - // Register arrowhead icon; re-register on style load and handle missing images useEffect(() => { if (!map) return; const register = () => { @@ -357,39 +318,27 @@ const MapContainer = forwardRef(({ canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); ctx.clearRect(0,0,size,size); - // Draw an open chevron (two line segments) pointing up (north) const tipX = size * 0.5; const tipY = size * 0.2; - const arm = size * 0.28; // vertical extent down from tip - const spread = arm * 0.7; // horizontal spread + const arm = size * 0.28; + const spread = arm * 0.7; ctx.strokeStyle = '#000000'; ctx.lineWidth = 6; ctx.lineCap = 'round'; - // Left limb (down-left from tip) - ctx.beginPath(); - ctx.moveTo(tipX, tipY); - ctx.lineTo(tipX - spread, tipY + arm); - ctx.stroke(); - // Right limb (down-right from tip) - ctx.beginPath(); - ctx.moveTo(tipX, tipY); - ctx.lineTo(tipX + spread, tipY + arm); - ctx.stroke(); + ctx.beginPath(); ctx.moveTo(tipX, tipY); ctx.lineTo(tipX - spread, tipY + arm); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(tipX, tipY); ctx.lineTo(tipX + spread, tipY + arm); ctx.stroke(); const data = ctx.getImageData(0,0,size,size); if (map.addImage) map.addImage(arrowIconId, data, { pixelRatio: 2 }); } catch (e) { console.warn('Failed to register arrow icon', e); } }; - // Initial attempt: prefer waiting for style load to avoid early addImage failures on basemap switches try { const ready = (typeof map.isStyleLoaded === 'function') ? map.isStyleLoaded() : true; if (ready) register(); else map.once('style.load', register); } catch (_) { register(); } - // On style load const onStyleLoad = () => register(); map.on('style.load', onStyleLoad); - // Handle on-demand missing image const onMissing = (e) => { try { if (e && e.id === arrowIconId) register(); } catch (_) {} }; map.on('styleimagemissing', onMissing); return () => { @@ -398,17 +347,14 @@ const MapContainer = forwardRef(({ }; }, [map]); - // Enforce single-selection across points and rectangles at the map layer level useEffect(() => { if (!map) return; try { if (selectedKind === 'point') { - // Clear rectangle handles immediately try { const hs = map.getSource && map.getSource('dropped-rectangles-handles'); if (hs && hs.setData) hs.setData({ type: 'FeatureCollection', features: [] }); } catch (_) {} - // Force rectangle selection flag off in the base source to avoid stale styling try { const rs = map.getSource && map.getSource('dropped-rectangles'); const fc = rs && rs._data; @@ -418,7 +364,6 @@ const MapContainer = forwardRef(({ } } catch (_) {} } else if (selectedKind === 'rect') { - // Hide point selection ring immediately try { if (map.getLayer && map.getLayer('dropped-objects-selected')) { map.setFilter('dropped-objects-selected', ['==', ['get', 'id'], '__none__']); @@ -428,7 +373,6 @@ const MapContainer = forwardRef(({ } catch (_) {} }, [map, selectedKind]); - // Sync derived annotations source & layers useEffect(() => { if (!map) return; const ensure = () => { @@ -439,7 +383,6 @@ const MapContainer = forwardRef(({ const src = map.getSource(derivedSourceId); src.setData(derivedAnnotations); } - // Determine insertion point: below draw layers if present let insertBeforeId; try { const style = map.getStyle ? map.getStyle() : null; @@ -448,7 +391,6 @@ const MapContainer = forwardRef(({ : null; insertBeforeId = firstDrawLayer ? firstDrawLayer.id : undefined; } catch (_) {} - // Text layer (both explicit text annotations and shape labels) if (!map.getLayer('annotation-text')) { map.addLayer({ id: 'annotation-text', @@ -459,15 +401,12 @@ const MapContainer = forwardRef(({ 'text-field': ['get', 'label'], 'text-size': ['coalesce', ['get', 'textSize'], 14], 'text-font': ['literal', ['Open Sans Bold','Arial Unicode MS Bold']], - // Center text for standalone text annotations; keep shape labels above - 'text-offset': ['case', ['==', ['get', 'type'], 'text'], ['literal', [0, 0]], ['literal', [0, -1.0]]], - 'text-anchor': ['case', ['==', ['get', 'type'], 'text'], 'center', 'bottom'], - // Keep labels perfectly horizontal in the viewport regardless of map rotation/pitch + 'text-offset': ['literal', [0, -1.0]], + 'text-anchor': 'bottom', 'text-rotate': 0, 'text-rotation-alignment': 'viewport', 'text-pitch-alignment': 'viewport', 'text-keep-upright': true, - // Favor always-visible labels for user annotations 'text-allow-overlap': true, 'text-ignore-placement': true }, @@ -475,12 +414,10 @@ const MapContainer = forwardRef(({ 'text-color': ['coalesce', ['get', 'textColor'], '#111827'], 'text-halo-color': '#ffffff', 'text-halo-width': 1.0, - // Hide map-canvas text; we'll draw DOM overlay labels above all layers to fix z-order 'text-opacity': 0 } - }); + }, insertBeforeId); } else { - // Ensure rotation properties are applied if the layer already exists try { map.setLayoutProperty('annotation-text', 'text-rotate', 0); } catch (_) {} try { map.setLayoutProperty('annotation-text', 'text-rotation-alignment', 'viewport'); } catch (_) {} try { map.setLayoutProperty('annotation-text', 'text-pitch-alignment', 'viewport'); } catch (_) {} @@ -489,24 +426,6 @@ const MapContainer = forwardRef(({ try { map.setLayoutProperty('annotation-text', 'text-ignore-placement', true); } catch (_) {} try { map.setPaintProperty('annotation-text', 'text-opacity', 0); } catch (_) {} } - // Arrow lines layer (black) - if (!map.getLayer('annotation-arrows')) { - map.addLayer({ - id: 'annotation-arrows', - type: 'line', - source: derivedSourceId, - filter: ['==', ['geometry-type'], 'LineString'], - layout: { - 'line-cap': 'round', - 'line-join': 'round' - }, - paint: { - 'line-color': '#000000', - 'line-width': 3 - } - }, insertBeforeId); - } - // Arrowhead layer if (!map.getLayer('annotation-arrowheads')) { map.addLayer({ id: 'annotation-arrowheads', @@ -534,86 +453,12 @@ const MapContainer = forwardRef(({ }; const ready = typeof map.isStyleLoaded === 'function' ? map.isStyleLoaded() : true; if (ready) ensure(); else map.once('style.load', ensure); - // Also schedule a second pass shortly after to catch late Draw binding after import try { setTimeout(() => { try { ensure(); } catch (_) {} }, 100); } catch (_) {} }, [map, derivedAnnotations]); - // Mirror Draw features into a dedicated preview source so lines render during editing - useEffect(() => { - if (!map) return; - - const ensureSourceAndLayers = () => { - try { - const fc = drawTools?.draw?.current && drawTools.draw.current.getAll ? drawTools.draw.current.getAll() : { type: 'FeatureCollection', features: [] }; - const data = { type: 'FeatureCollection', features: Array.isArray(fc.features) ? fc.features : [] }; - - if (!map.getSource(previewSourceId)) { - map.addSource(previewSourceId, { type: 'geojson', data }); - } else { - const src = map.getSource(previewSourceId); - src.setData(data); - } - - const style = map.getStyle && map.getStyle(); - const layers = (style && style.layers) ? style.layers : []; - const firstDrawLayer = layers.find((l) => typeof l?.id === 'string' && (l.id.startsWith('mapbox-gl-draw') || l.id.startsWith('gl-draw'))); - const beforeId = firstDrawLayer ? firstDrawLayer.id : undefined; - - if (!map.getLayer('draw-preview-line')) { - map.addLayer({ - id: 'draw-preview-line', - type: 'line', - source: previewSourceId, - filter: ['all', ['==', ['geometry-type'], 'LineString'], ['!=', ['get', 'type'], 'arrow']], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#2563eb', - 'line-width': 3, - 'line-opacity': 0.85 - } - }, beforeId); - } - - if (!map.getLayer('draw-preview-polygon-fill')) { - map.addLayer({ - id: 'draw-preview-polygon-fill', - type: 'fill', - source: previewSourceId, - filter: ['==', ['geometry-type'], 'Polygon'], - paint: { - 'fill-color': '#2563eb', - 'fill-opacity': 0.18 - } - }, beforeId); - } - - if (!map.getLayer('draw-preview-polygon-outline')) { - map.addLayer({ - id: 'draw-preview-polygon-outline', - type: 'line', - source: previewSourceId, - filter: ['==', ['geometry-type'], 'Polygon'], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#2563eb', - 'line-width': 2, - 'line-opacity': 0.8 - } - }, beforeId); - } - } catch (err) { - console.warn('Failed to maintain draw preview layers', err); - } - }; - - ensureSourceAndLayers(); - }, [map, drawTools?.draw, annotationsTrigger]); - - // Build DOM overlay labels from derived annotations so they can render above all map layers and SVG overlays const domAnnotationLabels = useMemo(() => { try { if (!map || !derivedAnnotations || !Array.isArray(derivedAnnotations.features)) return []; - // Only points with a label const feats = derivedAnnotations.features.filter(f => f && f.geometry && f.geometry.type === 'Point' && f.properties && typeof f.properties.label === 'string' && f.properties.label.trim()); return feats.map((f, idx) => { const [lng, lat] = f.geometry.coordinates || []; @@ -626,354 +471,55 @@ const MapContainer = forwardRef(({ } catch (_) { return []; } }, [map, derivedAnnotations, view?.renderTick]); - // Keep annotation layers above MapboxDraw layers (which may be added later) useEffect(() => { if (!map) return; const bringToTop = (id) => { try { if (map.getLayer(id)) map.moveLayer(id); } catch (_) {} }; - // Hide Draw's default point styling for text annotations and line styling for arrows - const hideDrawTextPoints = () => { + const styleDrawLayers = () => { try { const style = map.getStyle && map.getStyle(); const layers = (style && style.layers) ? style.layers : []; layers.forEach((l) => { - try { - if (!l || !l.id || !l.source) return; - const isDrawSource = l.source === 'mapbox-gl-draw-cold' || l.source === 'mapbox-gl-draw-hot'; - if (!isDrawSource) return; - const existing = map.getFilter && map.getFilter(l.id); - if (l.type === 'circle' || (typeof l.id === 'string' && (l.id.includes('point') || l.id.includes('point-stroke')))) { - const base = existing || ['==', ['geometry-type'], 'Point']; - const next = ['all', base, ['!=', ['get', 'type'], 'text']]; - map.setFilter(l.id, next); - if (l.type === 'circle') { - try { map.setPaintProperty(l.id, 'circle-opacity', ['case', ['==', ['get', 'type'], 'text'], 0, 1]); } catch (_) {} - try { map.setPaintProperty(l.id, 'circle-stroke-opacity', ['case', ['==', ['get', 'type'], 'text'], 0, 1]); } catch (_) {} - } - } else if (l.type === 'line') { - const base = existing || ['==', ['geometry-type'], 'LineString']; - // Keep active/hot features visible to ensure selection/deletion works repeatedly - const next = ['all', base, ['any', ['!=', ['get', 'type'], 'arrow'], ['==', ['get', 'active'], 'true']]]; - map.setFilter(l.id, next); - } - } catch (_) {} + if (!l || !l.id || !l.source) return; + const isDrawSource = l.source === 'mapbox-gl-draw-cold' || l.source === 'mapbox-gl-draw-hot'; + if (!isDrawSource) return; + if (l.type === 'circle' || (typeof l.id === 'string' && (l.id.includes('point') || l.id.includes('point-stroke')))) { + try { + map.setPaintProperty(l.id, 'circle-opacity', 1); + map.setPaintProperty(l.id, 'circle-stroke-opacity', 1); + } catch (_) {} + } }); } catch (_) {} }; const t = setTimeout(() => { bringToTop('annotation-text'); - bringToTop('annotation-arrows'); bringToTop('annotation-arrowheads'); - hideDrawTextPoints(); + styleDrawLayers(); }, 50); return () => clearTimeout(t); }, [map, drawTools?.draw, derivedAnnotations]); - // Add click-to-edit popup for annotation layers - useEffect(() => { - if (!map) return; - let popup = null; - let suppressSequence = false; - const Ctor = (typeof window !== 'undefined' && (window.maplibregl || window.mapboxgl) && (window.maplibregl.Popup || window.mapboxgl.Popup)) || null; - const ensurePopup = () => { - if (popup) { annotationPopupRef.current = popup; return popup; } - if (!Ctor) return null; - try { popup = new Ctor({ closeButton: false, closeOnClick: false, offset: [0, -12] }); } catch (_) { popup = null; } - annotationPopupRef.current = popup; - return popup; - }; - const openAt = (lngLat, el) => { - const p = ensurePopup(); - if (!p || !el) return; - p.setDOMContent(el); - p.setLngLat(lngLat); - p.addTo(map); - annotationPopupRef.current = p; - try { console.debug('ANNOT: popup opened', { lngLat }); } catch (_) {} - try { - const root = p.getElement && p.getElement(); - if (root) { - root.style.background = 'transparent'; - root.style.border = 'none'; - root.style.boxShadow = 'none'; - const contentEl = root.querySelector('.maplibregl-popup-content, .mapboxgl-popup-content'); - if (contentEl) { - contentEl.style.background = 'transparent'; - contentEl.style.border = 'none'; - contentEl.style.boxShadow = 'none'; - contentEl.style.padding = '0'; - } - const tipEl = root.querySelector('.maplibregl-popup-tip, .mapboxgl-popup-tip'); - if (tipEl) tipEl.style.display = 'none'; - } - } catch (_) {} - }; - const buildPill = (buttons = []) => { - const wrap = document.createElement('div'); - const box = document.createElement('div'); - box.className = 'rounded-full px-2 py-1 text-[11px] shadow-sm flex gap-1 bg-white/90 dark:bg-gray-900/80 border border-gray-200/60 dark:border-gray-700/60'; - box.style.transform = 'translateY(4px) scale(0.97)'; - box.style.opacity = '0'; - box.style.transition = 'opacity 140ms ease, transform 160ms cubic-bezier(.2,.7,.3,1)'; - buttons.forEach(({ label, onClick, className }) => { - const b = document.createElement('button'); - b.type = 'button'; - b.textContent = label; - b.className = className || 'px-2 py-0.5 rounded-full border border-gray-300/70 dark:border-gray-700 text-gray-700 dark:text-gray-200 bg-white/70 dark:bg-gray-800/50 hover:bg-white/90'; - b.onclick = (e) => { e.stopPropagation(); onClick && onClick(e); try { popup && popup.remove(); } catch (_) {} }; - box.appendChild(b); - }); - wrap.appendChild(box); - requestAnimationFrame(() => { box.style.transform = 'translateY(0) scale(1)'; box.style.opacity = '1'; }); - return wrap; - }; - - const closeArrowOverlay = () => { - try { - const ref = arrowOverlayRef.current; - if (ref && ref.root && ref.mount) { - try { ref.root.unmount(); } catch (_) {} - try { if (ref.mount.parentNode) ref.mount.parentNode.removeChild(ref.mount); } catch (_) {} - } - } catch (_) {} - arrowOverlayRef.current = null; - }; - - const openForFeature = (feature, lngLat) => { - try { - try { console.debug('ANNOT: openForFeature', { id: feature?.properties?.sourceId, geomType: feature?.geometry?.type }); } catch (_) {} - const srcId = feature?.properties?.sourceId; - if (!srcId) return; - // Treat both line and arrowhead point as arrow features if they carry a sourceId - const isArrow = !!srcId && ((feature && feature.geometry && feature.geometry.type !== 'Point') || (feature && feature.properties && typeof feature.properties.bearing !== 'undefined')); - const getDraw = () => (drawTools && drawTools.draw && drawTools.draw.current) ? drawTools.draw.current : (map && map.getControl ? map.getControl('MapboxDraw') : null); - const drawCtrl = getDraw(); - if (isArrow) { - // Use MapLibre popup just like DroppedObjects for reliability - try { if (popup) popup.remove(); } catch (_) {} - const el = buildPill([ - { label: 'Label…', onClick: () => { - try { - const val = typeof window !== 'undefined' ? window.prompt('Arrow label') : null; - if (val != null && drawCtrl) { - if (drawCtrl.setFeatureProperty) drawCtrl.setFeatureProperty(srcId, 'label', String(val)); - else { - try { const f = drawCtrl.get(srcId); if (f) { f.properties = Object.assign({}, f.properties || {}, { label: String(val) }); drawCtrl.add(f); } } catch (_) {} - } - try { window.dispatchEvent(new Event('annotations:changed')); } catch (_) {} - setAnnotationsTrigger(v => v + 1); - } - } catch (_) {} - } }, - { label: 'Remove', className: 'text-white rounded-full px-2 py-0.5 bg-red-500 hover:bg-red-600', onClick: () => { - try { drawCtrl && drawCtrl.delete && drawCtrl.delete(srcId); } catch (_) {} - setAnnotationsTrigger(v => v + 1); - } } - ]); - openAt(lngLat, el); - } else { - const el = buildPill([ - { label: 'Edit', onClick: () => setTextEditorFeatureId(srcId) }, - { label: 'Remove', className: 'text-white rounded-full px-2 py-0.5 bg-red-500 hover:bg-red-600', onClick: () => { try { console.debug('ANNOT: remove clicked for text', { srcId, hasDraw: !!drawCtrl }); drawCtrl && drawCtrl.delete && drawCtrl.delete(srcId); } catch (_) {} setAnnotationsTrigger(v => v + 1); } } - ]); - openAt(lngLat, el); - } - } catch (_) {} - }; - - // Direct layer-bound handlers mirroring DroppedObjects approach (more reliable than canvas capture) - const onArrowClick = (e) => { - try { - if (!e || !e.features || !e.features[0]) return; - const f = e.features[0]; - if (e && e.preventDefault) e.preventDefault(); - const oe = e && (e.originalEvent || e.point && e.point.originalEvent); - if (oe && typeof oe.stopPropagation === 'function') oe.stopPropagation(); - openForFeature(f, e.lngLat); - } catch (_) {} - }; - const cursorPointerOn = () => { try { map && map.getCanvas && (map.getCanvas().style.cursor = 'pointer'); } catch (_) {} }; - const cursorPointerOff = () => { try { map && map.getCanvas && (map.getCanvas().style.cursor = ''); } catch (_) {} }; - const bindAnnotationLayerHandlers = () => { - try { - if (map.getLayer && map.getLayer('annotation-arrows')) { - map.on('click', 'annotation-arrows', onArrowClick); - map.on('mouseenter', 'annotation-arrows', cursorPointerOn); - map.on('mouseleave', 'annotation-arrows', cursorPointerOff); - } - } catch (_) {} - try { - if (map.getLayer && map.getLayer('annotation-arrowheads')) { - map.on('click', 'annotation-arrowheads', onArrowClick); - map.on('mouseenter', 'annotation-arrowheads', cursorPointerOn); - map.on('mouseleave', 'annotation-arrowheads', cursorPointerOff); - } - } catch (_) {} - }; - bindAnnotationLayerHandlers(); - const onStyleLoadRebindAnnots = () => bindAnnotationLayerHandlers(); - try { map.on('style.load', onStyleLoadRebindAnnots); } catch (_) {} - - const canvas = map && map.getCanvas ? map.getCanvas() : null; - if (!canvas) return; - const layers = ['annotation-text', 'annotation-arrows', 'annotation-arrowheads']; - - const onMouseDownCapture = (ev) => { - try { - const rect = canvas.getBoundingClientRect(); - const x = ev.clientX - rect.left; - const y = ev.clientY - rect.top; - const feats = map.queryRenderedFeatures && map.queryRenderedFeatures([x, y], { layers }); - if (feats && feats.length > 0) { - try { console.debug('ANNOT: mousedown hit', { x, y, hits: feats.length }); } catch (_) {} - // Prevent Draw/map handlers and open pill - ev.preventDefault(); - if (typeof ev.stopImmediatePropagation === 'function') ev.stopImmediatePropagation(); - if (typeof ev.stopPropagation === 'function') ev.stopPropagation(); - ev.cancelBubble = true; - suppressSequence = true; - try { console.debug('ANNOT: preventing downstream handlers (mousedown)'); } catch (_) {} - const lngLat = map.unproject([x, y]); - openForFeature(feats[0], lngLat); - } else if (popup) { - try { console.debug('ANNOT: outside click on canvas; closing popup'); } catch (_) {} - try { popup.remove(); } catch (_) {} - // Allow event to propagate normally - } - } catch (_) {} - }; - - const onMouseUpCapture = (ev) => { - try { - if (suppressSequence) { - try { console.debug('ANNOT: mouseup capture suppressed (open sequence)'); } catch (_) {} - ev.preventDefault(); - if (typeof ev.stopImmediatePropagation === 'function') ev.stopImmediatePropagation(); - if (typeof ev.stopPropagation === 'function') ev.stopPropagation(); - ev.cancelBubble = true; - } - } catch (_) {} - }; - - const onClickCapture = (ev) => { - try { - if (suppressSequence) { - try { console.debug('ANNOT: click capture suppressed (open sequence)'); } catch (_) {} - ev.preventDefault(); - if (typeof ev.stopImmediatePropagation === 'function') ev.stopImmediatePropagation(); - if (typeof ev.stopPropagation === 'function') ev.stopPropagation(); - ev.cancelBubble = true; - suppressSequence = false; - } - } catch (_) {} - }; - - const onDblClickCapture = (ev) => { - try { - if (suppressSequence) { - try { console.debug('ANNOT: dblclick capture suppressed (open sequence)'); } catch (_) {} - ev.preventDefault(); - if (typeof ev.stopImmediatePropagation === 'function') ev.stopImmediatePropagation(); - if (typeof ev.stopPropagation === 'function') ev.stopPropagation(); - ev.cancelBubble = true; - suppressSequence = false; - } - } catch (_) {} - }; - - const onDocPointerDownCapture = (ev) => { - try { - if (!popup) return; - const root = popup.getElement && popup.getElement(); - if (!root) return; - if (!root.contains(ev.target)) { - try { console.debug('ANNOT: document outside pointerdown; closing popup'); } catch (_) {} - try { popup.remove(); } catch (_) {} - annotationPopupRef.current = null; - } - } catch (_) {} - }; - - const onDrawUpdateClose = () => { - try { - if (popup) { - try { console.debug('ANNOT: draw.update -> closing popup'); } catch (_) {} - popup.remove(); - } - try { if (arrowOverlayRef.current) { console.debug('ANNOT: draw.update -> closing arrow overlay'); } } catch (_) {} - try { - const ref = arrowOverlayRef.current; - if (ref && ref.root && ref.mount) { - try { ref.root.unmount(); } catch (_) {} - try { if (ref.mount.parentNode) ref.mount.parentNode.removeChild(ref.mount); } catch (_) {} - } - } catch (_) {} - arrowOverlayRef.current = null; - annotationPopupRef.current = null; - } catch (_) {} - }; - - // Capture-phase listeners on the canvas - try { canvas.addEventListener('mousedown', onMouseDownCapture, true); } catch (_) {} - try { canvas.addEventListener('mouseup', onMouseUpCapture, true); } catch (_) {} - try { canvas.addEventListener('click', onClickCapture, true); } catch (_) {} - try { canvas.addEventListener('dblclick', onDblClickCapture, true); } catch (_) {} - // Global outside-click handler (capture) - try { document.addEventListener('pointerdown', onDocPointerDownCapture, true); } catch (_) {} - // Close on Draw updates (moving annotations) - try { map.on('draw.update', onDrawUpdateClose); } catch (_) {} - // Also close on feature deletion and selection changes - try { map.on('draw.delete', onDrawUpdateClose); } catch (_) {} - try { map.on('draw.selectionchange', onDrawUpdateClose); } catch (_) {} - try { map.on('draw.modechange', onDrawUpdateClose); } catch (_) {} - - return () => { - try { canvas.removeEventListener('mousedown', onMouseDownCapture, true); } catch (_) {} - try { canvas.removeEventListener('mouseup', onMouseUpCapture, true); } catch (_) {} - try { canvas.removeEventListener('click', onClickCapture, true); } catch (_) {} - try { canvas.removeEventListener('dblclick', onDblClickCapture, true); } catch (_) {} - try { document.removeEventListener('pointerdown', onDocPointerDownCapture, true); } catch (_) {} - try { map.off('draw.update', onDrawUpdateClose); } catch (_) {} - try { map.off('draw.delete', onDrawUpdateClose); } catch (_) {} - try { map.off('draw.selectionchange', onDrawUpdateClose); } catch (_) {} - try { map.off('draw.modechange', onDrawUpdateClose); } catch (_) {} - try { if (popup) { console.debug('ANNOT: cleanup removing popup'); popup.remove(); } } catch (_) {} - try { - const ref = arrowOverlayRef.current; - if (ref && ref.root && ref.mount) { - try { ref.root.unmount(); } catch (_) {} - try { if (ref.mount.parentNode) ref.mount.parentNode.removeChild(ref.mount); } catch (_) {} - } - } catch (_) {} - arrowOverlayRef.current = null; - annotationPopupRef.current = null; - }; - }, [map]); - - // Re-apply hiding of Draw points for text on style changes and mode changes useEffect(() => { if (!map) return; const rerun = () => { try { const style = map.getStyle && map.getStyle(); if (!style) return; - // Reuse logic by triggering a small timeout for ordering setTimeout(() => { try { const layers = style.layers || []; layers.forEach((l) => { - try { - if (!l || !l.id || !l.source) return; - const isDrawSource = l.source === 'mapbox-gl-draw-cold' || l.source === 'mapbox-gl-draw-hot'; - const looksLikePointLayer = typeof l.id === 'string' && (l.id.includes('point') || l.id.includes('point-stroke')); - if (isDrawSource && looksLikePointLayer) { - const existing = map.getFilter && map.getFilter(l.id); - const base = existing || ['==', ['geometry-type'], 'Point']; - const next = ['all', base, ['!=', ['get', 'type'], 'text']]; - map.setFilter(l.id, next); - } - } catch (_) {} + if (!l || !l.id || !l.source) return; + const isDrawSource = l.source === 'mapbox-gl-draw-cold' || l.source === 'mapbox-gl-draw-hot'; + if (!isDrawSource) return; + if (l.type === 'circle' || (typeof l.id === 'string' && (l.id.includes('point') || l.id.includes('point-stroke')))) { + try { + map.setPaintProperty(l.id, 'circle-opacity', 1); + map.setPaintProperty(l.id, 'circle-stroke-opacity', 1); + } catch (_) {} + } }); } catch (_) {} }, 0); @@ -989,7 +535,6 @@ const MapContainer = forwardRef(({ }; }, [map]); - // Expose current rect mode + id for sidebar active highlight useEffect(() => { try { window.__app = Object.assign({}, window.__app || {}, { @@ -1006,18 +551,12 @@ const MapContainer = forwardRef(({ } catch (_) {} }, [drawTools, infrastructure]); - // Compass click handler const handleCompassClick = () => { if (map && map.rotateTo) { map.rotateTo(0, { duration: 500 }); } }; - // Projection toggle - const snapToNearest45 = (deg) => { - const centerOffset = getCenterOffsetForPitch(view?.pitch || 0); - return quantizeToSlices(deg, 8, centerOffset); - }; const handleToggleProjection = () => { if (!map) return; const currentCenter = map.getCenter ? map.getCenter() : null; @@ -1025,26 +564,18 @@ const MapContainer = forwardRef(({ const isIso = (map.getPitch ? map.getPitch() : 0) > 15; const currentBearing = (map.getBearing ? map.getBearing() : 0) || 0; if (isIso) { - // Return to top-down - try { - map.easeTo({ pitch: 0, bearing: currentBearing, center: currentCenter || undefined, zoom: currentZoom, duration: 600 }); - } catch (_) {} + try { map.easeTo({ pitch: 0, bearing: currentBearing, center: currentCenter || undefined, zoom: currentZoom, duration: 600 }); } catch (_) {} } else { - // Go to isometric: high pitch, snap bearing to nearest 45° - try { - map.easeTo({ pitch: 60, bearing: currentBearing, center: currentCenter || undefined, zoom: currentZoom, duration: 600 }); - } catch (_) {} + try { map.easeTo({ pitch: 60, bearing: currentBearing, center: currentCenter || undefined, zoom: currentZoom, duration: 600 }); } catch (_) {} } }; - // Centralized camera rotation (Q/E and rotateend snap) useCameraRotation({ map, getAreaGeometry: () => (permitAreas?.hasSubFocus ? permitAreas?.subFocusArea?.geometry : permitAreas?.focusedArea?.geometry) || null, isEnabled: true }); - // Delete selected dropped object or selected annotation with Delete/Backspace (select mode only) useGlobalKeymap([ { key: ['Delete', 'Backspace'], @@ -1066,13 +597,11 @@ const MapContainer = forwardRef(({ if (selectedKind === 'rect') { try { clickToPlace.removeDroppedObject(id); } catch (_) {} try { clearSelection(); } catch (_) {} - try { if (annotationPopupRef.current) { annotationPopupRef.current.remove(); annotationPopupRef.current = null; } } catch (_) {} return; } if (selectedKind === 'point') { try { clickToPlace.removeDroppedObject(id); } catch (_) {} try { clearSelection(); } catch (_) {} - try { if (annotationPopupRef.current) { annotationPopupRef.current.remove(); annotationPopupRef.current = null; } } catch (_) {} return; } } @@ -1082,44 +611,30 @@ const MapContainer = forwardRef(({ drawTools.deleteSelectedShape(); } } catch (_) {} - try { if (annotationPopupRef.current) { annotationPopupRef.current.remove(); annotationPopupRef.current = null; } } catch (_) {} - try { if (arrowOverlayRef.current) { const ref = arrowOverlayRef.current; ref.root.unmount(); if (ref.mount.parentNode) ref.mount.parentNode.removeChild(ref.mount); arrowOverlayRef.current = null; } } catch (_) {} } catch (_) {} } } ]); - // Disable double-click zoom when map is loaded to prevent conflicts with permit area selection React.useEffect(() => { if (mapLoaded && map && map.doubleClickZoom) { map.doubleClickZoom.disable(); } }, [mapLoaded, map]); - // Open text editor on text feature creation anywhere useEffect(() => { if (!map) return; - const onCreateAny = (e) => { - try { - const f = e?.features?.[0]; - if (f && f.geometry?.type === 'Point' && f.properties?.type === 'text') { - setTextEditorFeatureId(f.id); - } - setAnnotationsTrigger(v => v + 1); - } catch (_) {} - }; + const onCreateAny = (e) => { setAnnotationsTrigger(v => v + 1); }; map.on('draw.create', onCreateAny); return () => { try { map.off('draw.create', onCreateAny); } catch (_) {} }; }, [map, drawTools]); - // Refresh derived annotations on updates/deletes as well useEffect(() => { if (!map || !drawTools?.draw?.current) return; const bump = () => setAnnotationsTrigger(v => v + 1); map.on('draw.update', bump); map.on('draw.delete', bump); map.on('draw.selectionchange', bump); - // Ensure we also respond after Style reload or initial Draw render (post-import timing) map.on('draw.render', bump); map.on('style.load', bump); return () => { @@ -1131,7 +646,6 @@ const MapContainer = forwardRef(({ }; }, [map, drawTools]); - // Listen for rectangle draw completion to convert into dropped object and remove draw feature useEffect(() => { if (!map) return; const onCreate = (e) => { @@ -1139,11 +653,9 @@ const MapContainer = forwardRef(({ const f = e?.features?.[0]; if (!f || f.geometry?.type !== 'Polygon') return; const typeId = f.properties?.user_rectObjectType; - // If a rectangle-object type was set, this is an equipment rectangle → convert to dropped object if (typeId) { const objectType = placeableObjects?.find(p => p.id === typeId); if (!objectType) return; - // Build dropped object const coords = f.geometry.coordinates?.[0] || []; if (coords.length < 4) return; const centroid = { lng: (coords[0][0] + coords[2][0]) / 2, lat: (coords[0][1] + coords[2][1]) / 2 }; @@ -1164,16 +676,12 @@ const MapContainer = forwardRef(({ try { drawTools.draw.current.delete(f.id); } catch (_) {} return; } - - // If sub-focus mode is armed and this is a regular polygon (not an equipment rectangle), - // treat it as the sub-focus scope and do NOT persist the draw feature as an annotation if (subFocusArmedRef.current && !typeId && permitAreas?.focusedArea && permitAreas?.setSubFocusPolygon) { const ok = permitAreas.setSubFocusPolygon({ type: 'Feature', properties: {}, geometry: f.geometry }); try { drawTools.draw.current.delete(f.id); } catch (_) {} subFocusArmedRef.current = false; if (ok) return; } - // If a text annotation was created, open inline editor (also handle when created via point tool then tagged) setAnnotationsTrigger(v => v + 1); } catch (err) { console.warn('Failed to convert rect feature to dropped object', err); @@ -1183,29 +691,6 @@ const MapContainer = forwardRef(({ return () => { try { map.off('draw.create', onCreate); } catch (_) {} }; }, [map, drawTools, placeableObjects, clickToPlace, permitAreas]); - if (DEBUG) console.log('MapContainer: Rendering with map instance', { - hasMap: !!map, - hasProject: map && typeof map.project === 'function', - mapLoaded, - droppedObjectsCount: droppedObjects?.length || 0 - }); - - // Utility: point-in-polygon for selection (lon/lat ring) - const pointInPolygon = (point, ring) => { - try { - let inside = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const xi = ring[i][0], yi = ring[i][1]; - const xj = ring[j][0], yj = ring[j][1]; - const intersect = ((yi > point[1]) !== (yj > point[1])) && - (point[0] < (xj - xi) * (point[1] - yi) / ((yj - yi) || 1e-12) + xi); - if (intersect) inside = !inside; - } - return inside; - } catch (_) { return false; } - }; - - // Selection controller const { handleClick: handleSelectionClick } = useSelectionController({ map, placeableObjects, @@ -1215,17 +700,12 @@ const MapContainer = forwardRef(({ setSelectedPointId: (id) => select(id, 'point') }); - // Clear selection when clicking off dropped objects and rectangles useEffect(() => { if (!map) return; const onBackgroundClick = (e) => { try { if (placementMode) return; - // If another handler consumed this click, don't clear - try { - if (e && (e.defaultPrevented || (e.originalEvent && e.originalEvent.defaultPrevented))) return; - } catch (_) {} - + try { if (e && (e.defaultPrevented || (e.originalEvent && e.originalEvent.defaultPrevented))) return; } catch (_) {} const layerIds = []; try { if (map.getLayer && map.getLayer('dropped-objects-symbol')) layerIds.push('dropped-objects-symbol'); } catch (_) {} try { if (map.getLayer && map.getLayer('dropped-objects-circle')) layerIds.push('dropped-objects-circle'); } catch (_) {} @@ -1234,44 +714,14 @@ const MapContainer = forwardRef(({ try { if (map.getLayer && map.getLayer('dropped-rectangles-pattern')) layerIds.push('dropped-rectangles-pattern'); } catch (_) {} try { if (map.getLayer && map.getLayer('dropped-rectangles-line')) layerIds.push('dropped-rectangles-line'); } catch (_) {} try { if (map.getLayer && map.getLayer('dropped-rectangles-handles')) layerIds.push('dropped-rectangles-handles'); } catch (_) {} - - const hits = (map.queryRenderedFeatures && typeof e?.point !== 'undefined') - ? map.queryRenderedFeatures(e.point, { layers: layerIds }) - : []; - if (!hits || hits.length === 0) { - clearSelection(); - } + const hits = (map.queryRenderedFeatures && typeof e?.point !== 'undefined') ? map.queryRenderedFeatures(e.point, { layers: layerIds }) : []; + if (!hits || hits.length === 0) { clearSelection(); } } catch (_) {} }; try { map.on('click', onBackgroundClick); } catch (_) {} return () => { try { map.off('click', onBackgroundClick); } catch (_) {} }; }, [map, placementMode, clearSelection]); - // Keyboard handler for dimensions editor (press 'D' when rectangle is selected) - useGlobalKeymap([ - { - key: ['d', 'D'], - preventDefault: true, - priority: 60, - enabled: () => { - try { - const ae = typeof document !== 'undefined' ? document.activeElement : null; - if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return false; - } catch (_) {} - return true; - }, - onEvent: () => { - try { - if (selectedKind === 'rect' && selectedObjectId) { - const obj = droppedObjects?.find(o => o.id === selectedObjectId); - if (obj) setDimensionsEditingObject(obj); - } - } catch (_) {} - } - } - ]); - - // Rotation controller (handles placement mode and selected objects) useRotationControls({ map, isPlacementActive: !!placementMode, @@ -1282,7 +732,6 @@ const MapContainer = forwardRef(({ const id = selectedObjectId; clickToPlace.updateDroppedObject(id, (prev) => { if (!prev || prev?.geometry?.type !== 'Polygon') return prev; - // Rotate in Web Mercator (map plane) for stability regardless of pitch const newGeom = rotateRectanglePolygonMercator(prev.geometry, delta); const curRot = Number(prev?.properties?.rotationDeg || 0); let nextRot = normalizeAngle(curRot + delta); @@ -1298,7 +747,6 @@ const MapContainer = forwardRef(({ if (!prev) return prev; const cur = Number(prev?.properties?.rotationDeg || 0); let next = normalizeAngle(cur + deltaDeg); - // Free continuous rotation - no snapping in 2D mode const nextProps = Object.assign({}, prev.properties || {}, { rotationDeg: next }); return { ...prev, properties: nextProps }; }); @@ -1312,8 +760,7 @@ const MapContainer = forwardRef(({ const d = drawTools.draw && drawTools.draw.current; if (!d || !id) return; const f = d.get(id); if (!f || !f.geometry) return; const g = f.geometry; - // Rotate in WebMercator (map plane) for stability regardless of pitch - const R = 6378137; // meters + const R = 6378137; const toMerc = ([lng, lat]) => { const x = R * (lng * Math.PI / 180); const y = R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI / 180) / 2)); @@ -1324,17 +771,14 @@ const MapContainer = forwardRef(({ const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * 180 / Math.PI; return [lng, lat]; }; - const coordsAll = (g.type === 'LineString') ? g.coordinates - : (g.type === 'Polygon') ? (g.coordinates[0] || []) : null; + const coordsAll = (g.type === 'LineString') ? g.coordinates : (g.type === 'Polygon') ? (g.coordinates[0] || []) : null; if (!Array.isArray(coordsAll) || coordsAll.length < 2) return; const mercPts = coordsAll.map(toMerc); - // Centroid in mercator let cx = 0, cy = 0; mercPts.forEach(([x, y]) => { cx += x; cy += y; }); cx /= mercPts.length; cy /= mercPts.length; const rad = deltaDeg * Math.PI / 180; - const cos = Math.cos(rad); - const sin = Math.sin(rad); + const cos = Math.cos(rad); const sin = Math.sin(rad); const rotateMerc = ([x, y]) => { const dx = x - cx, dy = y - cy; const rx = cx + dx * cos - dy * sin; @@ -1362,26 +806,9 @@ const MapContainer = forwardRef(({ return (
- {/* Compass + Projection Toggle Overlay */} -
- - -
- {/* Map root container (receives MapLibre canvas) */} -
- - {/* Placement/interaction overlay above the map (only active during placement) */} -
{ - try { - // If an upstream handler already handled this event (e.g., annotation mousedown/click), skip - if (e && (e.defaultPrevented || (e.nativeEvent && e.nativeEvent.defaultPrevented))) return; - if (e && e.cancelBubble) return; - } catch (_) {} + + {isLoading && } + +
+
{ + try { if (e && (e.defaultPrevented || (e.nativeEvent && e.nativeEvent.defaultPrevented))) return; if (e && e.cancelBubble) return; } catch (_) {} try { handleMapClick(e); } catch (_) {} - try { - if (e && (e.defaultPrevented || (e.nativeEvent && e.nativeEvent.defaultPrevented))) return; - if (e && e.cancelBubble) return; - } catch (_) {} - // Only run selection logic when not in placement mode - if (!placementMode) { - handleSelectionClick(e); - } + try { if (e && (e.defaultPrevented || (e.nativeEvent && e.nativeEvent.defaultPrevented))) return; if (e && e.cancelBubble) return; } catch (_) {} + if (!placementMode) { handleSelectionClick(e); } }} /> - setNoteEditingObject(obj)} - isNoteEditing={!!noteEditingObject} - selectedId={selectedKind === 'point' ? selectedObjectId : null} - onSelectObject={(obj) => { + setNoteEditingObject(obj)} isNoteEditing={!!noteEditingObject} selectedId={selectedKind === 'point' ? selectedObjectId : null} onSelectObject={(obj) => { try { const t = placeableObjects.find(p => p.id === obj.type); - if (t?.geometryType === 'rect') { - select(obj.id, 'rect'); - } else { - // Select any non-rect point object for rotation - select(obj.id, 'point'); - } + if (t?.geometryType === 'rect') { select(obj.id, 'rect'); } else { select(obj.id, 'point'); } } catch (_) {} - }} - onMoveObject={(id, lng, lat) => { - try { - clickToPlace.updateDroppedObject(id, (prev) => { - if (!prev) return prev; - return { ...prev, position: { lng, lat } }; - }); - } catch (_) {} - }} - areaBearingDeg={areaBearingDeg} + }} onMoveObject={(id, lng, lat) => { + try { clickToPlace.updateDroppedObject(id, (prev) => { if (!prev) return prev; return { ...prev, position: { lng, lat } }; }); } catch (_) {} + }} areaBearingDeg={areaBearingDeg} /> - {/* DOM overlay text labels to ensure highest z-order over map and SVG overlays */}
{domAnnotationLabels.map((l) => ( -
+
{l.label}
))}
- {noteEditingObject && (
- {/* Capture wheel/drag to disable map interactions while editing */}
e.preventDefault()} onMouseDown={(e) => e.preventDefault()} /> - { - clickToPlace.setDroppedObjectNote(noteEditingObject.id, text); - setNoteEditingObject(null); - }} - onCancel={() => setNoteEditingObject(null)} - /> + { clickToPlace.setDroppedObjectNote(noteEditingObject.id, text); setNoteEditingObject(null); }} onCancel={() => setNoteEditingObject(null)} />
)} {dimensionsEditingObject && (
e.preventDefault()} onMouseDown={(e) => e.preventDefault()} /> - { - // Resize rectangle to exact dimensions + { try { clickToPlace.updateDroppedObject(dimensionsEditingObject.id, (prev) => { if (!prev || prev?.geometry?.type !== 'Polygon') return prev; - - // Get current geometry and rotation const ring = prev?.geometry?.coordinates?.[0] || []; if (ring.length < 4) return prev; - - const centroid = prev.position || { - lng: (ring[0][0] + ring[2][0]) / 2, - lat: (ring[0][1] + ring[2][1]) / 2 - }; + const centroid = prev.position || { lng: (ring[0][0] + ring[2][0]) / 2, lat: (ring[0][1] + ring[2][1]) / 2 }; const rotationDeg = Number(prev?.properties?.rotationDeg || 0); - - // Build new rectangle with exact dimensions - // Convert to Web Mercator for accurate sizing const R = 6378137; - const toMerc = (lng, lat) => { - const x = R * (lng * Math.PI / 180); - const y = R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI / 180) / 2)); - return { x, y }; - }; - const toLngLat = (x, y) => { - const lng = (x / R) * 180 / Math.PI; - const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * 180 / Math.PI; - return [lng, lat]; - }; - + const toMerc = (lng, lat) => { const x = R * (lng * Math.PI / 180); const y = R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI / 180) / 2)); return { x, y }; }; + const toLngLat = (x, y) => { const lng = (x / R) * 180 / Math.PI; const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * 180 / Math.PI; return [lng, lat]; }; const center = toMerc(centroid.lng, centroid.lat); - const halfW = widthMeters / 2; - const halfH = heightMeters / 2; - - // Create corners relative to center + const halfW = widthMeters / 2; const halfH = heightMeters / 2; const rad = (rotationDeg * Math.PI) / 180; - const cos = Math.cos(rad); - const sin = Math.sin(rad); - - const corners = [ - [-halfW, -halfH], - [halfW, -halfH], - [halfW, halfH], - [-halfW, halfH] - ].map(([dx, dy]) => { - const rx = dx * cos - dy * sin; - const ry = dx * sin + dy * cos; + const cos = Math.cos(rad); const sin = Math.sin(rad); + const corners = [[-halfW, -halfH], [halfW, -halfH], [halfW, halfH], [-halfW, halfH]].map(([dx, dy]) => { + const rx = dx * cos - dy * sin; const ry = dx * sin + dy * cos; return toLngLat(center.x + rx, center.y + ry); }); - - const newGeom = { - type: 'Polygon', - coordinates: [[...corners, corners[0]]] - }; - - return { - ...prev, - geometry: newGeom, - properties: { - ...prev.properties, - dimensions: { width: widthMeters, height: heightMeters } - } - }; + const newGeom = { type: 'Polygon', coordinates: [[...corners, corners[0]]] }; + return { ...prev, geometry: newGeom, properties: { ...prev.properties, dimensions: { width: widthMeters, height: heightMeters } } }; }); - } catch (err) { - console.error('Failed to update rectangle dimensions:', err); - } + } catch (err) { console.error('Failed to update rectangle dimensions:', err); } setDimensionsEditingObject(null); - }} - onCancel={() => setDimensionsEditingObject(null)} - /> + }} onCancel={() => setDimensionsEditingObject(null)} />
)} - {/* Labels now rendered via map symbol layer from derivedAnnotations; disable HTML overlay */} - - {textEditorFeatureId && ( - { setTextEditorFeatureId(null); setAnnotationsTrigger(v => v + 1); }} - onCancel={() => setTextEditorFeatureId(null)} - /> - )} - - {/* Placement Preview */} - - - {/* Floating per-instance nudge markers */} - - - - + + + {!mapLoaded && } - {drawTools?.activeTool && } + {!drawTools?.activeTool && !permitAreas.clickedTooltip?.visible && ( )} + {permitAreas.clickedTooltip?.visible && ( )} + {permitAreas.showOverlapSelector && ( )} - {/* Only show hover tooltip when not drawing and no clicked popover is visible */} - {!drawTools?.activeTool && !permitAreas.clickedTooltip?.visible && ( - - )} - - {/* Clicked popover (parks mode), persists and follows camera */} - {permitAreas.clickedTooltip?.visible && ( - - )} - - {permitAreas.showOverlapSelector && ( - - )} - {/* MapLibre-based rectangle rendering for proper z-ordering */} - {/* Rectangle action buttons overlay (edit dimensions, delete) - updates with map view */} {selectedKind === 'rect' && selectedObjectId && map && rectMovingId !== selectedObjectId && (() => { const rect = droppedObjects.find(o => o.id === selectedObjectId); if (!rect || !rect.geometry?.coordinates?.[0] || rect.geometry.coordinates[0].length < 4) return null; - - // Calculate centroid for button positioning const ring = rect.geometry.coordinates[0]; - const centroid = [ - (ring[0][0] + ring[2][0]) / 2, - (ring[0][1] + ring[2][1]) / 2 - ]; - + const centroid = [(ring[0][0] + ring[2][0]) / 2, (ring[0][1] + ring[2][1]) / 2]; try { const screenPos = map.project(centroid); - // Consume view.renderTick to force re-render on camera move - const _ = view?.renderTick; return ( -
- - +
+ +
); - } catch (_) { - return null; - } + } catch (_) { return null; } })()} {map && ( - select(id, 'rect')} - onResizeRect={(id, newGeom) => { + select(id, 'rect')} onResizeRect={(id, newGeom) => { try { - // Use silent mode - MapLibre source is already updated via direct manipulation clickToPlace.updateDroppedObject(id, (prev) => { if (!prev) return prev; - // Update dimensions label from new geometry using great-circle distances on sides let dims = prev?.properties?.dimensions || prev?.properties?.user_dimensions_m || {}; try { const ring = Array.isArray(newGeom?.coordinates?.[0]) ? newGeom.coordinates[0] : []; - if (ring.length >= 4 && map && typeof map.project === 'function') { - const a = ring[0], b = ring[1], c = ring[2]; - const dist = (p, q) => { - const R = 6378137; // meters - const toRad = (d) => d * Math.PI / 180; - const dLat = toRad(q[1] - p[1]); - const dLon = toRad(q[0] - p[0]); - const lat1 = toRad(p[1]); - const lat2 = toRad(q[1]); - const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; - const d = 2 * R * Math.asin(Math.min(1, Math.sqrt(s))); - return d; - }; - const wMeters = dist(ring[0], ring[1]); - const hMeters = dist(ring[1], ring[2]); - dims = { width: wMeters, height: hMeters }; + if (ring.length >= 4) { + const dist = (p, q) => { const R = 6378137; const toRad = (d) => d * Math.PI / 180; const dLat = toRad(q[1] - p[1]); const dLon = toRad(q[0] - p[0]); const lat1 = toRad(p[1]); const lat2 = toRad(q[1]); const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.min(1, Math.sqrt(s))); }; + dims = { width: dist(ring[0], ring[1]), height: dist(ring[1], ring[2]) }; } } catch (_) {} - const nextProps = Object.assign({}, prev.properties || {}, { dimensions: dims }); - return { ...prev, geometry: newGeom, properties: nextProps }; - }, true); // silent=true - don't trigger objectUpdateTrigger + return { ...prev, geometry: newGeom, properties: { ...prev.properties, dimensions: dims } }; + }, true); } catch (_) {} - }} - onMoveRect={(id, newGeom) => { + }} onMoveRect={(id, newGeom) => { try { - // Use silent mode - MapLibre source is already updated via direct manipulation clickToPlace.updateDroppedObject(id, (prev) => { if (!prev) return prev; - // Update geometry and centroid position when moving const ring = Array.isArray(newGeom?.coordinates?.[0]) ? newGeom.coordinates[0] : []; - const centroid = ring.length >= 4 ? { - lng: (ring[0][0] + ring[2][0]) / 2, - lat: (ring[0][1] + ring[2][1]) / 2 - } : prev.position; + const centroid = ring.length >= 4 ? { lng: (ring[0][0] + ring[2][0]) / 2, lat: (ring[0][1] + ring[2][1]) / 2 } : prev.position; return { ...prev, geometry: newGeom, position: centroid }; - }, true); // silent=true - don't trigger objectUpdateTrigger + }, true); } catch (_) {} }} /> )} - +
); }); diff --git a/src/components/Sidebar/CustomShapesList.jsx b/src/components/Sidebar/CustomShapesList.jsx index 9d24f05..8f00df5 100644 --- a/src/components/Sidebar/CustomShapesList.jsx +++ b/src/components/Sidebar/CustomShapesList.jsx @@ -31,7 +31,7 @@ const CustomShapesList = ({ const handleSaveEdit = (e) => { e.stopPropagation(); if (onShapeRename && editingShape) { - onShapeRename(editingShape, editValue); + onShapeRename(editingShape, { label: editValue }); } setEditingShape(null); setEditValue(''); @@ -150,9 +150,15 @@ const CustomShapesList = ({ + const SHAPE_TYPE_NAMES = { + 'Point': 'Point', + 'LineString': 'Line', + 'Polygon': 'Polygon' + }; + const customShapes = filteredFeatures.map(feature => ({ id: feature.id, - type: feature.geometry.type, + type: SHAPE_TYPE_NAMES[feature.geometry.type] || feature.geometry.type, label: feature.properties?.label || '', properties: feature.properties })); diff --git a/src/components/Sidebar/DrawingTools.jsx b/src/components/Sidebar/DrawingTools.jsx index b4ac9ff..7fe399e 100644 --- a/src/components/Sidebar/DrawingTools.jsx +++ b/src/components/Sidebar/DrawingTools.jsx @@ -13,9 +13,7 @@ const DrawingTools = ({ activeTool, onToolSelect, selectedShape, onDelete, drawA const tools = [ { id: 'point', icon: Dot, title: 'Point' }, { id: 'line', icon: DashedLineIcon, title: 'Line' }, - { id: 'polygon', icon: Square, title: 'Polygon' }, - { id: 'text', icon: Type, title: 'Text' }, - { id: 'arrow', icon: ArrowRight, title: 'Arrow' } + { id: 'polygon', icon: Square, title: 'Polygon' } ]; return ( @@ -43,7 +41,7 @@ const DrawingTools = ({ activeTool, onToolSelect, selectedShape, onDelete, drawA
)} -
+
{tools.map(({ id, icon: Icon, title }) => (
{drawTools.selectedShape && ( ({ id: f.id, type: f.geometry.type, label: f.properties?.label }))} + draw={drawTools.draw} + onUpdateShape={drawTools.updateShape} + onDeleteShape={drawTools.deleteSelectedShape} /> )}
@@ -179,7 +181,7 @@ const RightSidebar = ({ selectedShape={drawTools.selectedShape} onShapeSelect={drawTools.selectShape} draw={drawTools.draw} - onShapeRename={drawTools.renameShape} + onShapeRename={drawTools.updateShape} showLabels={drawTools.showLabels} onToggleLabels={drawTools.setShowLabels} onCountChange={(n) => setAnnotationCount(n)} diff --git a/src/components/Sidebar/ShapeProperties.jsx b/src/components/Sidebar/ShapeProperties.jsx index 85cdbdc..68bca58 100644 --- a/src/components/Sidebar/ShapeProperties.jsx +++ b/src/components/Sidebar/ShapeProperties.jsx @@ -1,61 +1,82 @@ import React, { useState, useEffect } from 'react'; -import { Type } from 'lucide-react'; +import { Type, X } from 'lucide-react'; const ShapeProperties = ({ selectedShape, customShapes, draw, - onUpdateShape + onUpdateShape, + onDeleteShape }) => { const [shapeLabel, setShapeLabel] = useState(''); const [textSize, setTextSize] = useState(14); const [textColor, setTextColor] = useState('#111827'); const [halo, setHalo] = useState(true); + const [arrowStart, setArrowStart] = useState(false); + const [arrowEnd, setArrowEnd] = useState(false); + const selectedShapeRef = React.useRef(null); // Update local state when selected shape changes useEffect(() => { if (selectedShape) { - const shape = customShapes.find(s => s.id === selectedShape); - setShapeLabel(shape?.label || ''); - const feature = draw?.current ? draw.current.get(selectedShape) : null; - const p = feature?.properties || {}; - setTextSize(Number(p.textSize || 14)); - setTextColor(p.textColor || '#111827'); - setHalo(p.halo !== false); + // Only reset local fields if the selected shape ID has actually changed + if (selectedShapeRef.current !== selectedShape) { + const shape = customShapes.find(s => s.id === selectedShape); + setShapeLabel(shape?.label || ''); + const feature = draw?.current ? draw.current.get(selectedShape) : null; + const p = feature?.properties || {}; + setTextSize(Number(p.textSize || 14)); + setTextColor(p.textColor || '#111827'); + setHalo(p.halo !== false); + setArrowStart(!!p.arrowStart); + setArrowEnd(!!p.arrowEnd); + selectedShapeRef.current = selectedShape; + } } else { setShapeLabel(''); setTextSize(14); setTextColor('#111827'); setHalo(true); + setArrowStart(false); + setArrowEnd(false); + selectedShapeRef.current = null; } - }, [selectedShape, customShapes]); + }, [selectedShape, customShapes, draw]); - const updateShapeLabel = () => { - if (selectedShape && draw?.current) { - // Update shape in customShapes array - onUpdateShape(selectedShape, { label: shapeLabel }); - - // Update the draw feature properties - const feature = draw.current.get(selectedShape); - if (feature) { - feature.properties.label = shapeLabel; - feature.properties.textSize = Number(textSize) || 14; - feature.properties.textColor = textColor; - feature.properties.halo = !!halo; - draw.current.add(feature); - } + const updateShapeProperties = () => { + if (selectedShape && onUpdateShape) { + onUpdateShape(selectedShape, { + label: shapeLabel, + textSize: Number(textSize) || 14, + textColor, + halo: !!halo, + arrowStart: !!arrowStart, + arrowEnd: !!arrowEnd + }); } }; const handleKeyPress = (e) => { if (e.key === 'Enter') { - updateShapeLabel(); + updateShapeProperties(); } }; if (!selectedShape) return null; + const SHAPE_TYPE_NAMES = { + 'Point': 'Point', + 'LineString': 'Line', + 'Polygon': 'Polygon' + }; + const selectedShapeData = customShapes.find(s => s.id === selectedShape); + + // If the shape ID is set but it doesn't exist in our list anymore, + // it was likely just deleted. Don't render the properties panel. + if (!selectedShapeData) return null; + + const displayType = selectedShapeData ? (SHAPE_TYPE_NAMES[selectedShapeData.type] || selectedShapeData.type) : ''; return (
@@ -66,7 +87,7 @@ const ShapeProperties = ({ {selectedShapeData && (
- Type: {selectedShapeData.type} + Type: {displayType}
)} @@ -83,13 +104,64 @@ const ShapeProperties = ({ className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" />
+ + {selectedShapeData && selectedShapeData.type === 'LineString' && ( +
+ +
+ + +
+
+ )} +
@@ -101,9 +173,40 @@ const ShapeProperties = ({
+ +
+ +
); diff --git a/src/constants/drawStyles.js b/src/constants/drawStyles.js new file mode 100644 index 0000000..e23cfff --- /dev/null +++ b/src/constants/drawStyles.js @@ -0,0 +1,231 @@ +// constants/drawStyles.js +/** + * Canonical styles for Mapbox GL Draw. + * Standardizes on blue (#2563eb) for all features and handles. + * Hides point markers for features tagged as 'text' annotations. + */ +export const DRAW_STYLES = [ + // PUBLIC POLYGON + { + 'id': 'gl-draw-polygon-fill-inactive', + 'type': 'fill', + 'filter': ['all', + ['==', 'active', 'false'], + ['==', '$type', 'Polygon'], + ['!=', 'mode', 'static'] + ], + 'paint': { + 'fill-color': '#2563eb', + 'fill-outline-color': '#2563eb', + 'fill-opacity': 0.1 + } + }, + { + 'id': 'gl-draw-polygon-fill-active', + 'type': 'fill', + 'filter': ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], + 'paint': { + 'fill-color': '#2563eb', + 'fill-outline-color': '#2563eb', + 'fill-opacity': 0.1 + } + }, + { + 'id': 'gl-draw-polygon-midpoint', + 'type': 'circle', + 'filter': ['all', + ['==', '$type', 'Point'], + ['==', 'meta', 'midpoint'] + ], + 'paint': { + 'circle-radius': 3, + 'circle-color': '#2563eb' + } + }, + { + 'id': 'gl-draw-polygon-stroke-inactive', + 'type': 'line', + 'filter': ['all', + ['==', 'active', 'false'], + ['==', '$type', 'Polygon'], + ['!=', 'mode', 'static'] + ], + 'layout': { + 'line-cap': 'round', + 'line-join': 'round' + }, + 'paint': { + 'line-color': '#2563eb', + 'line-width': 2 + } + }, + { + 'id': 'gl-draw-polygon-stroke-active', + 'type': 'line', + 'filter': ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], + 'layout': { + 'line-cap': 'round', + 'line-join': 'round' + }, + 'paint': { + 'line-color': '#2563eb', + 'line-dasharray': [0.2, 2], + 'line-width': 2 + } + }, + // PUBLIC LINE + { + 'id': 'gl-draw-line-inactive', + 'type': 'line', + 'filter': ['all', + ['==', 'active', 'false'], + ['==', '$type', 'LineString'], + ['!=', 'mode', 'static'] + ], + 'layout': { + 'line-cap': 'round', + 'line-join': 'round' + }, + 'paint': { + 'line-color': '#2563eb', + 'line-width': 3 + } + }, + { + 'id': 'gl-draw-line-active', + 'type': 'line', + 'filter': ['all', + ['==', '$type', 'LineString'], + ['==', 'active', 'true'] + ], + 'layout': { + 'line-cap': 'round', + 'line-join': 'round' + }, + 'paint': { + 'line-color': '#2563eb', + 'line-dasharray': [0.2, 2], + 'line-width': 3 + } + }, + // PUBLIC POINT + { + 'id': 'gl-draw-point-point-stroke-inactive', + 'type': 'circle', + 'filter': ['all', + ['==', 'active', 'false'], + ['==', '$type', 'Point'], + ['==', 'meta', 'feature'], + ['!=', 'mode', 'static'] + ], + 'paint': { + 'circle-radius': 5, + 'circle-color': '#fff' + } + }, + { + 'id': 'gl-draw-point-inactive', + 'type': 'circle', + 'filter': ['all', + ['==', 'active', 'false'], + ['==', '$type', 'Point'], + ['==', 'meta', 'feature'], + ['!=', 'mode', 'static'] + ], + 'paint': { + 'circle-radius': 3, + 'circle-color': '#2563eb' + } + }, + { + 'id': 'gl-draw-point-stroke-active', + 'type': 'circle', + 'filter': ['all', + ['==', '$type', 'Point'], + ['==', 'active', 'true'], + ['==', 'meta', 'feature'] + ], + 'paint': { + 'circle-radius': 7, + 'circle-color': '#fff' + } + }, + { + 'id': 'gl-draw-point-active', + 'type': 'circle', + 'filter': ['all', + ['==', '$type', 'Point'], + ['==', 'active', 'true'], + ['==', 'meta', 'feature'] + ], + 'paint': { + 'circle-radius': 5, + 'circle-color': '#2563eb' + } + }, + // VERTEX + { + 'id': 'gl-draw-polygon-and-line-vertex-stroke-active', + 'type': 'circle', + 'filter': ['all', + ['==', 'meta', 'vertex'], + ['==', '$type', 'Point'], + ['!=', 'mode', 'static'] + ], + 'paint': { + 'circle-radius': 5, + 'circle-color': '#fff' + } + }, + { + 'id': 'gl-draw-polygon-and-line-vertex-active', + 'type': 'circle', + 'filter': ['all', + ['==', 'meta', 'vertex'], + ['==', '$type', 'Point'], + ['!=', 'mode', 'static'] + ], + 'paint': { + 'circle-radius': 3, + 'circle-color': '#2563eb' + } + }, + // STATIC + { + 'id': 'gl-draw-polygon-fill-static', + 'type': 'fill', + 'filter': ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']], + 'paint': { + 'fill-color': '#404040', + 'fill-outline-color': '#404040', + 'fill-opacity': 0.1 + } + }, + { + 'id': 'gl-draw-polygon-stroke-static', + 'type': 'line', + 'filter': ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']], + 'paint': { + 'line-color': '#404040', + 'line-width': 2 + } + }, + { + 'id': 'gl-draw-line-static', + 'type': 'line', + 'filter': ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']], + 'paint': { + 'line-color': '#404040', + 'line-width': 2 + } + }, + { + 'id': 'gl-draw-point-static', + 'type': 'circle', + 'filter': ['all', ['==', 'mode', 'static'], ['==', '$type', 'Point']], + 'paint': { + 'circle-radius': 5, + 'circle-color': '#404040' + } + } +]; diff --git a/src/hooks/useDrawTools.js b/src/hooks/useDrawTools.js index 66414e0..a0b523f 100644 --- a/src/hooks/useDrawTools.js +++ b/src/hooks/useDrawTools.js @@ -1,90 +1,7 @@ // hooks/useDrawTools.js import { useState, useEffect, useRef, useCallback } from 'react'; import RectObjectMode from '../draw-modes/rectObjectMode'; -// Custom modes (to be created): -// - draw_text_annotation: a point placement that prompts for label -// For now, register a placeholder mode that behaves like draw_point -const TextAnnotationMode = { - onSetup() { try { console.debug('DRAW: enter TextAnnotationMode'); } catch (_) {} return {}; }, - onClick(state, e) { - try { console.debug('DRAW: TextAnnotationMode onClick', { lng: e?.lngLat?.lng, lat: e?.lngLat?.lat }); } catch (_) {} - // Delegate to built-in point placement for now - const pt = this.newFeature({ type: 'Feature', properties: { type: 'text', label: '' }, geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] } }); - this.addFeature(pt); - try { console.debug('DRAW: firing draw.create for text'); } catch (_) {} - this.map.fire('draw.create', { features: [pt.toGeoJSON()] }); - try { console.debug('DRAW: changing mode to simple_select for text'); } catch (_) {} - this.changeMode('simple_select', { featureIds: [pt.id] }); - }, - toDisplayFeatures(state, geojson, display) { display(geojson); }, - onStop() { try { console.debug('DRAW: exit TextAnnotationMode'); } catch (_) {} } -}; - -// Custom two-click arrow mode independent of base; creates a 2-point line and finalizes on second click -const ArrowTwoPointMode = { - onSetup() { - try { console.debug('DRAW: enter ArrowTwoPointMode'); } catch (_) {} - try { this.setActionableState({ trash: true }); } catch (_) {} - return { start: null, line: null }; - }, - onClick(state, e) { - try { console.debug('DRAW: ArrowTwoPointMode onClick', { lng: e?.lngLat?.lng, lat: e?.lngLat?.lat, hasStart: !!state.start }); } catch (_) {} - const lng = e.lngLat && e.lngLat.lng; - const lat = e.lngLat && e.lngLat.lat; - if (typeof lng !== 'number' || typeof lat !== 'number') return; - const clicked = [lng, lat]; - - if (!state.start) { - state.start = clicked; - try { - const line = this.newFeature({ - type: 'Feature', - properties: {}, - geometry: { type: 'LineString', coordinates: [clicked, clicked] } - }); - this.addFeature(line); - state.line = line; - try { console.debug('DRAW: ArrowTwoPointMode created preview line', { id: line.id }); } catch (_) {} - } catch (_) {} - return; - } - - try { - if (state.line && state.line.updateCoordinate) { - state.line.updateCoordinate(1, clicked[0], clicked[1]); - } - try { console.debug('DRAW: firing draw.create for arrow'); } catch (_) {} - try { this.map.fire('draw.create', { features: [state.line.toGeoJSON()] }); } catch (_) {} - try { console.debug('DRAW: changing mode to simple_select for arrow'); } catch (_) {} - this.changeMode('simple_select', { featureIds: [state.line.id] }); - } catch (_) {} - }, - onMouseMove(state, e) { - try { if (!state.start || !state.line) return; console.debug('DRAW: ArrowTwoPointMode onMouseMove'); } catch (_) {} - try { - const lng = e.lngLat && e.lngLat.lng; - const lat = e.lngLat && e.lngLat.lat; - if (typeof lng !== 'number' || typeof lat !== 'number') return; - if (state.line.updateCoordinate) state.line.updateCoordinate(1, lng, lat); - } catch (_) {} - }, - toDisplayFeatures(state, geojson, display) { display(geojson); }, - onStop(state) { - try { console.debug('DRAW: exit ArrowTwoPointMode'); } catch (_) {} - try { - const gj = state.line && state.line.toGeoJSON ? state.line.toGeoJSON() : null; - const coords = gj && gj.geometry && Array.isArray(gj.geometry.coordinates) ? gj.geometry.coordinates : []; - if (!coords || coords.length < 2) { - try { this.deleteFeature([state.line.id]); } catch (_) {} - } - } catch (_) {} - }, - onTrash(state) { - try { console.debug('DRAW: ArrowTwoPointMode onTrash'); } catch (_) {} - try { if (state.line) this.deleteFeature([state.line.id]); } catch (_) {} - this.changeMode('simple_select'); - } -}; +import { DRAW_STYLES } from '../constants/drawStyles'; export const useDrawTools = (map, focusedArea = null) => { const draw = useRef(null); @@ -98,6 +15,7 @@ export const useDrawTools = (map, focusedArea = null) => { const [drawInitialized, setDrawInitialized] = useState(false); const [showLabels, setShowLabelsState] = useState(true); const [activeRectObjectTypeId, setActiveRectObjectTypeId] = useState(null); + const [renderTrigger, setRenderTrigger] = useState(0); const setShowLabels = useCallback((value) => { setShowLabelsState(value); @@ -121,35 +39,14 @@ export const useDrawTools = (map, focusedArea = null) => { setActiveTool(null); return; } - if (currentTool === 'arrow' && feature && feature.geometry && feature.geometry.type === 'LineString') { - // Tag as arrow and normalize to two points - feature.properties = Object.assign({}, feature.properties, { type: 'arrow' }); - const coords = Array.isArray(feature.geometry.coordinates) ? feature.geometry.coordinates : []; - if (coords.length > 2) { - feature.geometry.coordinates = [coords[0], coords[coords.length - 1]]; - } - try { draw.current && draw.current.add(feature); } catch (_) {} - // If custom mode already switched to simple_select, don't toggle again here - if (currentMode !== 'simple_select') { - try { draw.current && draw.current.changeMode('simple_select', { featureIds: [feature.id] }); } catch (_) {} - setActiveTool(null); - } - } else if (currentTool === 'text' && feature && feature.geometry && feature.geometry.type === 'Point') { - feature.properties = Object.assign({}, feature.properties, { type: 'text', label: feature.properties?.label || '' }); - if (draw.current) draw.current.add(feature); - // Open the inline text editor immediately for convenience - try { window.dispatchEvent(new CustomEvent('annotations:changed')); } catch (_) {} - try { if (map && typeof map.fire === 'function') map.fire('ui:open-text-editor', { featureId: feature.id }); } catch (_) {} - // Toggle off tool after placement - try { draw.current.changeMode('simple_select', { featureIds: [feature.id] }); } catch (_) {} - setActiveTool(null); - } else if (currentTool === 'point' || currentTool === 'line' || currentTool === 'polygon') { + if (currentTool === 'point' || currentTool === 'line' || currentTool === 'polygon') { // For built-in modes, place a single feature then toggle off try { draw.current && draw.current.changeMode('simple_select', { featureIds: [feature.id] }); } catch (_) {} setActiveTool(null); } } catch (_) {} setSelectedShape(feature.id); + setRenderTrigger(v => v + 1); }, handleDrawUpdate: (e) => { console.log('Shape updated:', e.features); @@ -172,48 +69,14 @@ export const useDrawTools = (map, focusedArea = null) => { }; const currentTool = activeToolRef.current; - if (currentTool === 'arrow' && f.geometry.type === 'LineString') { - if (coords.length >= 2) { - try { - const a = coords[coords.length - 2]; - const b = coords[coords.length - 1]; - // Compute compass bearing (0°=north, 90°=east) for Map symbol rotation - const theta = Math.atan2(b[0] - a[0], b[1] - a[1]); - let bearingDeg = (theta * 180 / Math.PI); - if (bearingDeg < 0) bearingDeg += 360; - f.properties = Object.assign({}, f.properties, { type: 'arrow', bearing: bearingDeg }); - f.geometry.coordinates = [coords[0], coords[coords.length - 1]]; - addIfChanged(f); - try { console.debug('DRAW: update -> changeMode simple_select (arrow creation)'); } catch (_) {} - try { draw.current.changeMode('simple_select', { featureIds: [f.id] }); } catch (_) {} - } catch (_) {} - } - return; - } - - if (f.properties && f.properties.type === 'arrow' && f.geometry.type === 'LineString') { - if (coords.length >= 2) { - const a = coords[coords.length - 2]; - const b = coords[coords.length - 1]; - const theta = Math.atan2(b[0] - a[0], b[1] - a[1]); - let bearingDeg = (theta * 180 / Math.PI); - if (bearingDeg < 0) bearingDeg += 360; - f.properties.bearing = bearingDeg; - addIfChanged(f); - } - if (coords.length > 2) { - const trimmed = [coords[0], coords[coords.length - 1]]; - f.geometry.coordinates = trimmed; - addIfChanged(f); - try { console.debug('DRAW: trimmed arrow to 2 points, changeMode simple_select'); } catch (_) {} - try { draw.current.changeMode('simple_select', { featureIds: [f.id] }); } catch (_) {} - } - } } catch (_) {} + setRenderTrigger(v => v + 1); }, handleDrawDelete: (e) => { try { console.debug('DRAW: handleDrawDelete', { ids: e?.features?.map(f => f.id) }); } catch (_) {} setSelectedShape(null); + setShapeLabel(''); + setRenderTrigger(v => v + 1); }, handleSelectionChange: (e) => { try { console.debug('DRAW: handleSelectionChange', { count: e?.features?.length, id: e?.features?.[0]?.id }); } catch (_) {} @@ -225,6 +88,7 @@ export const useDrawTools = (map, focusedArea = null) => { setSelectedShape(null); setShapeLabel(''); } + setRenderTrigger(v => v + 1); } }); @@ -270,10 +134,9 @@ export const useDrawTools = (map, focusedArea = null) => { controls: {}, defaultMode: 'simple_select', userProperties: true, + styles: DRAW_STYLES, modes: Object.assign({}, window.MapboxDraw.modes, { - draw_rect_object: RectObjectMode, - draw_text_annotation: TextAnnotationMode, - draw_arrow_two_point: ArrowTwoPointMode + draw_rect_object: RectObjectMode }) }); @@ -360,11 +223,16 @@ export const useDrawTools = (map, focusedArea = null) => { return () => { if (map && draw.current) { try { - map.off('draw.create', eventHandlers.current.handleDrawCreate); - map.off('draw.update', eventHandlers.current.handleDrawUpdate); - map.off('draw.delete', eventHandlers.current.handleDrawDelete); - map.off('draw.selectionchange', eventHandlers.current.handleSelectionChange); - map.removeControl(draw.current); + // Double check map still exists and has off/removeControl + if (map.off) { + map.off('draw.create', eventHandlers.current.handleDrawCreate); + map.off('draw.update', eventHandlers.current.handleDrawUpdate); + map.off('draw.delete', eventHandlers.current.handleDrawDelete); + map.off('draw.selectionchange', eventHandlers.current.handleSelectionChange); + } + if (map.removeControl) { + map.removeControl(draw.current); + } } catch (error) { console.warn('Error during draw controls cleanup:', error); } @@ -387,17 +255,21 @@ export const useDrawTools = (map, focusedArea = null) => { if (draw.current) { // Rebind by removing/adding control to inject layers for current style try { - map.off('draw.create', eventHandlers.current.handleDrawCreate); - map.off('draw.update', eventHandlers.current.handleDrawUpdate); - map.off('draw.delete', eventHandlers.current.handleDrawDelete); - map.off('draw.selectionchange', eventHandlers.current.handleSelectionChange); + if (map.off) { + map.off('draw.create', eventHandlers.current.handleDrawCreate); + map.off('draw.update', eventHandlers.current.handleDrawUpdate); + map.off('draw.delete', eventHandlers.current.handleDrawDelete); + map.off('draw.selectionchange', eventHandlers.current.handleSelectionChange); + } + } catch (_) {} + try { if (map.removeControl) map.removeControl(draw.current); } catch (_) {} + try { + map.addControl(draw.current); + map.on('draw.create', eventHandlers.current.handleDrawCreate); + map.on('draw.update', eventHandlers.current.handleDrawUpdate); + map.on('draw.delete', eventHandlers.current.handleDrawDelete); + map.on('draw.selectionchange', eventHandlers.current.handleSelectionChange); } catch (_) {} - try { map.removeControl(draw.current); } catch (_) {} - map.addControl(draw.current); - map.on('draw.create', eventHandlers.current.handleDrawCreate); - map.on('draw.update', eventHandlers.current.handleDrawUpdate); - map.on('draw.delete', eventHandlers.current.handleDrawDelete); - map.on('draw.selectionchange', eventHandlers.current.handleSelectionChange); if (existingShapes && existingShapes.features && existingShapes.features.length > 0) { try { draw.current.add(existingShapes); } catch (_) {} } @@ -411,7 +283,10 @@ export const useDrawTools = (map, focusedArea = null) => { controls: {}, defaultMode: 'simple_select', userProperties: true, - modes: Object.assign({}, window.MapboxDraw.modes, { draw_rect_object: RectObjectMode }) + styles: DRAW_STYLES, + modes: Object.assign({}, window.MapboxDraw.modes, { + draw_rect_object: RectObjectMode + }) }); draw.current = drawInstance; map.addControl(drawInstance); @@ -469,24 +344,6 @@ export const useDrawTools = (map, focusedArea = null) => { // Use standard polygon mode but tag on create via handler above try { draw.current.changeMode('draw_polygon'); } catch (_) { draw.current.changeMode('simple_select'); } break; - case 'text': - // Use custom text annotation mode to ensure type is set before draw.create - try { - draw.current.changeMode('draw_text_annotation'); - } catch (_) { - // Fallback to point; handleDrawCreate will tag, but editor may not auto-open - draw.current.changeMode('draw_point'); - } - break; - case 'arrow': - // Use custom two-point arrow mode - try { - draw.current.changeMode('draw_arrow_two_point'); - } catch (_) { - // Fallback to line mode; tagging happens on create - draw.current.changeMode('draw_line_string'); - } - break; default: draw.current.changeMode('simple_select'); setActiveTool(null); @@ -531,29 +388,37 @@ export const useDrawTools = (map, focusedArea = null) => { if (feature) { feature.properties.label = shapeLabel; draw.current.add(feature); + setRenderTrigger(v => v + 1); try { window.dispatchEvent(new CustomEvent('annotations:changed')); } catch (_) {} } } }, [selectedShape, shapeLabel]); - // Rename a specific shape - const renameShape = useCallback((shapeId, newLabel) => { + // Update properties of a specific shape + const updateShape = useCallback((shapeId, updates) => { if (!draw.current) return; const feature = draw.current.get(shapeId); if (feature) { - feature.properties.label = newLabel; + feature.properties = Object.assign({}, feature.properties || {}, updates); draw.current.add(feature); + setRenderTrigger(v => v + 1); try { window.dispatchEvent(new CustomEvent('annotations:changed')); } catch (_) {} } - console.log('Shape renamed:', shapeId, 'to:', newLabel); + console.log('Shape updated:', shapeId, 'with:', updates); }, []); // Delete selected shape const deleteSelectedShape = useCallback(() => { if (selectedShape && draw.current) { - draw.current.delete(selectedShape); + const idToDelete = selectedShape; + // Change mode to clear internal selection state before deleting + try { draw.current.changeMode('simple_select', { featureIds: [] }); } catch (_) {} + draw.current.delete(idToDelete); + setSelectedShape(null); + setShapeLabel(''); + setRenderTrigger(v => v + 1); } }, [selectedShape]); @@ -562,6 +427,7 @@ export const useDrawTools = (map, focusedArea = null) => { if (draw.current) { draw.current.changeMode('simple_select', { featureIds: [shapeId] }); setSelectedShape(shapeId); + setRenderTrigger(v => v + 1); } }, []); @@ -572,6 +438,7 @@ export const useDrawTools = (map, focusedArea = null) => { setSelectedShape(null); setShapeLabel(''); console.log('All custom shapes cleared'); + setRenderTrigger(v => v + 1); } }, []); @@ -588,17 +455,21 @@ export const useDrawTools = (map, focusedArea = null) => { if (draw.current) { // Rebind by removing/adding control to inject layers for current style try { - map.off('draw.create', eventHandlers.current.handleDrawCreate); - map.off('draw.update', eventHandlers.current.handleDrawUpdate); - map.off('draw.delete', eventHandlers.current.handleDrawDelete); - map.off('draw.selectionchange', eventHandlers.current.handleSelectionChange); + if (map.off) { + map.off('draw.create', eventHandlers.current.handleDrawCreate); + map.off('draw.update', eventHandlers.current.handleDrawUpdate); + map.off('draw.delete', eventHandlers.current.handleDrawDelete); + map.off('draw.selectionchange', eventHandlers.current.handleSelectionChange); + } + } catch (_) {} + try { if (map.removeControl) map.removeControl(draw.current); } catch (_) {} + try { + map.addControl(draw.current); + map.on('draw.create', eventHandlers.current.handleDrawCreate); + map.on('draw.update', eventHandlers.current.handleDrawUpdate); + map.on('draw.delete', eventHandlers.current.handleDrawDelete); + map.on('draw.selectionchange', eventHandlers.current.handleSelectionChange); } catch (_) {} - try { map.removeControl(draw.current); } catch (_) {} - map.addControl(draw.current); - map.on('draw.create', eventHandlers.current.handleDrawCreate); - map.on('draw.update', eventHandlers.current.handleDrawUpdate); - map.on('draw.delete', eventHandlers.current.handleDrawDelete); - map.on('draw.selectionchange', eventHandlers.current.handleSelectionChange); if (existingShapes && existingShapes.features && existingShapes.features.length > 0) { try { draw.current.add(existingShapes); } catch (_) {} } @@ -612,7 +483,10 @@ export const useDrawTools = (map, focusedArea = null) => { controls: {}, defaultMode: 'simple_select', userProperties: true, - modes: Object.assign({}, window.MapboxDraw.modes, { draw_rect_object: RectObjectMode }) + styles: DRAW_STYLES, + modes: Object.assign({}, window.MapboxDraw.modes, { + draw_rect_object: RectObjectMode + }) }); draw.current = drawInstance; map.addControl(drawInstance); @@ -662,7 +536,7 @@ export const useDrawTools = (map, focusedArea = null) => { setShapeLabel, activateDrawingTool, updateShapeLabel, - renameShape, + updateShape, deleteSelectedShape, selectShape, clearCustomShapes, diff --git a/src/hooks/usePermitAreas.js b/src/hooks/usePermitAreas.js index 0e8510b..1db307d 100644 --- a/src/hooks/usePermitAreas.js +++ b/src/hooks/usePermitAreas.js @@ -1208,7 +1208,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => { // Ignore clicks that intersect annotation layers to avoid clashing with annotation popup try { const pt = [e.point.x, e.point.y]; - const layers = ['annotation-text', 'annotation-arrows', 'annotation-arrowheads']; + const layers = ['annotation-text', 'annotation-arrowheads']; const annHits = map.queryRenderedFeatures && map.queryRenderedFeatures(pt, { layers }); if (annHits && annHits.length) { try { console.debug('PERMIT: bail annotation hit', { hits: annHits.length }); } catch (_) {} return; } } catch (_) {} @@ -1247,7 +1247,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => { // Ignore dblclicks that intersect annotation layers to avoid clashing with annotation popup try { const pt = [e.point.x, e.point.y]; - const layers = ['annotation-text', 'annotation-arrows', 'annotation-arrowheads']; + const layers = ['annotation-text', 'annotation-arrowheads']; const annHits = map.queryRenderedFeatures && map.queryRenderedFeatures(pt, { layers }); if (annHits && annHits.length) { try { console.debug('PERMIT: bail annotation hit dblclick', { hits: annHits.length }); } catch (_) {} return; } } catch (_) {} @@ -1271,7 +1271,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => { // Ignore clicks on annotation layers to avoid closing their popup try { const pt = [e.point.x, e.point.y]; - const layers = ['annotation-text', 'annotation-arrows', 'annotation-arrowheads']; + const layers = ['annotation-text', 'annotation-arrowheads']; const annHits = map.queryRenderedFeatures && map.queryRenderedFeatures(pt, { layers }); if (annHits && annHits.length) { try { console.debug('PERMIT: bail annotation hit general', { hits: annHits.length }); } catch (_) {} return; } } catch (_) {} diff --git a/src/utils/exportUtils.js b/src/utils/exportUtils.js index dac7a42..0a0a8c6 100644 --- a/src/utils/exportUtils.js +++ b/src/utils/exportUtils.js @@ -1639,13 +1639,9 @@ const drawCustomShapesOnPdf = (pdf, shapes, project, toMm) => { const kind = (shape.properties && shape.properties.type) || ''; if (g.type === 'Point') { const p = toMm(project(g.coordinates[0], g.coordinates[1])); - if (kind === 'text') { - // Defer actual text draw to topmost pass - } else { - // Default point marker + numbered badge - pdf.circle(p.x, p.y, 1.2, 'S'); - labelPoint = p; - } + // Default point marker + numbered badge + pdf.circle(p.x, p.y, 1.2, 'S'); + labelPoint = p; } else if (g.type === 'LineString') { const coords = g.coordinates.map(([lng, lat]) => toMm(project(lng, lat))); if (coords.length < 2) return; @@ -1680,26 +1676,35 @@ const drawCustomShapesOnPdf = (pdf, shapes, project, toMm) => { }; // Place badge at the geometric midpoint instead of an endpoint labelPoint = computePolylineMidpoint(coords); - // Arrowhead if this is an arrow - if (kind === 'arrow' || (shape.properties && shape.properties.arrow === true)) { - const a = coords[coords.length - 2]; - const b = coords[coords.length - 1]; - const dx = b.x - a.x; const dy = b.y - a.y; + // Arrowhead if this is an arrow or has specific arrowhead properties + const props = shape.properties || {}; + const size = 3; // mm + + const drawArrowhead = (p1, p2) => { + const dx = p2.x - p1.x; const dy = p2.y - p1.y; const len = Math.hypot(dx, dy) || 1; const ux = dx / len; const uy = dy / len; - const size = 3; // mm - // Triangle points at end - const tip = b; - const left = { x: b.x - ux * size - uy * (size * 0.6), y: b.y - uy * size + ux * (size * 0.6) }; - const right = { x: b.x - ux * size + uy * (size * 0.6), y: b.y - uy * size - ux * (size * 0.6) }; - // Match line stroke color + const left = { x: p2.x - ux * size - uy * (size * 0.6), y: p2.y - uy * size + ux * (size * 0.6) }; + const right = { x: p2.x - ux * size + uy * (size * 0.6), y: p2.y - uy * size - ux * (size * 0.6) }; pdf.setFillColor(59, 130, 246); if (typeof pdf.triangle === 'function') { - try { pdf.triangle(tip.x, tip.y, left.x, left.y, right.x, right.y, 'F'); } catch (_) { - pdf.lines([[left.x - tip.x, left.y - tip.y], [right.x - left.x, right.y - left.y], [tip.x - right.x, tip.y - right.y]], tip.x, tip.y, [1,1], 'F', true); + try { pdf.triangle(p2.x, p2.y, left.x, left.y, right.x, right.y, 'F'); } catch (_) { + pdf.lines([[left.x - p2.x, left.y - p2.y], [right.x - left.x, right.y - left.y], [p2.x - right.x, p2.y - right.y]], p2.x, p2.y, [1,1], 'F', true); } } else { - pdf.lines([[left.x - tip.x, left.y - tip.y], [right.x - left.x, right.y - left.y], [tip.x - right.x, tip.y - right.y]], tip.x, tip.y, [1,1], 'F', true); + pdf.lines([[left.x - p2.x, left.y - p2.y], [right.x - left.x, right.y - left.y], [p2.x - right.x, p2.y - right.y]], p2.x, p2.y, [1,1], 'F', true); + } + }; + + if (kind === 'arrow' || props.arrow === true || props.arrowEnd === true) { + if (coords.length >= 2) { + drawArrowhead(coords[coords.length - 2], coords[coords.length - 1]); + } + } + + if (props.arrowStart === true) { + if (coords.length >= 2) { + drawArrowhead(coords[1], coords[0]); } } } else if (g.type === 'Polygon') { @@ -1721,15 +1726,14 @@ const drawCustomShapesOnPdf = (pdf, shapes, project, toMm) => { const drawCustomTextLabelsOnPdf = (pdf, shapes, project, toMm) => { try { if (!Array.isArray(shapes) || shapes.length === 0) return; - shapes.forEach((shape) => { - try { - const g = shape && shape.geometry; - const props = shape && shape.properties; - if (!g || g.type !== 'Point') return; - if (!props || props.type !== 'text') return; - const label = String(props.label || '').trim(); - if (!label) return; - const p = toMm(project(g.coordinates[0], g.coordinates[1])); + shapes.forEach((shape) => { + try { + const g = shape && shape.geometry; + const props = shape && shape.properties; + if (!g || g.type !== 'Point') return; + const label = String(props.label || '').trim(); + if (!label) return; + const p = toMm(project(g.coordinates[0], g.coordinates[1])); const x = p.x, y = p.y; const pad = 0.8; const w = pdf.getTextWidth(label) + pad * 2; @@ -2345,14 +2349,15 @@ const drawCustomShapesOnCanvas = (ctx, mapArea, map, customShapes) => { lastPx = { x: mapPixelX, y: mapPixelY }; }); ctx.stroke(); - if (props.type === 'arrow') { + const drawCanvasArrowhead = (p1, p2) => { try { - const coords = shape.geometry.coordinates; - const aPx = map.project(coords[coords.length - 2]); - const bPx = map.project(coords[coords.length - 1]); + const aPx = map.project(p1); + const bPx = map.project(p2); const ax = mapArea.x + aPx.x, ay = mapArea.y + aPx.y; const bx = mapArea.x + bPx.x, by = mapArea.y + bPx.y; - const dx = bx - ax, dy = by - ay; const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; + const dx = bx - ax, dy = by - ay; + const len = Math.hypot(dx, dy) || 1; + const ux = dx / len, uy = dy / len; const size = 16; ctx.fillStyle = '#111827'; ctx.beginPath(); @@ -2362,6 +2367,20 @@ const drawCustomShapesOnCanvas = (ctx, mapArea, map, customShapes) => { ctx.closePath(); ctx.fill(); } catch (_) {} + }; + + if (props.type === 'arrow' || props.arrow === true || props.arrowEnd === true) { + const coords = shape.geometry.coordinates; + if (coords.length >= 2) { + drawCanvasArrowhead(coords[coords.length - 2], coords[coords.length - 1]); + } + } + + if (props.arrowStart === true) { + const coords = shape.geometry.coordinates; + if (coords.length >= 2) { + drawCanvasArrowhead(coords[1], coords[0]); + } } // Draw label at midpoint if available