PageSourceSearch

https://bikeatthebeach.com/js/map-view/drawing/index.js

js bikeatthebeach.com collected 2026-09-24 13:57:40 UTC 36,413 bytes, 1,017 lines download raw bytes

1/* =====================================================================
2   js/map-view/drawing/index.js
3   Final Consolidated Fix for Alignment & Survey Handoff
4   ===================================================================== */
5   
6   /**
7 * DRAFT LIFECYCLE CONTRACT
8 * -----------------------
9 * Draft features MUST survive:
10 * - modal open/close
11 * - survey save (draft)
12 * - draw mode changes
13 *
14 * Drafts may ONLY be destroyed by:
15 * - explicit Cancel Draft
16 * - explicit Commit / Promotion
17 *
18 * ⚠️ Do NOT overwrite or null draftFeature here.
19 * UI and survey events are NOT abandonment.
20 */
21 
22
23import state from '../state.js';
24import { customDrawStyles } from './styles.js';
25import { refreshMapData, setFrameClickable } from './layers.js';
26import { initDrawEvents, setDrawingCategory } from './draw-events.js';
27import {
28  initInteractions,
29  setDrawingPngMode,
30  startIconPreview,
31  startPinPreview,
32  stopIconPreview
33} from './interaction.js';
34import { showToast } from '/js/toast.js';
35import { isCategoryInactive } from './category-visibility.js';
36import { toggleBaseMap as legacyToggle } from '../map-core.js';
37import { handleToggleBaseMap as configToggle } from '../map-basemap.js';
38import { fetchFeatures, fetchIcons } from '../api.js';
39import { startPhotoFlow } from './photo-upload.js';
40import { getFrameConfig, computeFrameRectangle, rotatePolygonAroundCentroid } from './geometry.js';
41import { stopFrameHud } from '/js/frame-hud.js';
42import { startSnapping, stopSnapping, getLastSnap } from './snap.js';
43import { contributionsClosed } from '../details/closed-notice.js';
44import { createPointDraft, createGeometryDraft } from './feature-draft.js';
45import { openPointPlacement } from './point-placement-panel.js';
46import { openVertexEntry } from './vertex-entry-panel.js';
47import { closeContributionPanel } from './contribution-panel.js';
48import { showCategoryPicker } from './category-picker.js';
49import { setActiveMenuItem, clearActiveMenuItem, menuIdForDrawType } from '../a11y/menu-state.js';
50import { lastInputWasKeyboard } from '../a11y/input-modality.js';
51
52// Initialize Global State Flag
53window.wmState = window.wmState || {};
54window.wmState.isDrawing = false;
55
56// Transient active category for draw-events.js
57window.selectedCategoryId = null;
58
59// Named handler reference for point placement
60let activePointHandler = null;
61
62// Re-entrancy guard — prevents double-clicks from firing startDraw twice
63let _startDrawRunning = false;
64
65// Touch-tap fallback for mobile (iOS doesn't always synthesize click from touch)
66let _tapCleanup = null;
67
68// Touch-tap fallback for line/polygon draw mode (Mapbox Draw vertex placement)
69let _drawTapCleanup = null;
70
71// 🔑 Expose renderer with option forwarding for lifecycle events (Cancel, Delete)
72window.refreshMapData = (opts = {}) => refreshMapData(opts);
73
74// 🔑 Expose full re-fetch + re-render for external callers
75// (search filter, basemap switch, feature delete, etc.)
76// This replaces the old map-draw.js reapplyDrawFeatures function.
77window.reapplyDrawFeatures = async () => {
78  console.log('[Drawing] reapplyDrawFeatures → re-fetch data');
79  const projectId = window.wmState?.project?.id;
80  if (!projectId) {
81    console.warn('[Drawing] reapplyDrawFeatures: no projectId');
82    return;
83  }
84
85  try {
86    const [features, icons] = await Promise.all([
87      fetchFeatures({ project_id: projectId }),
88      fetchIcons(projectId)
89    ]);
90
91    window.wmState.features = features;
92    window.wmState.icons = icons;
93    window.allIcons = icons;
94
95    refreshMapData();
96    console.log('[Drawing] reapplyDrawFeatures → done');
97  } catch (err) {
98    console.error('[Drawing] reapplyDrawFeatures failed:', err);
99  }
100};
101
102async function promoteDraftFeature(draftFeature) {
103  if (!draftFeature?.geometry) {
104    throw new Error('Cannot promote draft without geometry');
105  }
106
107  const res = await fetch('/api/features', {
108    method: 'POST',
109    headers: { 'Content-Type': 'application/json' },
110    body: JSON.stringify({
111	  project_id: window.wmState.project?.id,
112	  category_id: draftFeature.category_id,
113	  geometry: draftFeature.geometry,
114	
115	  // Explicitly provide optional fields
116	  description: draftFeature.description || null,
117	  photo_path: draftFeature.photo_path || null,
118	  heading_deg: null,
119	
120	  // Identity (important)
121	  user_id: window.wmState.session?.userId ?? window.wmState.session?.user_id ?? null,
122	  guest_token: window.wmState.session?.guestToken ?? null
123})
124  });
125
126  if (!res.ok) {
127    throw new Error('Draft promotion failed');
128  }
129
130  const data = await res.json();
131  return data.featureId; // numeric ID
132}
133
134/**
135 * REQUIRED EXPORT: init
136 * Initializes Mapbox Draw and drawing interactions.
137 */
138export async function init() {
139  console.log('[Drawing] Initializing...');
140
141  if (!window.mapboxgl || !state.map) return;
142  if (!window.MapboxDraw) {
143    console.error('[Drawing] MapboxDraw missing.');
144    return;
145  }
146
147  const draw = new MapboxDraw({
148    displayControlsDefault: false,
149    userProperties: true,
150    styles: customDrawStyles,
151    controls: {
152      point: false,
153      line_string: false,
154      polygon: false
155    }
156  });
157
158  state.map.addControl(draw, 'top-left');
159  window.Draw = draw;
160
161  // Initialize event listeners and interactions
162  initDrawEvents(draw);
163  initInteractions(state.map);
164
165  // Define the public API for the Add buttons.
166  // Every entry point is guarded: the menu already hides these buttons on
167  // a closed project (menu-builder.js), but the API is also reachable from
168  // keyboard shortcuts, custom project JS, and stale cached menus — the
169  // guard keeps a doomed draft from ever starting. Admins/editors pass
170  // (contributionsClosed() is false for them), matching the server gate.
171  const guardClosed = (fn) => (...args) => {
172    if (contributionsClosed()) {
173      showToast('This project is closed to new submissions.', { type: 'warning' });
174      return;
175    }
176    return fn(...args);
177  };
178
179  window.wmApp = window.wmApp || {};
180  window.wmApp.addPoint = guardClosed(() => startDraw('point'));
181  window.wmApp.addLine = guardClosed(() => startDraw('linestring'));
182  window.wmApp.addPolygon = guardClosed(() => startDraw('polygon'));
183  window.wmApp.addPhoto = guardClosed(() => startPhotoFlow());
184  window.wmApp.addFile = guardClosed(async () => {
185    const { startFileUploadFlow } = await import('./file-upload.js');
186    startFileUploadFlow();
187  });
188  // "Share a trip or observation" — upload a GPX/KML from onX, Garmin,
189  // Strava, Gaia and contribute selected pieces of it. Distinct from
190  // addFile: that one loads a whole dataset into one category chosen up
191  // front, this one lets a participant pick individual observations out
192  // of a trip. See drawing/trip-import/index.js for why both exist.
193  window.wmApp.shareTrip = guardClosed(async () => {
194    const { startTripImport } = await import('./trip-import/index.js');
195    startTripImport();
196  });
197  // Use config-aware basemap cycle (reads basemap_styles from settings)
198  // instead of the legacy two-way streets↔satellite toggle
199  window.wmApp.toggleBaseMap = configToggle;
200
201  // "I'm here" — geolocate user, then start a point draw at their location
202  window.wmApp.iAmHere = guardClosed(() => {
203    if (!navigator.geolocation) {
204      showToast('Geolocation is not supported by your browser.', { type: 'warning' });
205      return;
206    }
207    showToast('Finding your location…', { type: 'info' });
208    navigator.geolocation.getCurrentPosition(
209      async (pos) => {
210        const { longitude: lng, latitude: lat } = pos.coords;
211        state.map.flyTo({ center: [lng, lat], zoom: 17, essential: true });
212        await startDraw('point');
213        // startDraw installs activePointHandler for point placement —
214        // fire it with the geolocated coords so the point auto-places
215        if (activePointHandler) {
216          activePointHandler({
217            lngLat: { lng, lat },
218            point: state.map.project([lng, lat])
219          });
220        }
221      },
222      (err) => {
223        const msgs = {
224          1: 'Location permission denied. Enable it in your browser settings.',
225          2: 'Could not determine your location. Try again.',
226          3: 'Location request timed out. Try again.'
227        };
228        showToast(msgs[err.code] || 'Could not get your location.', { type: 'warning' });
229      },
230      { enableHighAccuracy: true, timeout: 30000, maximumAge: 60000 }
231    );
232  });
233
234  // Framing toggle button — only show when the project has frame categories
235  initFrameToggle();
236}
237
238/**
239 * Entry point for Add Feature actions.
240 * Handles category selection and tool activation.
241 */
242export async function startDraw(type) {
243  if (_startDrawRunning) {
244    console.warn('[Drawing] startDraw already running, ignoring duplicate call');
245    return;
246  }
247  _startDrawRunning = true;
248
249  if (window.wmState?._suspendDrawReconcile) {
250    console.warn('[Drawing] Blocked during feature rehydrate');
251    _startDrawRunning = false;
252    return;
253  }
254
255 // 1. PRESERVE PREVIOUS DRAFT — only if it is still a true draft
256if (
257  window.wmState.draftFeature &&
258  window.wmState.draftFeature.properties?.__draft === true &&
259  window.wmState.ui?.activeFeatureId === null &&
260  window.wmState._draftWasCancelled !== true
261) {
262  const existingFeatures = window.wmState.features || [];
263  const isAlreadyStashed = existingFeatures.some(
264    f => String(f.id) === String(window.wmState.draftFeature.id)
265  );
266
267  if (!isAlreadyStashed) {
268    console.log(
269      '[Drawing] Stashing existing draft into state features:',
270      window.wmState.draftFeature.id
271    );
272    window.wmState.features = [
273      ...existingFeatures,
274      window.wmState.draftFeature
275    ];
276  }
277}
278
279  // 2. RESET DRAWING SESSION — Clear active pointers for the NEW point
280  window.wmState.draftFeature = null;
281  window.currentFeature = null;
282  window.currentFeatureId = null;
283  window.wmState._draftWasCancelled = false;
284
285  // 3. Reset UI and Draw modes
286  cleanupAfterDraw();
287  if (window.Draw) {
288    window.Draw.changeMode('simple_select');
289  }
290
291  // 4. Filter valid categories for this geometry type
292  const isAdmin = state.user?.isOwner || state.user?.isSuperAdmin || state.user?.projectRole === 'admin';
293  const validCats = state.categories.filter(c => {
294    // Parked categories are hidden from map + legend, so never offer
295    // them for new features — even to admins, who would otherwise
296    // create a point that vanishes on save.
297    if (isCategoryInactive(c)) return false;
298    if (!isAdmin && c.admin_only) return false;
299    const geom = (c.geometry_type || '').toLowerCase();
300    if (geom === type.toLowerCase()) return true;
301    // Frame and Coverage Grid categories use polygon drawing
302    // (Annotations have their own creation flow via annotation-manager.js)
303    if (type === 'polygon' && (
304      c.render_as === 'frame' ||
305      c.render_as === 'coverage_grid'
306    )) return true;
307    return false;
308  });
309
310  if (validCats.length === 0) {
311    showToast(`No categories found for ${type}.`, { type: 'warning' });
312    _startDrawRunning = false;
313    return;
314  }
315
316  let catId = null;
317  if (validCats.length === 1) {
318    catId = validCats[0].id;
319    // Only show auto-select toast for points — polygons and lines
320    // get their own detailed instruction toast when draw mode starts
321    if (type === 'point') {
322      showToast('Click on the map to place the point.', { type: 'info' });
323    }
324  } else {
325    catId = await showCategoryPicker(validCats, type);
326  }
327
328  if (!catId) {
329    cleanupAfterDraw();
330    _startDrawRunning = false;
331    return;
332  }
333
334  const cat = validCats.find(c => String(c.id) === String(catId));
335
336  // 5. Establish drawing state
337  window.wmState.isDrawing = true;
338  window.selectedCategoryId = cat.id;
339  setDrawingCategory(cat.id);
340
341  // Say which menu item is now live. Set here rather than on the click,
342  // because the category picker in between can still be cancelled and a
343  // button that stayed pressed after a cancel would be lying.
344  setActiveMenuItem(menuIdForDrawType(type));
345
346  // 6. POINT PLACEMENT LOGIC
347  if (type === 'point') {
348    const isCustom = ['custom', 'marker'].includes((cat.line_style || '').toLowerCase());
349    setDrawingPngMode(isCustom);
350
351    if (isCustom && cat.icon_path) {
352      startIconPreview({ map: state.map, iconPath: cat.icon_path, iconScale: 1 });
353      document.getElementById('map')?.classList.add('placing-point');
354    } else if ((cat.line_style || '').toLowerCase() === 'pin') {
355      // Pin categories had no preview, so the only thing following the
356      // cursor was snap.js's amber snap dot — which reads as the point
357      // being placed when there is nothing beside it to compare it to.
358      startPinPreview({
359        map: state.map,
360        color: cat.stroke_color || cat.color || '#177AB5'
361      });
362      document.getElementById('map')?.classList.add('placing-point');
363    }
364
365    // Show crosshair while waiting for the user to click a placement spot
366    document.getElementById('map')?.classList.add('cursor-crosshair');
367
368    // Start vertex snapping for point placement
369    startSnapping(state.map);
370
371    // The draft lifecycle moved to feature-draft.js so the keyboard
372    // placement panel below can create the identical draft from a typed
373    // coordinate. Both routes call the same function; there is no second
374    // copy of this to keep in step.
375    activePointHandler = async (e) => {
376      // Use snapped coordinate if available
377      const snap = getLastSnap();
378      const lng = snap ? snap.lng : Number(e.lngLat.lng);
379      const lat = snap ? snap.lat : Number(e.lngLat.lat);
380      stopSnapping();
381      closeContributionPanel({ silent: true, restoreFocus: false });
382      cleanupAfterDraw();
383      await createPointDraft(cat, lng, lat);
384    };
385
386    state.map.once('click', activePointHandler);
387
388    // Mobile fallback: iOS sometimes doesn't fire Mapbox 'click' from touch taps.
389    // Listen for a quick single-finger tap on the canvas and invoke the handler.
390    _tapCleanup = installTapFallback(state.map, activePointHandler);
391
392    // Keyboard route, for the reader who cannot click the map. It used to
393    // open for everyone, which put a 380px card over the middle of the map
394    // at the moment the map was asking to be clicked — and the card ate
395    // the click. It opens only when the draw was started from the keyboard
396    // now; see a11y/input-modality.js.
397    if (lastInputWasKeyboard()) openPointPlacement({
398      cat,
399      map: state.map,
400      onPlace: async (lng, lat) => {
401        stopSnapping();
402        cleanupAfterDraw();
403        await createPointDraft(cat, lng, lat);
404      },
405      onCancel: () => {
406        stopSnapping();
407        cleanupAfterDraw();
408      }
409    });
410
411    _startDrawRunning = false;
412    return;
413  }
414
415  // 6b. FRAME PLACEMENT — stamp-and-move approach
416  // Instead of freehand drawing, compute a correctly-sized rectangle from
417  // the frame config (paper × scale) and place it at the map center for
418  // the user to drag-position before confirming.
419  if (cat.render_as === 'frame') {
420    const config = getFrameConfig(cat);
421    const center = state.map.getCenter();
422    const geometry = computeFrameRectangle(config, center);
423    console.log('[Drawing] Frame config:', config);
424    console.log('[Drawing] Frame rectangle:', JSON.stringify(geometry));
425
426    // Add the pre-sized rectangle to Mapbox Draw
427    const featureIds = window.Draw.add({
428      type: 'Feature',
429      properties: {},
430      geometry
431    });
432    console.log('[Drawing] Draw.add returned IDs:', featureIds);
433
434    // Fit the map to show the rectangle with some padding
435    const ring = geometry.coordinates[0];
436    const lngs = ring.map(p => p[0]);
437    const lats = ring.map(p => p[1]);
438    state.map.fitBounds(
439      [[Math.min(...lngs), Math.min(...lats)],
440       [Math.max(...lngs), Math.max(...lats)]],
441      { padding: 80, duration: 600 }
442    );
443
444    // Select the feature in simple_select mode after a short delay
445    // to ensure the map has rendered the feature first
446    setTimeout(() => {
447      window.Draw.changeMode('simple_select', { featureIds });
448      console.log('[Drawing] Entered simple_select with', featureIds);
449    }, 100);
450
451    // Show floating action bar for confirm/cancel (with rotation input)
452    showFramePlacementBar(cat, () => {
453      // CONFIRM: grab geometry from Draw, apply rotation, create draft
454      const features = window.Draw.getAll().features;
455      let frameGeom = features[0]?.geometry;
456      const rotInput = document.getElementById('frame-rotation-input');
457      const rotDeg = Number(rotInput?.value) || 0;
458
459      window.Draw.deleteAll();
460      stopFrameHud();
461      removeFramePlacementBar();
462      cleanupAfterDraw();
463
464      if (frameGeom) {
465        // Apply rotation if non-zero (rotation was only a live preview
466        // on the Draw feature, but we re-apply to the final geometry to
467        // ensure precision matches the typed value)
468        if (rotDeg) {
469          // The Draw feature is already rotated from live preview, but
470          // rebuild from scratch for precision: unrotated base → rotate
471          const ring = frameGeom.coordinates[0];
472          const xs = ring.map(p => p[0]), ys = ring.map(p => p[1]);
473          const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
474          const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
475          const config = getFrameConfig(cat);
476          const base = computeFrameRectangle(config, { lng: cx, lat: cy });
477          frameGeom = rotatePolygonAroundCentroid(base, rotDeg);
478        }
479        createFrameDraft(cat, frameGeom);
480      }
481    }, () => {
482      // CANCEL: remove from Draw, cleanup
483      window.Draw.deleteAll();
484      stopFrameHud();
485      removeFramePlacementBar();
486      cleanupAfterDraw();
487    });
488
489    _startDrawRunning = false;
490    return;  // Don't fall through to draw_polygon
491  }
492
493  // 7. LINES & POLYGONS
494  // For lines: check if snap-to-roads is enabled (per-category or global toggle)
495  const snapProfile = cat.snap_profile || (window.wmState?.snapToRoads ? (window.wmState.snapProfile || 'cycling') : null);
496  if (type === 'linestring' && snapProfile) {
497    const { startSnapDraw, setSnapProfile } = await import('./snap-to-roads.js');
498    setSnapProfile(snapProfile);
499    state.map.doubleClickZoom.disable();
500    document.getElementById('map')?.classList.add('cursor-crosshair');
501
502    startSnapDraw(state.map, {
503      color: cat.stroke_color || '#0d6efd',
504      onFinish: async (geometry) => {
505        closeContributionPanel({ silent: true, restoreFocus: false });
506        cleanupAfterDraw();
507        await createGeometryDraft(cat, geometry);
508      }
509    });
510
511    // Snap-to-roads is still a click-per-vertex tool, so it gets the same
512    // typed-vertex alternative as plain line drawing. The vertices go in
513    // unsnapped: a reader who names a street address has already said
514    // where they mean more precisely than a snap would.
515    if (lastInputWasKeyboard()) openVertexEntry({
516      cat,
517      map: state.map,
518      kind: 'linestring',
519      onFinish: async (geometry) => {
520        cleanupAfterDraw();
521        await createGeometryDraft(cat, geometry);
522      },
523      onCancel: () => cleanupAfterDraw()
524    });
525
526    _startDrawRunning = false;
527    return;
528  }
529
530  const mode = type === 'linestring' ? 'draw_line_string' : 'draw_polygon';
531  const shapeLabel = type === 'linestring' ? 'line' : 'polygon';
532  state.map.doubleClickZoom.disable();
533  document.getElementById('map')?.classList.add('cursor-crosshair');
534
535  window.Draw.changeMode(mode);
536
537  // Start vertex snapping for polygons only — lines don't need to snap
538  // to existing features. draw.modechange doesn't fire for programmatic
539  // changeMode() calls in Mapbox Draw v1.5.0
540  if (type === 'polygon') {
541    startSnapping(state.map);
542  }
543
544  state.map.once('draw.cancel', cleanupAfterDraw);
545
546  // Tell the user how to finish drawing (especially helpful on mobile)
547  showToast(`Tap to add points. Double-tap to finish the ${shapeLabel}.`, 'info');
548
549  // Mobile fallback: iOS doesn't reliably fire map 'click' from touch taps
550  // during Mapbox Draw modes. Install a tap-to-click converter so each tap
551  // places a vertex, and a double-tap finishes the line/polygon.
552  _drawTapCleanup = installDrawTapFallback(state.map);
553
554  // Keyboard route. A toast is not an instruction a screen reader can act
555  // on and "double-tap to finish" is not a keystroke, so the same shape is
556  // buildable from a list of typed or searched vertices. Draw stays armed
557  // beside it; whichever finishes first wins.
558  if (lastInputWasKeyboard()) openVertexEntry({
559    cat,
560    map: state.map,
561    kind: type === 'linestring' ? 'linestring' : 'polygon',
562    onFinish: async (geometry) => {
563      // The mouse route's own partial drawing, if any, is discarded —
564      // the reader has said this list of vertices is the shape.
565      try { window.Draw?.deleteAll(); window.Draw?.changeMode('simple_select'); } catch { /* Draw may be gone */ }
566      cleanupAfterDraw();
567      await createGeometryDraft(cat, geometry);
568    },
569    onCancel: () => {
570      try { window.Draw?.deleteAll(); window.Draw?.changeMode('simple_select'); } catch { /* Draw may be gone */ }
571      cleanupAfterDraw();
572    }
573  });
574
575  _startDrawRunning = false;
576}
577
578// --------------------------------------------------------------------
579// UI Helpers
580// --------------------------------------------------------------------
581
582function cleanupAfterDraw() {
583  window.wmState.isDrawing = false;
584  window.selectedCategoryId = null;
585  stopIconPreview();
586
587  // Every exit from a draw comes through here, so this is the one place
588  // the pressed state has to be released.
589  clearActiveMenuItem();
590
591  // The keyboard panel is armed alongside every draw route, so every exit
592  // from a draw — Draw's own draw.create, a cancel, a mode change — has to
593  // take it down too. restoreFocus is off: the finished feature's detail
594  // panel is claiming focus, and pulling it back to the menu button would
595  // fight that.
596  closeContributionPanel({ silent: true, restoreFocus: false });
597  document.getElementById('map')?.classList.remove('placing-point', 'cursor-crosshair');
598
599  // Remove touch-tap fallbacks if still active
600  if (_tapCleanup) { _tapCleanup(); _tapCleanup = null; }
601  if (_drawTapCleanup) { _drawTapCleanup(); _drawTapCleanup = null; }
602
603  if (state.map) {
604    state.map.dragPan.enable();
605    state.map.doubleClickZoom.enable();
606    if (activePointHandler) {
607      state.map.off('click', activePointHandler);
608      activePointHandler = null;
609    }
610  }
611}
612
613/**
614 * Mobile touch-tap fallback for point placement.
615 * On iOS, Mapbox GL JS sometimes doesn't fire the map 'click' event from
616 * a touch tap (touch-action:none + preventDefault on touchstart can block
617 * the browser's synthesized click event). This detects a quick single-finger
618 * tap on the canvas and manually invokes the point handler.
619 *
620 * Returns a cleanup function to remove the listeners.
621 */
622function installTapFallback(map, handler) {
623  if (!map) return () => {};
624  const canvas = map.getCanvas();
625  if (!canvas) return () => {};
626
627  let startTime = 0;
628  let startX = 0;
629  let startY = 0;
630
631  const TAP_TIMEOUT = 300;   // max ms for a tap
632  const TAP_THRESHOLD = 10;  // max px movement
633
634  function onTouchStart(e) {
635    if (e.touches.length !== 1) return;
636    startTime = Date.now();
637    startX = e.touches[0].clientX;
638    startY = e.touches[0].clientY;
639  }
640
641  function onTouchEnd(e) {
642    // If drawing was already handled (e.g. map click fired first), bail
643    if (!window.wmState?.isDrawing) { cleanup(); return; }
644    if (e.changedTouches.length !== 1) return;
645
646    const dt = Date.now() - startTime;
647    if (dt > TAP_TIMEOUT) return;
648
649    const endX = e.changedTouches[0].clientX;
650    const endY = e.changedTouches[0].clientY;
651    const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
652    if (dist > TAP_THRESHOLD) return;
653
654    // Convert screen coords to map lngLat
655    const rect = canvas.getBoundingClientRect();
656    const point = new mapboxgl.Point(endX - rect.left, endY - rect.top);
657    const lngLat = map.unproject(point);
658
659    cleanup();
660
661    // Remove the map.once('click') so it doesn't double-fire later
662    map.off('click', handler);
663
664    console.log('[Drawing] Touch-tap fallback fired at', lngLat.lng.toFixed(4), lngLat.lat.toFixed(4));
665
666    // Invoke the handler with a Mapbox-compatible event shape
667    handler({ lngLat, point, originalEvent: e });
668  }
669
670  function cleanup() {
671    canvas.removeEventListener('touchstart', onTouchStart);
672    canvas.removeEventListener('touchend', onTouchEnd);
673  }
674
675  canvas.addEventListener('touchstart', onTouchStart, { passive: true });
676  canvas.addEventListener('touchend', onTouchEnd, { passive: true });
677
678  return cleanup;
679}
680
681/**
682 * Mobile touch-tap fallback for line/polygon drawing (Mapbox Draw modes).
683 *
684 * Mapbox Draw relies on the map's 'click' event to place vertices and
685 * 'dblclick' to finish the shape. On iOS, touch-action:none on the canvas
686 * prevents click synthesis from touch events. This listener detects taps
687 * and fires the corresponding map events so Draw registers them.
688 *
689 * - Single tap → map 'click' (places a vertex)
690 * - Double tap → map 'dblclick' (finishes the line/polygon)
691 *
692 * Unlike installTapFallback (for points), this stays active for multiple
693 * taps and only removes itself via the returned cleanup function.
694 */
695function installDrawTapFallback(map) {
696  if (!map) return () => {};
697  const canvas = map.getCanvas();
698  if (!canvas) return () => {};
699
700  const TAP_TIMEOUT   = 300;  // max ms for a single tap
701  const TAP_THRESHOLD = 10;   // max px finger movement
702  const DBL_TAP_GAP   = 300;  // max ms between taps for a double-tap
703
704  let startTime = 0;
705  let startX = 0;
706  let startY = 0;
707  let lastTapTime = 0;
708  let lastTapX = 0;
709  let lastTapY = 0;
710
711  function onTouchStart(e) {
712    if (e.touches.length !== 1) return;
713    startTime = Date.now();
714    startX = e.touches[0].clientX;
715    startY = e.touches[0].clientY;
716  }
717
718  function onTouchEnd(e) {
719    if (!window.wmState?.isDrawing) return;
720    if (e.changedTouches.length !== 1) return;
721
722    const dt = Date.now() - startTime;
723    if (dt > TAP_TIMEOUT) return;
724
725    const endX = e.changedTouches[0].clientX;
726    const endY = e.changedTouches[0].clientY;
727    const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
728    if (dist > TAP_THRESHOLD) return;
729
730    // Convert screen coords to map lngLat
731    const rect = canvas.getBoundingClientRect();
732    const point = new mapboxgl.Point(endX - rect.left, endY - rect.top);
733    const lngLat = map.unproject(point);
734
735    // Detect double-tap: two taps close together in time and space
736    const now = Date.now();
737    const tapDist = Math.sqrt((endX - lastTapX) ** 2 + (endY - lastTapY) ** 2);
738    const isDoubleTap = (now - lastTapTime < DBL_TAP_GAP) && (tapDist < TAP_THRESHOLD * 2);
739
740    // Save this tap for double-tap detection on the next tap
741    lastTapTime = now;
742    lastTapX = endX;
743    lastTapY = endY;
744
745    // Prevent default to stop iOS from panning/scrolling
746    e.preventDefault();
747
748    if (isDoubleTap) {
749      console.log('[Drawing] Touch double-tap → dblclick at', lngLat.lng.toFixed(4), lngLat.lat.toFixed(4));
750      map.fire('dblclick', { point, lngLat, originalEvent: e });
751    } else {
752      console.log('[Drawing] Touch tap → click at', lngLat.lng.toFixed(4), lngLat.lat.toFixed(4));
753      map.fire('click', { point, lngLat, originalEvent: e });
754    }
755  }
756
757  function cleanup() {
758    canvas.removeEventListener('touchstart', onTouchStart);
759    canvas.removeEventListener('touchend', onTouchEnd);
760  }
761
762  // passive:false so we can preventDefault on touchend to avoid scroll/zoom
763  canvas.addEventListener('touchstart', onTouchStart, { passive: true });
764  canvas.addEventListener('touchend', onTouchEnd, { passive: false });
765
766  return cleanup;
767}
768
769// --------------------------------------------------------------------
770// Frame Placement Helpers
771// --------------------------------------------------------------------
772
773let framePlacementBar = null;
774
775/**
776 * Create a draft feature from the frame geometry and open the feature modal.
777 * Mirrors the draft-creation pattern in draw-events.js draw.create handler.
778 */
779async function createFrameDraft(cat, geometry) {
780  const draftId = `draft-${crypto.randomUUID()}`;
781  const draftFeatureObj = {
782    type: 'Feature',
783    id: String(draftId),
784    geometry,
785    category_id: Number(cat.id),
786    category_survey_id: cat.survey_id || null,
787    properties: {
788      category_id: Number(cat.id),
789      category_survey_id: cat.survey_id || null,
790      __draft: true,
791      icon_path: cat.icon_path || null
792    }
793  };
794
795  window.currentFeatureId = draftId;
796  window.wmState.draftFeature = draftFeatureObj;
797  await refreshMapData({ includeDraft: true });
798
799  // Mark draft as active in UI state
800  if (window.wmState?.ui) {
801    window.wmState.ui.activeFeatureId = draftId;
802    window.wmState.ui.activeFeature = draftFeatureObj;
803    console.log('[Drawing] Frame draft set', draftId);
804  }
805
806  // Bind survey context if the category has a survey
807  if (cat.survey_id) {
808    window.wmState.activeSurveyContext = {
809      featureId: draftId,
810      surveyId: cat.survey_id,
811      isDraft: true
812    };
813  }
814
815  // Open the feature modal for the new draft
816  const m = await import('../details/openFeatureDetails.js');
817  await m.openFeatureDetails(draftId);
818}
819
820/**
821 * Show a floating action bar at the bottom of the screen during frame placement.
822 * Includes a rotation input with live preview, plus Place/Cancel buttons.
823 */
824function showFramePlacementBar(cat, onConfirm, onCancel) {
825  removeFramePlacementBar();
826
827  framePlacementBar = document.createElement('div');
828  framePlacementBar.id = 'frame-placement-bar';
829  Object.assign(framePlacementBar.style, {
830    position: 'fixed',
831    bottom: '60px',
832    left: '50%',
833    transform: 'translateX(-50%)',
834    zIndex: '1060',
835    background: 'rgba(0,0,0,0.85)',
836    color: '#fff',
837    padding: '10px 16px',
838    borderRadius: '10px',
839    display: 'flex',
840    alignItems: 'center',
841    gap: '12px',
842    font: '500 14px/1.4 system-ui, -apple-system, sans-serif',
843    boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
844  });
845
846  framePlacementBar.innerHTML = `
847    <span style="font-size:13px;">Drag to position &middot; Rotate &amp; Place when ready</span>
848    <label style="display:flex;align-items:center;gap:4px;margin:0;">
849      <span style="font-size:12px;">Rot:</span>
850      <input id="frame-rotation-input" type="number" value="0"
851             min="-180" max="180" step="5"
852             style="width:60px;padding:2px 4px;border-radius:4px;border:1px solid #555;
853                    background:#333;color:#fff;text-align:center;font-size:13px;">
854      <span style="font-size:12px;">&deg;</span>
855    </label>
856    <button id="frame-place-btn" style="background:#198754;color:#fff;border:none;
857      padding:6px 16px;border-radius:6px;font-weight:600;cursor:pointer;">
858      Place Frame</button>
859    <button id="frame-cancel-btn" style="background:#6c757d;color:#fff;border:none;
860      padding:6px 12px;border-radius:6px;cursor:pointer;">Cancel</button>
861  `;
862
863  document.body.appendChild(framePlacementBar);
864  framePlacementBar.querySelector('#frame-place-btn').onclick = onConfirm;
865  framePlacementBar.querySelector('#frame-cancel-btn').onclick = onCancel;
866
867  // Live rotation preview: stash the unrotated base geometry so each
868  // rotation is relative to 0° (not cumulative).
869  const rotInput = framePlacementBar.querySelector('#frame-rotation-input');
870  let baseGeometry = null; // captured on first rotation
871
872  rotInput.addEventListener('input', () => {
873    const deg = Number(rotInput.value) || 0;
874    const features = window.Draw?.getAll()?.features;
875    if (!features?.length) return;
876
877    const feat = features[0];
878    // Stash the original unrotated geometry on first interaction
879    if (!baseGeometry) {
880      baseGeometry = JSON.parse(JSON.stringify(feat.geometry));
881    }
882
883    // Apply rotation to the stashed base (not cumulative)
884    const rotated = deg
885      ? rotatePolygonAroundCentroid(baseGeometry, deg)
886      : JSON.parse(JSON.stringify(baseGeometry));
887
888    // Replace in Draw
889    try {
890      window.Draw.delete(feat.id);
891      const ids = window.Draw.add({ type: 'Feature', properties: {}, geometry: rotated });
892      window.Draw.changeMode('simple_select', { featureIds: ids });
893    } catch (e) {
894      console.warn('[Drawing] Live rotation preview error:', e);
895    }
896  });
897}
898
899/** Remove the frame placement action bar (safe to call multiple times). */
900function removeFramePlacementBar() {
901  if (framePlacementBar) {
902    framePlacementBar.remove();
903    framePlacementBar = null;
904  }
905}
906
907// --------------------------------------------------------------------
908// Framing Toggle Button (Mapbox IControl)
909// --------------------------------------------------------------------
910
911/**
912 * Create a "Frame" toggle button on the map using the Mapbox GL IControl
913 * interface so it stacks properly in the top-right control group
914 * (below NavigationControl and GeolocateControl).
915 *
916 * Only added when the project has at least one frame category and
917 * the user is admin or editor.
918 */
919function initFrameToggle() {
920  const categories = window.wmState?.categories || [];
921  const hasFrames = categories.some(c => c.render_as === 'frame');
922  if (!hasFrames) return;
923
924  // Only add for admin/editor
925  const role = window.wmState?.user?.projectRole;
926  if (role !== 'admin' && role !== 'editor') return;
927
928  const map = state.map;
929  if (!map) return;
930
931  let active = false;
932
933  // Mapbox IControl: must implement onAdd() and onRemove()
934  const frameControl = {
935    onAdd(mapInstance) {
936      const container = document.createElement('div');
937      container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group';
938
939      const btn = document.createElement('button');
940      btn.id = 'frame-mode-toggle';
941      btn.type = 'button';
942      btn.title = 'Toggle Frame Editing';
943      btn.innerHTML = '<i class="fas fa-vector-square"></i>';
944      Object.assign(btn.style, {
945        width: '30px',
946        height: '30px',
947        display: 'flex',
948        alignItems: 'center',
949        justifyContent: 'center',
950        fontSize: '14px',
951        cursor: 'pointer',
952        transition: 'background 0.15s, color 0.15s'
953      });
954
955      btn.addEventListener('click', (e) => {
956        e.stopPropagation();
957        active = !active;
958        setFrameClickable(active);
959
960        // Dismiss frame edit HUD when toggling frame mode off
961        if (!active) {
962          import('../details/frame-edit-hud.js').then(m => m.hideFrameEditHud());
963        }
964
965        // Visual feedback
966        btn.style.background = active ? '#198754' : '';
967        btn.style.color = active ? '#fff' : '';
968        btn.title = active
969          ? 'Frame Editing ON — click a frame to edit'
970          : 'Toggle Frame Editing';
971
972        // Brighten frame outlines when active
973        if (mapInstance.getLayer('existing-frames-outline')) {
974          mapInstance.setPaintProperty('existing-frames-outline', 'line-opacity',
975            active ? 1 : ['coalesce', ['get', 'stroke_opacity'], 1]);
976          mapInstance.setPaintProperty('existing-frames-outline', 'line-width',
977            active ? 3 : ['coalesce', ['get', 'line_width'], 2]);
978        }
979      });
980
981      container.appendChild(btn);
982
983      // Grid generator button — creates a tiled grid of frames
984      const gridBtn = document.createElement('button');
985      gridBtn.type = 'button';
986      gridBtn.title = 'Generate Frame Grid';
987      gridBtn.innerHTML = '<i class="fas fa-th"></i>';
988      Object.assign(gridBtn.style, {
989        width: '30px', height: '30px',
990        display: 'flex', alignItems: 'center', justifyContent: 'center',
991        fontSize: '14px', cursor: 'pointer',
992        borderTop: '1px solid #ddd'
993      });
994      gridBtn.addEventListener('click', (e) => {
995        e.stopPropagation();
996        import('./frame-grid.js').then(m => m.startFrameGridFlow());
997      });
998      container.appendChild(gridBtn);
999
1000      this._container = container;
1001      return container;
1002    },
1003
1004    onRemove() {
1005      if (this._container?.parentNode) {
1006        this._container.parentNode.removeChild(this._container);
1007      }
1008      this._container = null;
1009    }
1010  };
1011
1012  map.addControl(frameControl, 'top-right');
1013}
1014
1015// --------------------------------------------------------------------
1016// Category Picker Modal
1017// --------------------------------------------------------------------

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.