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 · Rotate & 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;">°</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.