Greasy Fork is available in English.
WME Candy Shop: master-layer shapes, pop/revert, brush/eraser (geodesic, hole→slit per part), shape–place links, FPS, workflow UX.
// ==UserScript== // @name WME Candy Shop // @namespace https://github.com/horizon911 // @version 2026.04.12.15 // @description WME Candy Shop: master-layer shapes, pop/revert, brush/eraser (geodesic, hole→slit per part), shape–place links, FPS, workflow UX. // @match https://*.waze.com/*/editor* // @match https://*.waze.com/editor* // @icon https://raw.githubusercontent.com/microsoft/fluentui-emoji/main/assets/Lollipop/Flat/lollipop_flat.svg // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jsts.min.js // @require https://cdn.jsdelivr.net/gh/WazeSpace/wme-sdk-plus@f09f4dfbcfa3a09d127cc6b85e964c5b54374ae0/dist/wme-sdk-plus.js // @connect unpkg.com // @connect cdnjs.cloudflare.com // @license GPLv3 // @supportURL https://github.com/horizon911/WME_Candy-Paint/issues // ==/UserScript== //WME SDK Plus GitHub commits page: https://github.com/WazeSpace/wme-sdk-plus/commits/main (function() { 'use strict'; /** Set localStorage.wmeCandyPaintDebug=1 to mirror log lines to the browser console. */ const DEBUG = (function() { try { return localStorage.getItem('wmeCandyPaintDebug') === '1'; } catch (e) { return false; } })(); const LS_AUTO_INGEST = 'wmeCandyPaintAutoIngest'; function getAutoIngest() { try { return localStorage.getItem(LS_AUTO_INGEST) === '1'; } catch (e) { return false; } } function setAutoIngest(on) { try { localStorage.setItem(LS_AUTO_INGEST, on ? '1' : '0'); } catch (e) { /* ignore */ } } const LS_COMMIT_MODE = 'wmeCandyPaintCommitMode'; /** Boolean commit modes for shape tools; also used for undo snapshot validation. */ const COMMIT_MODE_IDS = new Set(['replace', 'union', 'difference', 'intersection', 'xor']); function getPersistedCommitMode() { try { const raw = localStorage.getItem(LS_COMMIT_MODE); if (raw && COMMIT_MODE_IDS.has(raw)) return raw; } catch (e) { /* ignore */ } return null; } function setPersistedCommitMode(mode) { if (!COMMIT_MODE_IDS.has(mode)) return; try { localStorage.setItem(LS_COMMIT_MODE, mode); } catch (e) { /* ignore */ } } const LS_EXPERIMENTAL_UNDO_KEYS = 'wmeCandyPaintExperimentalUndoKeys'; function getExperimentalUndoKeys() { try { return localStorage.getItem(LS_EXPERIMENTAL_UNDO_KEYS) === '1'; } catch (e) { return false; } } function setExperimentalUndoKeys(on) { try { localStorage.setItem(LS_EXPERIMENTAL_UNDO_KEYS, on ? '1' : '0'); } catch (e) { /* ignore */ } } /** Tools where switching away after Ingest would interrupt an in-progress draw. */ const DRAWING_STYLE_TOOLS = new Set(['rectangle', 'ellipse', 'lasso', 'polygon', 'brush', 'eraser', 'wand']); let autoIngestTimer = null; let selectionChangeDebounceTimer = null; const SELECTION_CHANGE_DEBOUNCE_MS = 120; // WME enables Trusted Types in report-only mode: assigning plain strings to innerHTML / script.src // logs "TrustedHTML" / "TrustedScriptURL" warnings. They do not block execution until enforcement. // They are an artifact of Waze's CSP policies and Tampermonkey scripts bypassing them using pure DOM injection. const BOOTSTRAP_INTERVAL_MS = 800; const BOOTSTRAP_MAX_ATTEMPTS = 75; let wmeSDK; let canvasElement, ctx; let renderFrameRequested = false; /** Coalesce tracker HUD updates — one RAF per frame max (avoids N HUD callbacks stacking per mousemove burst). */ let liveMouseHudFrameRequested = false; // FPS Tracking State let fpsFrames = 0; let fpsLastTime = performance.now(); // Core Geometry States /** * jsts MultiPolygon: each entry is one independent shape for Apply / linking. * Geometry policy: * - Each masterPolygons[i] should end as one closed ring (one continuous boundary). * - If an eraser punches a hole (inner ring), replace that topology with a narrow cut from the hole to the * closest point on this part’s exterior boundary — the same single outline, not a second disconnected loop. * - Multiple indices = multiple shapes, each with its own boundary; do not add cuts between different entries. */ let masterPolygons = []; // Target: one ring per entry. Transient: [ outerRing, innerRing?, ... ] before slit fix. let tempVertices = []; let hoveredTempVertexIndex = -1; let draggingVertexIndex = -1; // Draft Mode States let draftVertices = []; /** Inner rings (holes) while editing; outer boundary is always draftVertices. Pop-to-draft copies from master part[1..]. */ let draftInteriorRings = []; let hoveredDraftVertexIndex = -1; let draggingDraftVertexIndex = -1; let hoveredDraftMidpointIndex = -1; let draftAction = 'none'; let dragStartMouse = null; let dragStartGlobalCoords = null; /** @type {null | { x: number, y: number }[][]} Per-ring screen pixels at start of move/rotate/resize (includes holes). */ let dragStartDraftRingsPx = null; let dragStartBBox = null; let currentMousePixel = null; let isDrawingShape = false; /** Synced from document keydown/keyup (capture) for commitDraft / shape drag modifiers. */ let isShiftDown = false; let isAltDown = false; /** Space-hold temporary pan (P10); restored on keyup or window blur. */ let spacePanActive = false; let spacePanRestoreTool = 'pan'; // Advanced Tool States let dragStartMasterPixels = null; let hoveredMasterPolyIndex = -1; let revertOriginalPolygon = null; /** @type {null | object} Link entry spliced with revert pop; restored on draft cancel (full schema via cloneLinkEntry). */ let revertOriginalLink = null; /** Link tool: WME venue id to bind on next hovered shape (flexible link order). */ let pendingLinkVenueId = null; /** masterPolygons index shape was popped from; survives changeTool (hover index does not). */ let draftPopInsertMasterIndex = -1; /** Parallel to masterPolygons[i]: null = new place on Apply; { venueId } = updateVenue. */ let shapePlaceLinks = []; /** User layers: bottom (index 0) → top; drawing targets the active layer. Places preview is separate. */ const PLACES_LAYER_ID = '__candy_places_preview__'; const DEFAULT_SHAPE_LAYER_NAME = 'Default layer'; const LS_DOCUMENT_KEY = 'wmeCandyPaintDocument'; const DOCUMENT_STATE_VERSION = 2; let userLayers = []; let activeLayerId = null; let documentSaveTimer = null; const placesPreviewLayer = { id: PLACES_LAYER_ID, isPlaces: true, visible: true, name: 'Places', polygons: [], links: [], shapeIds: [] }; /** CS Layers: selection keys `layerId:shapeId` */ const csLayersSelection = new Set(); let csLayersLastAnchorLayerId = null; let csLayersLastAnchorShapeIndex = -1; let csLayersMapHoverShapeKey = null; let csLayersListHoverShapeKey = null; let csLayersNameFilter = ''; let csLayersCollapsed = Object.create(null); let csLayersPanelEventsBound = false; let shapeInspectorRefreshScheduled = false; let csLayersMapHoverRaf = null; let csLayersDragLayerIndex = -1; let csLayersFocusedKind = 'none'; let csLayersFocusedLayerId = null; let csLayersFocusedShapeKey = null; const LS_CS_LAYERS_POS = 'wmeCandyPaintCsLayersPos'; const LS_CS_LAYERS_OPEN = 'wmeCandyPaintCsLayersOpen'; const LS_CS_HISTORY_POS = 'wmeCandyPaintCsHistoryPos'; const LS_CS_HISTORY_OPEN = 'wmeCandyPaintCsHistoryOpen'; let csHistoryRefreshScheduled = false; const BRUSH_METERS_MIN = 1; const BRUSH_METERS_MAX = 250; /** Limit geodesic subdivisions per mousemove (large jumps else insert hundreds of points in one frame). */ const BRUSH_APPEND_MAX_STEPS_PER_EVENT = 64; /** Soft cap on stroke vertices; decimate by half until under cap (keeps endpoints). */ const BRUSH_STROKE_SAMPLES_MAX = 12000; /** Long eraser strokes: subtract in sub-strokes (overlap 1 vertex) to avoid jsts hangs. */ const BRUSH_ERASER_DIFF_CHUNK_VERTS = 28; const BRUSH_ERASER_DIFF_CHUNK_OVERLAP = 1; /** Long brush strokes: build stamp per sub-path then union chunks (avoids jsts OOM / throw on 3k+ verts). */ /** Larger chunks ⇒ fewer boolean unions (jsts can throw on many outline unions). */ const BRUSH_STAMP_UNION_MAX_VERTS = 512; const BRUSH_STAMP_UNION_CHUNK_OVERLAP = 3; /** Max multipolygon parts unioned in one balanced/sequential batch before folding (avoids stack overflow in jsts). */ const BRUSH_STAMP_UNION_BATCH_SIZE = 16; /** Centerline longer than this (after pre-stroke RDP): build brush stamp from overlapping bufferPath chunks then union. */ const BRUSH_STAMP_BUFFER_CHUNK_VERTS = 200; /** Post-eraser coalesce: max merge waves (stagnation exits earlier when unions stop reducing part count). */ const BRUSH_COALESCE_MAX_PASSES = 48; /** Partial brush loss: union/simplify kept valid multipolygon but dropped area vs pre-stroke (deg² proxy). */ const BRUSH_REGRESSION_AREA_RATIO = 0.72; const BRUSH_COMPLEX_VERT_THRESHOLD = 200; const BRUSH_SHARP_TURN_MIN_RAD = 2.0; const BRUSH_SHARP_TURN_COUNT_THRESHOLD = 8; /** Canvas preview: at most this many vertices (stride subsample; full path kept for mouse-up commit). */ const BRUSH_PREVIEW_MAX_VERTICES = 480; /** While dragging brush/eraser: draw each ring with at most this many verts (full data unchanged). */ const BRUSH_LIVE_RENDER_MAX_RING_VERTS = 1400; /** While dragging brush/eraser: cursor ring uses at most this many sides (commit uses full N from sagitta). */ const BRUSH_LIVE_CURSOR_MAX_SIDES = 24; /** Max sagitta δ (m): inscribed regular N-gon stays within δ of true geodesic circle (see brushCircleSidesForRadiusMeters). */ const BRUSH_CIRCLE_SAGITTA_MIN_M = 0.2; const BRUSH_CIRCLE_SAGITTA_MAX_M = 3.0; const BRUSH_CIRCLE_SAGITTA_DEFAULT_M = 0.5; const BRUSH_CIRCLE_SIDES_MIN = 8; const BRUSH_CIRCLE_SIDES_MAX = 64; /** Chaikin passes on outer ring after brush (smooths disk-union scallops). */ const BRUSH_OUTER_CHAIKIN_ITER = 1; /** Full-viewport paint canvas: below WME side/top/bottom chrome (typically z-index in low thousands); above base map. Increase if stroke hides under the map. */ const CANVAS_OVERLAY_Z_INDEX = 500; /** Pixels outward for draft transform handles vs vertex nodes (hit + render). */ const DRAFT_BBOX_PAD_PX = 12; /** 10px hit radius squared (vertex / midpoint stubs). */ const VERTEX_HIT_RADIUS_SQ = 100; const LS_BRUSH_VERTEX_MIN_M = 'wmeCandyPaintBrushVertexMinM'; const LS_BRUSH_MIN_TURN_DEG = 'wmeCandyPaintBrushMinTurnDeg'; /** Brush/eraser geodesic radius (meters); slider writes here on change. */ const LS_BRUSH_SIZE_M = 'wmeCandyPaintBrushRadiusM'; /** Brush circle sagitta δ (m); optional slider; same policy drives geodesic disks and JSTS buffer quadrant segments. */ const LS_BRUSH_CIRCLE_SAGITTA_M = 'wmeCandyPaintBrushCircleSagittaM'; /** freehand | line */ const LS_BRUSH_STYLE = 'wmeCandyPaintBrushStrokeStyle'; /** circle | square | diamond | hexagon | rounded_square */ const LS_BRUSH_PROFILE = 'wmeCandyPaintBrushProfile'; /** map | stroke */ const LS_BRUSH_ALIGN = 'wmeCandyPaintBrushAlign'; /** Corner radius as fraction of R for rounded square */ const LS_BRUSH_CORNER_RATIO = 'wmeCandyPaintBrushCornerRatio'; const BRUSH_PROFILE_IDS = ['circle', 'square', 'diamond', 'hexagon', 'rounded_square']; const BRUSH_ALIGN_IDS = ['map', 'stroke']; const BRUSH_CORNER_RATIO_MIN = 0.06; const BRUSH_CORNER_RATIO_MAX = 0.45; const BRUSH_CORNER_RATIO_DEFAULT = 0.18; /** Densify stroke so stamp centers are at most this factor × R apart (overlap). */ const BRUSH_POLY_STAMP_SPACING_FACTOR = 0.75; /** Segments per quarter-circle on rounded-square local outline. */ const BRUSH_ROUNDED_SQUARE_ARC_SEGS = 5; function readPersistedBrushSizeM() { try { const raw = localStorage.getItem(LS_BRUSH_SIZE_M); if (raw == null || raw === '') return null; const n = Number(raw); if (!Number.isFinite(n)) return null; return Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Math.round(n))); } catch (e) { return null; } } function writePersistedBrushSizeM(meters) { try { const v = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Math.round(Number(meters)))); localStorage.setItem(LS_BRUSH_SIZE_M, String(v)); } catch (e) { /* ignore */ } } function readPersistedBrushCircleSagittaM() { try { const raw = localStorage.getItem(LS_BRUSH_CIRCLE_SAGITTA_M); if (raw == null || raw === '') return null; const n = Number(raw); if (!Number.isFinite(n)) return null; const v = Math.round(n * 20) / 20; return Math.max(BRUSH_CIRCLE_SAGITTA_MIN_M, Math.min(BRUSH_CIRCLE_SAGITTA_MAX_M, v)); } catch (e) { return null; } } function writePersistedBrushCircleSagittaM(deltaM) { try { const v = Math.round(Number(deltaM) * 20) / 20; const c = Math.max(BRUSH_CIRCLE_SAGITTA_MIN_M, Math.min(BRUSH_CIRCLE_SAGITTA_MAX_M, v)); localStorage.setItem(LS_BRUSH_CIRCLE_SAGITTA_M, String(c)); } catch (e) { /* ignore */ } } function readPersistedBrushStyle() { try { const raw = localStorage.getItem(LS_BRUSH_STYLE); if (raw === 'line' || raw === 'freehand') return raw; } catch (e) { /* ignore */ } return null; } function writePersistedBrushStyle(style) { try { if (style === 'line' || style === 'freehand') localStorage.setItem(LS_BRUSH_STYLE, style); } catch (e) { /* ignore */ } } function readPersistedBrushProfile() { try { const raw = localStorage.getItem(LS_BRUSH_PROFILE); if (raw && BRUSH_PROFILE_IDS.includes(raw)) return raw; } catch (e) { /* ignore */ } return null; } function writePersistedBrushProfile(id) { try { if (BRUSH_PROFILE_IDS.includes(id)) localStorage.setItem(LS_BRUSH_PROFILE, id); } catch (e) { /* ignore */ } } function readPersistedBrushAlign() { try { const raw = localStorage.getItem(LS_BRUSH_ALIGN); if (raw && BRUSH_ALIGN_IDS.includes(raw)) return raw; } catch (e) { /* ignore */ } return null; } function writePersistedBrushAlign(id) { try { if (BRUSH_ALIGN_IDS.includes(id)) localStorage.setItem(LS_BRUSH_ALIGN, id); } catch (e) { /* ignore */ } } function readPersistedBrushCornerRatio() { try { const raw = localStorage.getItem(LS_BRUSH_CORNER_RATIO); if (raw == null || raw === '') return null; const n = Number(raw); if (!Number.isFinite(n)) return null; return Math.max(BRUSH_CORNER_RATIO_MIN, Math.min(BRUSH_CORNER_RATIO_MAX, Math.round(n * 1000) / 1000)); } catch (e) { return null; } } function writePersistedBrushCornerRatio(ratio) { try { const v = Math.max(BRUSH_CORNER_RATIO_MIN, Math.min(BRUSH_CORNER_RATIO_MAX, Number(ratio))); localStorage.setItem(LS_BRUSH_CORNER_RATIO, String(Math.round(v * 1000) / 1000)); } catch (e) { /* ignore */ } } let brushPainting = false; let brushIsEraser = false; let brushStrokeSamples = []; /** Skip venue link validation every frame while painting (expensive on large maps). */ let applyHudInvalidLinksCache = false; // Brush tool writes into the shared masterPolygons layer. // --- CENTRALIZED STATE MACHINE --- const appState = { tool: 'pan', mode: getPersistedCommitMode() || 'replace', brushSize: readPersistedBrushSizeM() ?? 12, brushCircleSagittaM: readPersistedBrushCircleSagittaM() ?? BRUSH_CIRCLE_SAGITTA_DEFAULT_M, wandTolerance: 50, brushStyle: readPersistedBrushStyle() ?? 'freehand', brushProfile: readPersistedBrushProfile() ?? 'circle', brushAlign: readPersistedBrushAlign() ?? 'map', brushCornerRatio: readPersistedBrushCornerRatio() ?? BRUSH_CORNER_RATIO_DEFAULT, targetVenueObj: null, targetVenueId: null, isDraftActive: false, highlightedLinkIndex: -1 }; /** * Same primary URL as @require, plus CDN fallbacks if injection is blocked. Bridge userscripts that inline this * file (or load it via file://) do not get @require, so we inject when window.jsts is missing. */ const JSTS_SCRIPT_URLS = [ 'https://cdn.jsdelivr.net/npm/[email protected]/dist/jsts.min.js', 'https://unpkg.com/[email protected]/dist/jsts.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/jsts/2.12.1/jsts.min.js', ]; let geometryEngineLoadCallbacks = null; /** True when dynamic script injection of jsts failed (e.g. blocked CDN). */ let geometryEngineLoadFailed = false; // Guard against repeated bootstrap retries stacking listeners and/or double init. let wmeReadyListenerAdded = false; let initStarted = false; let bootstrapAttempts = 0; function initScriptOnce() { if (initStarted) return; initStarted = true; initScript(); } function onWmeReady() { ensureGeometryEngine(initScriptOnce); } const _jstsWrapperCache = { reader: null, writer: null, getReader() { if (!this.reader) this.reader = new window.jsts.io.GeoJSONReader(); return this.reader; }, getWriter() { if (!this.writer) this.writer = new window.jsts.io.GeoJSONWriter(); return this.writer; }, getDepth(arr) { let depth = 0; let current = arr; while (Array.isArray(current)) { depth++; current = current[0]; } return depth; }, toJSTS(coords) { if (!coords || !Array.isArray(coords) || coords.length === 0) return null; const depth = this.getDepth(coords); let geojson = null; if (depth === 2) { geojson = { type: 'Polygon', coordinates: [coords] }; } else if (depth === 3) { geojson = { type: 'Polygon', coordinates: coords }; } else if (depth === 4) { geojson = { type: 'MultiPolygon', coordinates: coords }; } else if (depth >= 5) { let resultGeom = null; for (const item of coords) { const g = this.toJSTS(item); if (!g) continue; if (!resultGeom) resultGeom = g; else { try { resultGeom = resultGeom.buffer(0).union(g.buffer(0)); } catch(e) { console.error('[Candy Paint] JSTS Union Error during read:', e); } } } return resultGeom; } if (!geojson) return null; try { return this.getReader().read(geojson); } catch (e) { console.error('[Candy Paint] JSTS Read Error:', e, geojson); return null; } }, fromJSTS(geom) { if (!geom || geom.isEmpty()) return []; const geojson = this.getWriter().write(geom); if (geojson.type === 'MultiPolygon') { return geojson.coordinates; } else if (geojson.type === 'Polygon') { return [geojson.coordinates]; } else if (geojson.type === 'GeometryCollection') { const out = []; for (const g of geojson.geometries) { if (g.type === 'MultiPolygon') out.push(...g.coordinates); else if (g.type === 'Polygon') out.push(g.coordinates); } return out; } return []; }, /** * Single closed shell (possibly self-intersecting) → planar faces as separate Candy parts. * Not used when the part has interior rings (real holes) — those use buffer(0) only. */ polygonizeClosedShellLonLatToParts(shellLonLat) { const j = window.jsts; if (!j || !shellLonLat || shellLonLat.length < 4) return null; const Polygonizer = j.operation && j.operation.polygonize && j.operation.polygonize.Polygonizer; const UnaryUnionOp = j.operation && j.operation.union && j.operation.union.UnaryUnionOp; const Coordinate = j.geom && j.geom.Coordinate; if (!Polygonizer || !UnaryUnionOp || !Coordinate) return null; try { const reader = this.getReader(); const anchor = reader.read({ type: 'Point', coordinates: [shellLonLat[0][0], shellLonLat[0][1]] }); const factory = anchor.getFactory(); const coords = []; for (let k = 0; k < shellLonLat.length; k++) { const p = shellLonLat[k]; coords.push(new Coordinate(p[0], p[1])); } if (coords.length > 1 && coords[0].equals2D(coords[coords.length - 1])) coords.pop(); if (coords.length < 3) return null; const segs = []; for (let i = 0; i < coords.length; i++) { const jn = (i + 1) % coords.length; segs.push(factory.createLineString([coords[i], coords[jn]])); } const ml = factory.createMultiLineString(segs); const noded = UnaryUnionOp.union(ml); if (!noded || noded.isEmpty()) return null; const writer = this.getWriter(); const minAbsArea = 1e-22; const extract = (checkRings) => { const pol = new Polygonizer(); pol.setCheckRingsValid(checkRings); pol.add(noded); const polyList = pol.getPolygons(); if (!polyList || polyList.size() === 0) return []; const parts = []; for (let pi = 0; pi < polyList.size(); pi++) { const pg = polyList.get(pi); if (!pg || pg.isEmpty()) continue; let a = 0; try { a = Math.abs(pg.getArea()); } catch (e) { a = 1; } if (a < minAbsArea) continue; const gj = writer.write(pg); if (gj.type === 'Polygon' && gj.coordinates && gj.coordinates.length) { parts.push(gj.coordinates.map(ring => ring.map(c => [c[0], c[1]]))); } } return parts; }; let parts = extract(true); if (parts.length === 0) parts = extract(false); return parts.length ? parts : null; } catch (e) { if (DEBUG) console.warn('[WME Candy Shop][polygonize] closed shell:', e); return null; } }, combine(geoms, opName) { const valid = geoms.filter(g => g !== null && !g.isEmpty()); if (valid.length === 0) return null; let result = valid[0]; for (let i = 1; i < valid.length; i++) { try { // Geometry precision issues can cause non-noded intersection topology exceptions. // A tiny buffer(0) operation often snaps coordinates properly before applying the operation. const a = result.buffer(0); const b = valid[i].buffer(0); result = a[opName](b); } catch (e) { console.error(`[Candy Paint] JSTS ${opName} error:`, e); } } return result; }, applyOp(opName, args) { if (args.length === 0) return []; const geoms = []; for (const arg of args) { if (arg == null) continue; const geom = this.toJSTS(arg); if (geom) geoms.push(geom); } if (geoms.length === 0) return []; if (geoms.length === 1 && (opName === 'union' || opName === 'symDifference')) { return this.fromJSTS(geoms[0]); } const resultGeom = this.combine(geoms, opName); return this.fromJSTS(resultGeom); } }; const jstsWrapper = { union: (...args) => _jstsWrapperCache.applyOp('union', args), difference: (...args) => _jstsWrapperCache.applyOp('difference', args), intersection: (...args) => _jstsWrapperCache.applyOp('intersection', args), xor: (...args) => _jstsWrapperCache.applyOp('symDifference', args), /** True if JSTS geometries intersect with non-empty area (P10 commit vs intersecting parts). */ geometriesIntersect(geomCoordsA, geomCoordsB) { try { const ga = _jstsWrapperCache.toJSTS(geomCoordsA); const gb = _jstsWrapperCache.toJSTS(geomCoordsB); if (!ga || !gb || ga.isEmpty() || gb.isEmpty()) return false; const inter = ga.buffer(0).intersection(gb.buffer(0)); return inter != null && !inter.isEmpty(); } catch (e) { return false; } }, /** JSTS buffer(0) per Candy part (array of rings); MultiPolygon → multiple parts. */ sanitizePartToParts(part) { if (!part || !Array.isArray(part) || part.length === 0) return []; try { if (part.length === 1 && part[0] && part[0].length >= 4) { const polyParts = _jstsWrapperCache.polygonizeClosedShellLonLatToParts(part[0]); if (polyParts && polyParts.length > 0) return polyParts; } const g = _jstsWrapperCache.toJSTS(part); if (!g || g.isEmpty()) return []; const fixed = g.buffer(0); if (!fixed || fixed.isEmpty()) return []; return _jstsWrapperCache.fromJSTS(fixed); } catch (e) { console.error('[Candy Paint] JSTS sanitizePartToParts error:', e); return []; } }, bufferPath: (lonLatArray, radiusM, capSegments = 8) => { if (!lonLatArray || lonLatArray.length === 0) return []; const nRaw = lonLatArray.length; const refLon = lonLatArray.reduce((s, p) => s + p[0], 0) / nRaw; const refLat = lonLatArray.reduce((s, p) => s + p[1], 0) / nRaw; const localMeters = lonLatArray.map(p => lonLatToLocalMeters(p[0], p[1], refLon, refLat)); const deduplicated = []; for (let i = 0; i < localMeters.length; i++) { if (i === 0 || Math.hypot(localMeters[i].x - deduplicated[deduplicated.length - 1][0], localMeters[i].y - deduplicated[deduplicated.length - 1][1]) > 1e-4) { deduplicated.push([localMeters[i].x, localMeters[i].y]); } } if (deduplicated.length === 0) return []; let geojson; if (deduplicated.length === 1) { geojson = { type: 'Point', coordinates: deduplicated[0] }; } else { geojson = { type: 'LineString', coordinates: deduplicated }; } try { const geom = _jstsWrapperCache.getReader().read(geojson); const bufferedGeom = geom.buffer(radiusM, capSegments); const outputGeojson = _jstsWrapperCache.getWriter().write(bufferedGeom); const convertCoordinates = (coords, depth) => { if (depth === 0) { const lonLat = localMetersToLonLat(coords[0], coords[1], refLon, refLat); return [lonLat[0], lonLat[1]]; } return coords.map(c => convertCoordinates(c, depth - 1)); }; let result = []; if (outputGeojson.type === 'Polygon') { result = [convertCoordinates(outputGeojson.coordinates, 2)]; } else if (outputGeojson.type === 'MultiPolygon') { result = convertCoordinates(outputGeojson.coordinates, 3); } else if (outputGeojson.type === 'GeometryCollection') { for (const g of outputGeojson.geometries) { if (g.type === 'MultiPolygon') { result.push(...convertCoordinates(g.coordinates, 3)); } else if (g.type === 'Polygon') { result.push(convertCoordinates(g.coordinates, 2)); } } } return result; } catch (e) { console.error('[Candy Paint] JSTS bufferPath error:', e); return []; } } }; function getGeometryEngine() { return window.jsts ? jstsWrapper : null; } function ensureGeometryEngine(done) { if (window.jsts) { done(); return; } if (geometryEngineLoadCallbacks) { geometryEngineLoadCallbacks.push(done); return; } geometryEngineLoadCallbacks = [done]; try { logToUI('Loading geometry engine (JSTS)…'); } catch (e) { /* ignore */ } function drainSuccess() { geometryEngineLoadFailed = false; const cbs = geometryEngineLoadCallbacks; geometryEngineLoadCallbacks = null; if (cbs) cbs.forEach(fn => fn()); } function tryLoad(urlIndex) { if (window.jsts) { drainSuccess(); return; } if (urlIndex >= JSTS_SCRIPT_URLS.length) { geometryEngineLoadFailed = true; geometryEngineLoadCallbacks = null; console.error('[WME Candy Shop] Failed to load jsts from all sources:', JSTS_SCRIPT_URLS); try { logToUI('Geometry engine failed to load (JSTS). Tools are disabled.', true); } catch (e) { /* ignore */ } initScriptOnce(); return; } // WME Trusted Types workaround for `.src` / `.innerHTML` warnings (report-only) const s = document.createElement('script'); const url = JSTS_SCRIPT_URLS[urlIndex]; s.setAttribute('src', url); s.onload = drainSuccess; s.onerror = () => { if (s.parentNode) s.parentNode.removeChild(s); tryLoad(urlIndex + 1); }; (document.head || document.documentElement).appendChild(s); } tryLoad(0); } /** * Host map adapter: WME OpenLayers map (`window.W.map`). * - Use this (not raw `W.map`) for resolution, projection, and map event registration so call sites stay in one place. * - `wmeSDK.Map` (wme-sdk-plus) provides high-level helpers such as `getMapCenter()`; it does not replace OL * `getResolution` / `getProjectionObject` on the pinned build — those still come from `getHostMap()`. * - If `W.map` is missing, returns null; callers must handle null (fail-soft). */ function getHostMap() { return window.W?.map ?? null; } function getMapResolution() { const map = getHostMap(); return typeof map?.getResolution === 'function' ? map.getResolution() : null; } function getMapProjection() { const map = getHostMap(); return typeof map?.getProjectionObject === 'function' ? map.getProjectionObject() : null; } function isImperialUnits() { return !!window.W?.prefs?.attributes?.isImperial; } function getHoveredLayerLabel() { const hi = window.W?.selectionManager?.hoveredItem; if (!hi) return 'None'; const m = hi.model || hi; return m.attributes?.name || m.type || 'None'; } function registerMapViewChange(handler) { const map = getHostMap(); if (map?.events?.register) { map.events.register('move', null, handler); map.events.register('zoomend', null, handler); } } /** @returns {number|null} geodesic length in meters (WME OpenLayers), or null if unavailable */ function hostGeodesicLineLengthMeters(lon1, lat1, lon2, lat2) { const OL = window.OpenLayers; const proj = getMapProjection(); if (!OL?.Geometry?.Point || !OL?.Projection || !proj) return null; try { const p4326 = new OL.Projection('EPSG:4326'); let a = new OL.Geometry.Point(lon1, lat1); let b = new OL.Geometry.Point(lon2, lat2); a.transform(p4326, proj); b.transform(p4326, proj); const line = new OL.Geometry.LineString([a, b]); return line.getGeodesicLength(proj); } catch (e) { return null; } } /** * @param {Array<{lon:number,lat:number}>} verticesLonLat closed or open ring in WGS84 * @returns {{ area: number, perimeter: number, olPoly: object }|null} */ function hostGeodesicPolygonAreaAndPerimeter(verticesLonLat) { const OL = window.OpenLayers; const proj = getMapProjection(); if (!OL?.Geometry?.Point || !OL?.Projection || !proj || !verticesLonLat?.length) return null; try { const p4326 = new OL.Projection('EPSG:4326'); const olPts = verticesLonLat.map(v => { const pt = new OL.Geometry.Point(v.lon, v.lat); pt.transform(p4326, proj); return pt; }); if (olPts[0].x !== olPts[olPts.length - 1].x || olPts[0].y !== olPts[olPts.length - 1].y) { olPts.push(olPts[0].clone()); } const ring = new OL.Geometry.LinearRing(olPts); const poly = new OL.Geometry.Polygon([ring]); return { area: poly.getGeodesicArea(proj), perimeter: ring.getGeodesicLength(proj), olPoly: poly }; } catch (e) { return null; } } function isGeometryLibraryReady() { return !!getGeometryEngine() && !geometryEngineLoadFailed; } /** Visible notice when WME never reaches a ready state (complements console.error for users without DevTools). */ function showBootstrapMaxAttemptsFailure() { if (document.getElementById('wme-candy-shop-bootstrap-fail')) return; const mount = () => { if (document.getElementById('wme-candy-shop-bootstrap-fail')) return; if (!document.body) { setTimeout(mount, 100); return; } const el = document.createElement('div'); el.id = 'wme-candy-shop-bootstrap-fail'; el.setAttribute('role', 'alert'); el.textContent = 'WME Candy Shop: the Waze editor did not become ready in time. Reload the page or open the browser console for details.'; el.style.cssText = [ 'position:fixed', 'bottom:12px', 'left:12px', 'max-width:min(420px,calc(100vw - 24px))', 'z-index:2147483646', 'padding:10px 12px', 'font:12px/1.35 "Helvetica Neue",Helvetica,Arial,sans-serif', 'color:#fff', 'background:#c0392b', 'border-radius:6px', 'box-shadow:0 2px 12px rgba(0,0,0,0.35)' ].join(';'); document.body.appendChild(el); }; mount(); } // --- 1. BOOTSTRAP & WME SDK INITIALIZATION --- function bootstrap() { if (window.W && window.W.loginManager && window.W.loginManager.user && getHostMap()) { ensureGeometryEngine(initScriptOnce); return; } if (!wmeReadyListenerAdded) { wmeReadyListenerAdded = true; document.addEventListener('wme-ready', onWmeReady, { once: true }); } bootstrapAttempts++; if (bootstrapAttempts > BOOTSTRAP_MAX_ATTEMPTS) { console.error('[WME Candy Shop] WME not ready after maximum bootstrap attempts.'); showBootstrapMaxAttemptsFailure(); return; } setTimeout(bootstrap, BOOTSTRAP_INTERVAL_MS); } function initScript() { wmeSDK = window.getWmeSdk({ scriptId: 'wme-candy-shop', scriptName: 'WME Candy Shop' }); initPaintDocument(); injectCanvasOverlay(); createRefactoredUI(); createTrackerHUD(); wmeSDK.Events.on({ eventName: 'wme-selection-changed', eventHandler: debouncedHandleSelectionChange }); /* Selection: wme-sdk-plus Events only; avoid duplicate W.selectionManager (same updates, double DOM work). */ registerPaintHistorySdkLifecycleClear(); registerMapViewChange(updateCanvasOnMapChange); const map = getHostMap(); if (map && typeof map.updateSize === 'function') { setTimeout(() => { map.updateSize(); window.dispatchEvent(new Event('resize')); }, 800); } requestAnimationFrame(trackFPS); logToUI('WME Candy Shop online.'); if (!isGeometryLibraryReady()) { if (geometryEngineLoadFailed) { logToUI('Geometry library failed to load — Ingest/Apply and shape boolean tools are disabled.', true); } else { logToUI('Geometry library not available — Ingest/Apply and shape boolean tools are disabled.', true); } } applyGeometryDependencyToUI(); handleSelectionChange(); document.addEventListener('keydown', onDocumentModifierAndExperimentalKeydown, true); document.addEventListener('keyup', onDocumentModifierKeyup, true); } function trackFPS(now) { fpsFrames++; if (now - fpsLastTime >= 1000) { const fpsEl = document.getElementById('hud-fps-val'); if (fpsEl) { fpsEl.innerText = fpsFrames; if (fpsFrames >= 45) fpsEl.style.color = '#26CC9A'; else if (fpsFrames >= 25) fpsEl.style.color = '#FFC000'; else fpsEl.style.color = '#FF5B5B'; } fpsFrames = 0; fpsLastTime = now; } requestAnimationFrame(trackFPS); } // --- 2. STATE MACHINE MANAGERS --- /** * @param {string} newTool * @param {{ quiet?: boolean, preserveDrawingState?: boolean }} [opts] If quiet, skip default "Tool: …" log. * preserveDrawingState: keep tempVertices / isDrawingShape (undo/redo restore with polygon in progress). */ function changeTool(newTool, opts) { endBrushPainting(); appState.tool = newTool; if (newTool !== 'link') pendingLinkVenueId = null; if (newTool === 'pan') { canvasElement.style.pointerEvents = appState.isDraftActive ? 'auto' : 'none'; canvasElement.style.cursor = ''; } else { canvasElement.style.pointerEvents = 'auto'; if (newTool === 'brush' || newTool === 'eraser') { canvasElement.style.cursor = 'crosshair'; } else { canvasElement.style.cursor = ''; } } const preserveDrawing = opts && opts.preserveDrawingState; if (!preserveDrawing) { isDrawingShape = false; tempVertices = []; } hoveredTempVertexIndex = -1; draggingVertexIndex = -1; hoveredMasterPolyIndex = -1; document.querySelectorAll('.wpo-btn-tool').forEach(btn => { btn.classList.toggle('active', btn.getAttribute('data-tool') === newTool); }); renderOptionsRow(); requestRender(); updateTrackerHUD(); if (!opts || !opts.quiet) logToUI(`Tool: ${newTool}`); } function changeMode(newMode, opts) { if (!COMMIT_MODE_IDS.has(newMode)) return; appState.mode = newMode; document.querySelectorAll('.wpo-btn-mode').forEach(btn => { btn.classList.toggle('active', btn.getAttribute('data-val') === newMode); }); requestRender(); if (opts && opts.persistCommitMode) setPersistedCommitMode(newMode); } function setDraftActive(state) { appState.isDraftActive = state; if (!state) { draftAction = 'none'; hoveredDraftVertexIndex = -1; draggingDraftVertexIndex = -1; hoveredDraftMidpointIndex = -1; draftVertices = []; draftInteriorRings = []; dragStartDraftRingsPx = null; draftPopInsertMasterIndex = -1; if (canvasElement && appState.tool === 'pan') canvasElement.style.pointerEvents = 'none'; } else if (canvasElement) { canvasElement.style.pointerEvents = 'auto'; } renderOptionsRow(); } function applyGeometryDependencyToUI() { const ready = isGeometryLibraryReady(); updateApplyButtonState(); document.querySelectorAll('.wpo-btn-tool').forEach(btn => { const tool = btn.getAttribute('data-tool'); const allowWithoutPolyClip = tool === 'pan' || tool === 'measure'; if (!allowWithoutPolyClip) { btn.disabled = !ready; btn.style.opacity = !ready ? '0.4' : ''; btn.style.pointerEvents = !ready ? 'none' : ''; } else { btn.disabled = false; btn.style.opacity = ''; btn.style.pointerEvents = ''; } }); if (!ready && appState.tool !== 'pan' && appState.tool !== 'measure') { changeTool('pan'); } } function handleSelectionChange() { const models = getSelectedAreaVenues(); const ingestBtn = document.getElementById('wpo-btn-ingest'); const targetText = document.getElementById('wpo-target-text'); const geomReady = isGeometryLibraryReady(); if (appState.tool === 'link') { if (models.length === 1 && models[0].id != null && models[0].id !== '') { pendingLinkVenueId = normalizeVenueIdForSdk(models[0].id); } else { pendingLinkVenueId = null; } } appState.highlightedLinkIndex = -1; if (models.length === 1 && models[0].id != null && models[0].id !== '' && masterPolygons.length) { const sid = normalizeVenueIdForSdk(models[0].id); let hit = -1; for (let i = 0; i < shapePlaceLinks.length; i++) { const L = shapePlaceLinks[i]; if (L && venueIdsMatch(L.venueId, sid) && isActiveNonBrokenLink(L)) { hit = i; break; } } if (hit < 0) { for (let i = 0; i < shapePlaceLinks.length; i++) { const L = shapePlaceLinks[i]; if (L && venueIdsMatch(L.venueId, sid)) { hit = i; break; } } } appState.highlightedLinkIndex = hit; } if (models.length > 0) { appState.targetVenueObj = models[0]; const rawId = models[0].id; appState.targetVenueId = rawId != null && rawId !== '' ? normalizeVenueIdForSdk(rawId) : null; const label = appState.targetVenueId != null ? String(appState.targetVenueId) : '(no venue id)'; targetText.innerHTML = `Target: <span style="color:#00A1F1; font-weight:bold;">${label}</span>`; ingestBtn.disabled = !geomReady; ingestBtn.style.opacity = geomReady ? '1' : '0.5'; ingestBtn.style.cursor = geomReady ? 'pointer' : 'not-allowed'; } else { appState.targetVenueObj = null; appState.targetVenueId = null; targetText.innerHTML = `Target: <em>None</em>`; ingestBtn.disabled = true; ingestBtn.style.opacity = '0.5'; ingestBtn.style.cursor = 'not-allowed'; } updateApplyButtonState(); updateTrackerHUD(); requestRender(); scheduleAutoIngest(); } function debouncedHandleSelectionChange() { if (selectionChangeDebounceTimer) clearTimeout(selectionChangeDebounceTimer); selectionChangeDebounceTimer = setTimeout(() => { selectionChangeDebounceTimer = null; handleSelectionChange(); }, SELECTION_CHANGE_DEBOUNCE_MS); } // --- 3. UI GENERATION (HTML/CSS INJECTION) --- function injectStyles() { if(document.getElementById('wpo-styles')) return; const style = document.createElement('style'); style.id = 'wpo-styles'; style.innerHTML = ` #wme-paint-palette { position: fixed; top: 100px; right: 50px; width: auto; min-width: 310px; z-index: 9999999; background: #ffffff; border: 1px solid #c2c9d1; border-radius: 6px; box-shadow: 0 4px 15px rgba(0,0,0,0.15); font-family: "Helvetica Neue", Helvetica, "Boing", Arial, sans-serif; display: flex; flex-direction: column; overflow: hidden; color: #333; white-space: nowrap; } .wpo-header { background: #00A1F1; color: #fff; padding: 5px 8px; font-size: 13px; font-weight: bold; cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center; } .wpo-hdr-btn { background: transparent; border: none; color: #fff; cursor: pointer; opacity: 0.7; margin-left: 6px; font-size: 13px; padding: 0 3px; } .wpo-hdr-btn:hover { opacity: 1; } .wpo-body { padding: 6px; display: flex; flex-direction: column; gap: 5px; } .wpo-row { display: flex; align-items: center; width: 100%; } .wpo-target-row { justify-content: space-between; border-bottom: 1px solid #e0e4e8; padding-bottom: 5px; } .wpo-tool-row { background: #f0f2f5; border-radius: 4px; padding: 3px; flex-wrap: nowrap; gap: 2px; border: 1px solid #e0e4e8; } .wpo-options-row { background: #f0f2f5; border-radius: 4px; padding: 3px 6px; min-height: 28px; border: 1px solid #e0e4e8; justify-content: space-between; transition: all 0.2s; white-space: normal; } .wpo-actions-row { justify-content: flex-end; gap: 3px; } .wpo-tool-group { display: flex; gap: 1px; border-right: 1px solid #c2c9d1; padding-right: 3px; margin-right: 2px; } .wpo-tool-group:last-child { border-right: none; padding-right: 0; margin-right: 0; } .wpo-btn { border: 1px solid transparent; background: transparent; padding: 4px 6px; cursor: pointer; border-radius: 4px; color: #495057; font-size: 13px; transition: all 0.15s ease-in-out; display: flex; align-items: center; justify-content: center; } .wpo-btn:hover:not(:disabled) { background: #e0e4e8; } .wpo-btn:disabled { opacity: 0.5; cursor: not-allowed; } .wpo-btn.active { background: #00A1F1; color: white; box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); border-color: #0084C9; } .wpo-icon-only { background: transparent; border: none; cursor: pointer; font-size: 16px; opacity: 0.7; transition: all 0.2s; display: flex; align-items: center; justify-content: center; padding: 2px 6px; } .wpo-icon-only:hover:not(:disabled) { opacity: 1; transform: scale(1.1); } .wpo-icon-only:disabled { opacity: 0.3 !important; cursor: not-allowed !important; color: #999 !important; transform: none !important; filter: grayscale(100%); } .wpo-icon-success { color: #26CC9A; } .wpo-icon-danger { color: #FF5B5B; } .wpo-btn-action { font-size: 11px; font-weight: bold; padding: 4px 8px; gap: 4px; border: 1px solid #c2c9d1; background: #fff; border-radius: 4px; } .wpo-btn-action:hover:not(:disabled) { background: #f4f6f8; } .wpo-btn-success { background: #26CC9A; color: white; border-color: #1FB386; } .wpo-btn-success:hover:not(:disabled) { background: #1FB386; } .wpo-slider-container { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #555; font-weight: bold; min-width: 0; } .wpo-slider { width: 70px; margin: 0; flex: 0 0 auto; } .wpo-slider.wpo-slider--brush { width: 88px; min-width: 48px; flex: 1 1 72px; max-width: 140px; } .wpo-brush-options { display: flex; flex-flow: row nowrap; align-items: center; gap: 6px; margin-left: auto; max-width: 100%; justify-content: flex-end; overflow-x: auto; } .wpo-brush-sliders { display: flex; flex-flow: row nowrap; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; } .wpo-brush-verts { font-size: 10px; color: #6c757d; font-weight: normal; line-height: 1.2; white-space: nowrap; flex-shrink: 0; } .wpo-brush-style-btn { padding: 3px 7px; min-width: 28px; } .wpo-brush-profile-btn { padding: 3px 5px; min-width: 24px; font-size: 12px; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } #wme-paint-log { height: 60px; overflow-y: auto; background: #212529; color:#fff; border-radius: 3px; padding: 4px; font-family: monospace; font-size: 10px; margin-top: 2px; border: 1px inset #555; } @keyframes wpo-pulse { 0% { opacity: 1; } 50% { opacity: 0.2; } 100% { opacity: 1; } } .wpo-blinking-dot { display: inline-block; width: 8px; height: 8px; background-color: #FF5B5B; border-radius: 50%; animation: wpo-pulse 1.5s infinite; } .wpo-meta-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; font-size: 10px; border-bottom: 1px solid #e8eaed; padding-bottom: 5px; margin-bottom: 2px; } #wpo-mode-chip { display: none; align-items: center; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: bold; border: 1px solid #c2c9d1; background: #f8f9fa; white-space: nowrap; } .wpo-auto-ingest-label { display: flex; align-items: center; gap: 4px; color: #495057; cursor: pointer; user-select: none; } .wpo-layer-strip { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; border-bottom: 1px solid #e0e4e8; padding-bottom: 6px; margin-bottom: 4px; } .wpo-layer-strip select { flex: 1; min-width: 120px; max-width: 220px; font-size: 11px; } .wpo-places-block { margin-top: 2px; border: 1px solid #e0e4e8; border-radius: 4px; padding: 4px; max-height: 140px; overflow-y: auto; background: #fafbfc; } .wpo-places-block-h { font-size: 10px; font-weight: bold; color: #495057; margin-bottom: 4px; } .wpo-places-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 2px 0; border-bottom: 1px solid #eef0f2; } .wpo-places-row:last-child { border-bottom: none; } .wpo-places-row-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 10px; } .wpo-places-empty { font-size: 10px; color: #6c757d; padding: 4px; } #wme-cs-history-panel { position: fixed; top: 120px; left: 360px; width: 260px; max-height: min(56vh, 420px); z-index: 9999998; display: flex; flex-direction: column; background: #fff; border: 1px solid #cfd8dc; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); font-family: sans-serif; font-size: 11px; } #wme-cs-history-panel.csh-hidden { display: none !important; } .csh-header { background: #5e35b1; color: #fff; padding: 6px 8px; font-weight: bold; cursor: move; user-select: none; display: flex; align-items: center; justify-content: space-between; border-radius: 5px 5px 0 0; } .csh-header-btns { display: flex; gap: 4px; } .csh-hdr-btn { background: transparent; border: none; color: #fff; cursor: pointer; opacity: 0.85; padding: 2px 6px; font-size: 12px; border-radius: 3px; } .csh-hdr-btn:hover { opacity: 1; background: rgba(255,255,255,0.12); } .csh-body { display: flex; flex-direction: column; min-height: 0; flex: 1; } .csh-hint { margin: 0; padding: 6px 8px; font-size: 9px; color: #6c757d; line-height: 1.35; border-bottom: 1px solid #e8eaed; } .csh-scroll { flex: 1; min-height: 60px; overflow-y: auto; padding: 4px 6px; } .csh-row { padding: 5px 8px; border-radius: 4px; margin-bottom: 2px; cursor: default; font-size: 10px; } .csh-row.csh-current { background: rgba(94, 53, 177, 0.12); font-weight: 600; color: #4527a0; border: 1px solid rgba(94, 53, 177, 0.25); } .csh-row.csh-past { cursor: pointer; border: 1px solid transparent; } .csh-row.csh-past:hover { background: #f0f2f5; border-color: #dee2e6; } .csh-lab { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #wme-cs-layers-panel { position: fixed; top: 120px; left: 24px; width: 320px; max-height: min(72vh, 560px); z-index: 9999998; background: #fff; border: 1px solid #c2c9d1; border-radius: 6px; box-shadow: 0 4px 15px rgba(0,0,0,0.12); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; display: flex; flex-direction: column; color: #333; font-size: 11px; } #wme-cs-layers-panel.csl-hidden { display: none !important; } .csl-header { background: #00838f; color: #fff; padding: 6px 8px; font-weight: bold; cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; border-radius: 5px 5px 0 0; } .csl-header-btns { display: flex; gap: 4px; } .csl-hdr-btn { background: transparent; border: none; color: #fff; cursor: pointer; opacity: 0.85; padding: 2px 6px; font-size: 12px; border-radius: 3px; } .csl-hdr-btn:hover { opacity: 1; background: rgba(255,255,255,0.12); } .csl-toolbar { display: flex; gap: 6px; align-items: center; padding: 6px 8px; border-bottom: 1px solid #e0e4e8; flex-wrap: wrap; } .csl-filter { flex: 1; min-width: 100px; font-size: 11px; padding: 3px 6px; border: 1px solid #c2c9d1; border-radius: 4px; } .csl-scroll { flex: 1; min-height: 80px; overflow-y: auto; padding: 4px 6px; } .csl-layer-block { border: 1px solid #e4e8ec; border-radius: 4px; margin-bottom: 6px; background: #fafbfc; } .csl-layer-head { display: flex; align-items: center; gap: 4px; padding: 4px 6px; background: #eef2f5; border-radius: 4px 4px 0 0; flex-wrap: wrap; } .csl-layer-head.csl-active-layer { box-shadow: inset 0 -2px 0 #00A1F1; } .csl-drag-h { cursor: grab; color: #6c757d; padding: 0 2px; user-select: none; } .csl-name { flex: 1; min-width: 60px; font-weight: 600; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .csl-name-input { flex: 1; min-width: 60px; font-size: 11px; padding: 2px 4px; } .csl-badge { font-size: 9px; color: #495057; background: #e9ecef; padding: 1px 4px; border-radius: 3px; max-width: 88px; overflow: hidden; text-overflow: ellipsis; } .csl-icon-btn { border: 1px solid #c2c9d1; background: #fff; border-radius: 3px; padding: 1px 5px; cursor: pointer; font-size: 10px; line-height: 1.3; color: #495057; } .csl-icon-btn:hover:not(:disabled) { background: #f0f2f5; } .csl-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; } .csl-shape-row { display: flex; align-items: center; gap: 4px; padding: 3px 6px 3px 10px; border-top: 1px solid #eef0f2; cursor: pointer; outline: none; } .csl-shape-row:focus-visible { box-shadow: inset 0 0 0 2px #00A1F1; } .csl-shape-row.csl-sel { background: rgba(0, 188, 212, 0.12); } .csl-shape-lab { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; } .csl-link-pill { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: #dee2e6; flex-shrink: 0; } .csl-places-section { margin-top: 8px; padding-top: 6px; border-top: 2px solid #cfd8dc; } .csl-places-h { font-size: 10px; font-weight: bold; color: #495057; margin-bottom: 4px; } .csl-footer { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 8px; border-top: 1px solid #e0e4e8; background: #f8f9fa; border-radius: 0 0 5px 5px; } .csl-footer .csl-icon-btn { font-size: 10px; } .csl-move-select { font-size: 10px; max-width: 92px; padding: 1px; border-radius: 3px; border: 1px solid #c2c9d1; } .csl-overflow-backdrop { position: fixed; inset: 0; z-index: 9999997; background: transparent; display: none; } .csl-overflow-backdrop.csl-show { display: block; } .csl-overflow-menu { position: absolute; z-index: 9999999; background: #fff; border: 1px solid #c2c9d1; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); min-width: 180px; display: none; padding: 4px 0; } .csl-overflow-menu.csl-show { display: block; } .csl-overflow-item { padding: 6px 12px; cursor: pointer; font-size: 11px; } .csl-overflow-item:hover { background: #f0f2f5; } .wpo-active-layer-readout { font-size: 10px; color: #495057; padding: 2px 0 4px; border-bottom: 1px solid #e8eaed; } `; document.head.appendChild(style); } function createRefactoredUI() { if(document.getElementById('wme-paint-palette')) return; injectStyles(); const palette = document.createElement('div'); palette.id = 'wme-paint-palette'; palette.innerHTML = ` <div id="wpo-header" class="wpo-header"> <div><i class="fa fa-paint-brush"></i> WME Candy Shop</div> <div> <button id="wpo-toggle-cs-history" type="button" class="wpo-hdr-btn" title="Toggle CS History (undo timeline)"><i class="fa fa-clock-o"></i></button> <button id="wpo-toggle-cs-layers" type="button" class="wpo-hdr-btn" title="Toggle CS Layers panel (layers, shapes, Places)"><i class="fa fa-list"></i></button> <button id="wpo-toggle-tracker" class="wpo-hdr-btn" title="Toggle Tracker"><i class="fa fa-bug"></i></button> <button id="wpo-toggle-log" class="wpo-hdr-btn" title="Toggle Console"><i class="fa fa-terminal"></i></button> </div> </div> <div class="wpo-body"> <div class="wpo-row wpo-target-row"> <div style="display:flex; gap: 4px; align-items:center;"> <button id="wpo-btn-undo" type="button" class="wpo-btn wpo-icon-only" disabled title="Undo"><i class="fa fa-undo"></i></button> <button id="wpo-btn-redo" type="button" class="wpo-btn wpo-icon-only" disabled title="Redo"><i class="fa fa-repeat"></i></button> <button id="wpo-btn-ingest" class="wpo-btn wpo-btn-action" title="Ingest selected WME Venue"><i class="fa fa-download"></i> Ingest</button> <button id="wpo-btn-apply" class="wpo-btn wpo-btn-action wpo-btn-success" title="Apply shapes to WME"><i class="fa fa-check"></i> Apply</button> <button id="wpo-btn-link-hover" type="button" class="wpo-btn wpo-icon-only" title="Link hovered shape to selected place, or use Link tool: select place first (remembered), then hover + click here"><i class="fa fa-link"></i></button> <button id="wpo-btn-unlink-hover" type="button" class="wpo-btn wpo-icon-only" title="Unlink hovered shape"><i class="fa fa-chain-broken"></i></button> <button id="wpo-btn-activate-link-hover" type="button" class="wpo-btn wpo-icon-only" title="Activate link: hovered shape becomes the active link for its venue (others inactive)"><i class="fa fa-star"></i></button> </div> <div id="wpo-target-text" style="font-size: 11px; color: #6c757d;">Target: <em>None</em></div> </div> <div class="wpo-meta-row"> <span id="wpo-mode-chip" title="How the next committed draft combines with the master layer"></span> <div style="display:flex; flex-wrap:wrap; gap:10px; align-items:center; justify-content:flex-end;"> <label class="wpo-auto-ingest-label" title="When the active Candy layer has no shapes, ingest the selected polygon place automatically"> <input type="checkbox" id="wpo-auto-ingest" /> Auto-ingest </label> <label class="wpo-auto-ingest-label" title="Ctrl+Z / Ctrl+Y; V/P/M/L tools; Space hold=pan (restore on release). Off while typing in fields. May conflict with WME when on."> <input type="checkbox" id="wpo-experimental-undo-keys" /> Hotkeys </label> </div> </div> <div class="wpo-active-layer-readout" id="wpo-active-layer-readout" title="WME map layers are separate from Candy Shop user layers. Open CS Layers to manage them."> Active layer: <strong id="wpo-active-layer-name">—</strong> </div> <div class="wpo-row wpo-tool-row"> <div class="wpo-tool-group"> <button class="wpo-btn wpo-btn-tool active" data-tool="pan" title="Pan Map"><i class="fa fa-hand-paper-o"></i></button> </div> <div class="wpo-tool-group"> <button class="wpo-btn wpo-btn-tool" data-tool="measure" title="Measure Geodesic Distance"><i class="fa fa-arrows-h"></i></button> <button class="wpo-btn wpo-btn-tool" data-tool="move" title="Move Master Layer"><i class="fa fa-arrows"></i></button> <button class="wpo-btn wpo-btn-tool" data-tool="revert" title="Shape Shifter (Pop to Draft)"><i class="fa fa-history"></i></button> <button class="wpo-btn wpo-btn-tool" data-tool="link" title="Link hovered part to selected place (Shift+click: unlink)"><i class="fa fa-link"></i></button> </div> <div class="wpo-tool-group"> <button class="wpo-btn wpo-btn-tool" data-tool="rectangle" title="Rectangle"><i class="fa fa-square-o"></i></button> <button class="wpo-btn wpo-btn-tool" data-tool="ellipse" title="Ellipse"><i class="fa fa-circle-o"></i></button> <button class="wpo-btn wpo-btn-tool" data-tool="lasso" title="Lasso"><i class="fa fa-pencil"></i></button> <button class="wpo-btn wpo-btn-tool" data-tool="polygon" title="Polygon"><i class="fa fa-star-o"></i></button> </div> <div class="wpo-tool-group"> <button class="wpo-btn wpo-btn-tool" data-tool="brush" title="Paintbrush"><i class="fa fa-paint-brush"></i></button> <button class="wpo-btn wpo-btn-tool" data-tool="eraser" title="Eraser"><i class="fa fa-eraser"></i></button> <button class="wpo-btn wpo-btn-tool" data-tool="wand" title="Magic Wand"><i class="fa fa-magic"></i></button> </div> </div> <div id="wpo-options-row" class="wpo-row wpo-options-row"></div> <div class="wpo-row wpo-actions-row"> <button id="wpo-btn-clear" class="wpo-btn wpo-btn-action" title="Wipe entire canvas"><i class="fa fa-trash"></i> Clear</button> </div> <div id="wme-paint-log"></div> </div> `; document.body.appendChild(palette); const header = document.getElementById('wpo-header'); let isDragging = false, currentX, currentY, initialX, initialY, xOffset = 0, yOffset = 0; header.addEventListener("mousedown", e => { if(e.target === header || e.target.parentNode === header) { initialX = e.clientX - xOffset; initialY = e.clientY - yOffset; isDragging = true; } }); document.addEventListener("mouseup", () => { initialX = currentX; initialY = currentY; isDragging = false; }); document.addEventListener("mousemove", e => { if (isDragging) { e.preventDefault(); currentX = e.clientX - initialX; currentY = e.clientY - initialY; xOffset = currentX; yOffset = currentY; palette.style.transform = `translate(${currentX}px, ${currentY}px)`; } }); document.getElementById('wpo-toggle-tracker').onclick = () => { const trk = document.getElementById('wme-paint-hud'); if(trk) trk.style.display = (trk.style.display === 'none') ? 'block' : 'none'; }; document.getElementById('wpo-toggle-log').onclick = () => { const lg = document.getElementById('wme-paint-log'); if(lg) lg.style.display = (lg.style.display === 'none') ? 'block' : 'none'; }; document.getElementById('wpo-btn-undo').onclick = () => undo(); document.getElementById('wpo-btn-redo').onclick = () => redo(); document.getElementById('wpo-btn-ingest').onclick = ingestWmeSelection; document.getElementById('wpo-btn-apply').onclick = injectToWaze; document.getElementById('wpo-btn-clear').onclick = clearCanvasState; document.getElementById('wpo-btn-link-hover').onclick = () => tryLinkHoveredToSelection(); document.getElementById('wpo-btn-activate-link-hover').onclick = () => tryActivateHoveredShapeLink(); document.getElementById('wpo-btn-unlink-hover').onclick = () => tryUnlinkHovered(); const autoIngestCb = document.getElementById('wpo-auto-ingest'); if (autoIngestCb) { autoIngestCb.checked = getAutoIngest(); autoIngestCb.onchange = () => setAutoIngest(autoIngestCb.checked); } const expUndoCb = document.getElementById('wpo-experimental-undo-keys'); if (expUndoCb) { expUndoCb.checked = getExperimentalUndoKeys(); expUndoCb.onchange = () => setExperimentalUndoKeys(expUndoCb.checked); } const tHist = document.getElementById('wpo-toggle-cs-history'); if (tHist) tHist.onclick = () => toggleCsHistoryPanel(); const tCs = document.getElementById('wpo-toggle-cs-layers'); if (tCs) tCs.onclick = () => toggleCsLayersPanel(); ensureCsHistoryPanel(); ensureCsLayersPanel(); rebuildLayerSelectDom(); refreshPlacesPreviewFromMap(); rebuildPlacesListDom(); document.querySelectorAll('.wpo-btn-tool').forEach(btn => { btn.addEventListener('click', (e) => changeTool(e.currentTarget.getAttribute('data-tool'))); }); renderOptionsRow(); updateUndoRedoButtons(); scheduleCsHistoryRefresh(); } function renderOptionsRow() { const container = document.getElementById('wpo-options-row'); let optionsHtml = ''; const shapeTools = ['rectangle', 'ellipse', 'polygon', 'lasso', 'move', 'revert']; const draftCapableTools = ['revert', 'rectangle', 'ellipse', 'lasso', 'polygon', 'wand']; if (appState.tool === 'pan') { optionsHtml = `<span style="font-size: 10px; color:#6c757d; text-align:right; flex:1;">Tip: Ingest a place, edit with Shape Shifter or draw tools, then Apply. Pan mode active.</span>`; } else if (appState.tool === 'measure') { optionsHtml = `<span style="font-size: 10px; color:#6c757d; text-align:right; flex:1;">Tip: Ingest → edit → Apply. Click and drag to measure distance.</span>`; } else if (appState.tool === 'move') { optionsHtml = `<span style="font-size: 10px; color:#6c757d; text-align:right; flex:1;">Click and drag to move the master layer.</span>`; } else if (appState.tool === 'revert' && !appState.isDraftActive) { optionsHtml = `<span style="font-size: 10px; color:#6c757d; text-align:right; flex:1;">Click a shape to pop it into Draft mode.</span>`; } else if (appState.tool === 'link' && !appState.isDraftActive) { optionsHtml = `<span style="font-size: 10px; color:#6c757d; text-align:right; flex:1;">Hover a part, then click to link the selected WME place. Shift+click or Alt+click to unlink.</span>`; } else if (shapeTools.includes(appState.tool)) { optionsHtml = ` <div style="display:flex; gap: 1px; margin-left: auto;"> <button class="wpo-btn wpo-btn-mode ${appState.mode==='replace'?'active':''}" data-val="replace" title="Replace"><i class="fa fa-file-o"></i></button> <button class="wpo-btn wpo-btn-mode ${appState.mode==='union'?'active':''}" data-val="union" title="Add / Union"><i class="fa fa-plus"></i></button> <button class="wpo-btn wpo-btn-mode ${appState.mode==='difference'?'active':''}" data-val="difference" title="Subtract"><i class="fa fa-minus"></i></button> <button class="wpo-btn wpo-btn-mode ${appState.mode==='intersection'?'active':''}" data-val="intersection" title="Intersect"><i class="fa fa-pie-chart"></i></button> <button class="wpo-btn wpo-btn-mode ${appState.mode==='xor'?'active':''}" data-val="xor" title="XOR"><i class="fa fa-exchange"></i></button> </div> `; } else if (appState.tool === 'brush' || appState.tool === 'eraser') { const sagVal = appState.brushCircleSagittaM; const sidesD = brushCircleSidesRawAndClamped(appState.brushSize); const vertsLabel = brushVertexReadoutText(sidesD); const fhActive = appState.brushStyle === 'freehand'; const lnActive = appState.brushStyle === 'line'; const prof = appState.brushProfile; const mapA = appState.brushAlign === 'map'; const strokeA = appState.brushAlign === 'stroke'; const showAlign = brushProfileUsesAlignControl(); const showCorner = prof === 'rounded_square'; const showCurve = brushProfileShowsCurveSlider(); const corVal = appState.brushCornerRatio; const pCircle = prof === 'circle' ? 'active' : ''; const pSquare = prof === 'square' ? 'active' : ''; const pDiamond = prof === 'diamond' ? 'active' : ''; const pHex = prof === 'hexagon' ? 'active' : ''; const pRound = prof === 'rounded_square' ? 'active' : ''; optionsHtml = ` <div class="wpo-brush-options"> <div role="group" aria-label="Brush stroke shape" style="display:flex; gap:2px; flex-shrink:0;"> <button type="button" class="wpo-btn wpo-brush-style-btn ${fhActive ? 'active' : ''}" id="wpo-brush-style-freehand" title="Freehand: drag to paint along the pointer path" aria-pressed="${fhActive ? 'true' : 'false'}"><i class="fa fa-pencil" aria-hidden="true"></i><span class="sr-only"> Freehand</span></button> <button type="button" class="wpo-btn wpo-brush-style-btn ${lnActive ? 'active' : ''}" id="wpo-brush-style-line" title="Straight line: click and drag one segment" aria-pressed="${lnActive ? 'true' : 'false'}"><i class="fa fa-minus" aria-hidden="true"></i><span class="sr-only"> Straight line</span></button> </div> <div role="group" aria-label="Brush footprint shape" style="display:flex; gap:1px; flex-shrink:0;"> <button type="button" class="wpo-btn wpo-brush-profile-btn ${pCircle}" data-profile="circle" title="Circle: round stamp; JSTS buffer along stroke" aria-pressed="${prof === 'circle' ? 'true' : 'false'}"><i class="fa fa-circle-o"></i></button> <button type="button" class="wpo-btn wpo-brush-profile-btn ${pSquare}" data-profile="square" title="Square: flat sides, map north/east or along stroke" aria-pressed="${prof === 'square' ? 'true' : 'false'}"><i class="fa fa-square-o"></i></button> <button type="button" class="wpo-btn wpo-brush-profile-btn ${pDiamond}" data-profile="diamond" title="Diamond (square on point)" aria-pressed="${prof === 'diamond' ? 'true' : 'false'}"><i class="fa fa-certificate"></i></button> <button type="button" class="wpo-btn wpo-brush-profile-btn ${pHex}" data-profile="hexagon" title="Regular hexagon (apothem = R)" aria-pressed="${prof === 'hexagon' ? 'true' : 'false'}"><span style="font-size:11px;line-height:1;">⬡</span></button> <button type="button" class="wpo-btn wpo-brush-profile-btn ${pRound}" data-profile="rounded_square" title="Rounded square (local flat-earth corners; R = half-width)" aria-pressed="${prof === 'rounded_square' ? 'true' : 'false'}"><i class="fa fa-square"></i></button> </div> <div id="wpo-brush-align-wrap" role="group" aria-label="Square alignment" style="display:${showAlign ? 'flex' : 'none'}; gap:1px; flex-shrink:0;"> <button type="button" class="wpo-btn wpo-brush-profile-btn ${mapA ? 'active' : ''}" id="wpo-brush-align-map" title="Map: square edges along north / east" aria-pressed="${mapA ? 'true' : 'false'}"><i class="fa fa-compass"></i></button> <button type="button" class="wpo-btn wpo-brush-profile-btn ${strokeA ? 'active' : ''}" id="wpo-brush-align-stroke" title="Along stroke: rotate with path direction" aria-pressed="${strokeA ? 'true' : 'false'}"><i class="fa fa-long-arrow-right"></i></button> </div> <div class="wpo-brush-sliders"> <div id="wpo-brush-corner-wrap" class="wpo-slider-container" style="display:${showCorner ? 'flex' : 'none'};" title="Corner radius as fraction of R (rounded square). Saved as ${LS_BRUSH_CORNER_RATIO}."> <span title="Corner">Corner</span> <input type="range" class="wpo-slider wpo-slider--brush" id="wpo-brush-corner" min="${BRUSH_CORNER_RATIO_MIN}" max="${BRUSH_CORNER_RATIO_MAX}" step="0.01" value="${corVal}"> <span id="wpo-brush-corner-val" style="width:36px; text-align:right; flex-shrink:0;">${Number(corVal).toFixed(2)}</span> </div> <div class="wpo-slider-container" title="Geodesic brush size R in meters: circle radius; square/hex apothem (center to flat edge). Saved as ${LS_BRUSH_SIZE_M}."> <span title="Brush size">R</span> <input type="range" class="wpo-slider wpo-slider--brush" id="wpo-brush-size" min="${BRUSH_METERS_MIN}" max="${BRUSH_METERS_MAX}" step="1" value="${appState.brushSize}"> <span id="wpo-brush-size-val" style="width:40px; text-align:right; flex-shrink:0;">${appState.brushSize} m</span> </div> <div id="wpo-brush-curve-wrap" class="wpo-slider-container" style="display:${showCurve ? 'flex' : 'none'};" title="Curve δ (m): sagitta for circles and rounded-square corner arcs. Saved as ${LS_BRUSH_CIRCLE_SAGITTA_M}."> <span title="Curve">δ</span> <input type="range" class="wpo-slider wpo-slider--brush" id="wpo-brush-sagitta" min="${BRUSH_CIRCLE_SAGITTA_MIN_M}" max="${BRUSH_CIRCLE_SAGITTA_MAX_M}" step="0.05" value="${sagVal}"> <span id="wpo-brush-sagitta-val" style="width:44px; text-align:right; flex-shrink:0;">${Number(sagVal).toFixed(2)} m</span> </div> <div class="wpo-brush-verts" id="wpo-brush-verts">${vertsLabel}</div> </div> </div> `; } else if (appState.tool === 'wand') { optionsHtml = ` <div class="wpo-slider-container" style="margin-left:auto;" title="Edge capture: max distance in screen pixels to polygon outline when not inside a fill (0 = inside only; 100 ≈ 40 px)."> <span>Tol:</span> <input type="range" class="wpo-slider" id="wpo-wand-tol" min="0" max="100" value="${appState.wandTolerance}"> <span id="wpo-wand-tol-val" style="width:18px; text-align:right;">${appState.wandTolerance}</span> </div> `; } if (draftCapableTools.includes(appState.tool)) { let disabledAttr = appState.isDraftActive ? '' : 'disabled'; let draftButtons = ` <div style="display:flex; gap: 6px; border-left: 1px solid #c2c9d1; padding-left: 8px; margin-left: auto;"> <button class="wpo-icon-only wpo-icon-success" id="wpo-opt-finish" title="Commit Draft (Enter)" ${disabledAttr}><i class="fa fa-check"></i></button> <button class="wpo-icon-only wpo-icon-danger" id="wpo-opt-cancel" title="Discard Draft (Esc)" ${disabledAttr}><i class="fa fa-times"></i></button> </div> `; container.innerHTML = `<div style="display:flex; align-items:center; width:100%; gap:6px; flex-wrap:wrap;">` + optionsHtml + draftButtons + `</div>`; document.getElementById('wpo-opt-finish').onclick = () => { if (appState.isDraftActive) { commitDraft(); requestRender(); updateTrackerHUD(); } }; document.getElementById('wpo-opt-cancel').onclick = () => { if (appState.isDraftActive || revertOriginalPolygon || isDrawingShape || tempVertices.length > 0) { cancelDraft(); requestRender(); updateTrackerHUD(); } }; } else { container.innerHTML = `<div style="display:flex; align-items:center; width:100%; gap:6px; flex-wrap:wrap;">${optionsHtml}</div>`; } container.querySelectorAll('.wpo-btn-mode').forEach(btn => { btn.onclick = (e) => { const v = e.currentTarget.getAttribute('data-val'); changeMode(v, { persistCommitMode: true }); }; }); if (document.getElementById('wpo-brush-size')) { const bs = document.getElementById('wpo-brush-size'); const bsv = document.getElementById('wpo-brush-size-val'); const syncBrush = () => { const v = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(bs.value) || BRUSH_METERS_MIN)); appState.brushSize = v; writePersistedBrushSizeM(v); bs.value = String(v); bsv.innerText = v + ' m'; updateModeChip(); updateBrushVertexReadout(); requestRender(); }; bs.oninput = syncBrush; bs.onchange = syncBrush; const syncBrushStyleButtons = () => { const fh = document.getElementById('wpo-brush-style-freehand'); const ln = document.getElementById('wpo-brush-style-line'); if (fh && ln) { const isFh = appState.brushStyle === 'freehand'; fh.classList.toggle('active', isFh); ln.classList.toggle('active', !isFh); fh.setAttribute('aria-pressed', isFh ? 'true' : 'false'); ln.setAttribute('aria-pressed', !isFh ? 'true' : 'false'); } }; const setBrushStyle = (style) => { if (style !== 'freehand' && style !== 'line') return; appState.brushStyle = style; writePersistedBrushStyle(style); syncBrushStyleButtons(); }; const fhBtn = document.getElementById('wpo-brush-style-freehand'); const lnBtn = document.getElementById('wpo-brush-style-line'); if (fhBtn) { fhBtn.onclick = () => setBrushStyle('freehand'); fhBtn.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setBrushStyle('freehand'); } }; } if (lnBtn) { lnBtn.onclick = () => setBrushStyle('line'); lnBtn.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setBrushStyle('line'); } }; } syncBrushStyleButtons(); updateBrushVertexReadout(); document.querySelectorAll('.wpo-brush-profile-btn[data-profile]').forEach(btn => { btn.onclick = () => { const id = btn.getAttribute('data-profile'); if (!BRUSH_PROFILE_IDS.includes(id)) return; appState.brushProfile = id; writePersistedBrushProfile(id); renderOptionsRow(); updateModeChip(); requestRender(); }; btn.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); } }; }); const mapAlignBtn = document.getElementById('wpo-brush-align-map'); const strokeAlignBtn = document.getElementById('wpo-brush-align-stroke'); if (mapAlignBtn) { mapAlignBtn.onclick = () => { appState.brushAlign = 'map'; writePersistedBrushAlign('map'); renderOptionsRow(); updateModeChip(); requestRender(); }; } if (strokeAlignBtn) { strokeAlignBtn.onclick = () => { appState.brushAlign = 'stroke'; writePersistedBrushAlign('stroke'); renderOptionsRow(); updateModeChip(); requestRender(); }; } const cr = document.getElementById('wpo-brush-corner'); const crv = document.getElementById('wpo-brush-corner-val'); if (cr && crv) { const syncCorner = () => { let v = Number(cr.value); if (!Number.isFinite(v)) v = BRUSH_CORNER_RATIO_DEFAULT; v = Math.max(BRUSH_CORNER_RATIO_MIN, Math.min(BRUSH_CORNER_RATIO_MAX, Math.round(v * 1000) / 1000)); appState.brushCornerRatio = v; writePersistedBrushCornerRatio(v); cr.value = String(v); crv.innerText = v.toFixed(2); updateBrushVertexReadout(); requestRender(); }; cr.oninput = syncCorner; cr.onchange = syncCorner; } } if (document.getElementById('wpo-brush-sagitta')) { const sg = document.getElementById('wpo-brush-sagitta'); const sgv = document.getElementById('wpo-brush-sagitta-val'); const syncSagitta = () => { let v = Number(sg.value); if (!Number.isFinite(v)) v = BRUSH_CIRCLE_SAGITTA_DEFAULT_M; v = Math.round(v * 20) / 20; v = Math.max(BRUSH_CIRCLE_SAGITTA_MIN_M, Math.min(BRUSH_CIRCLE_SAGITTA_MAX_M, v)); appState.brushCircleSagittaM = v; writePersistedBrushCircleSagittaM(v); sg.value = String(v); sgv.innerText = v.toFixed(2) + ' m'; updateBrushVertexReadout(); requestRender(); }; sg.oninput = syncSagitta; sg.onchange = syncSagitta; } if (document.getElementById('wpo-wand-tol')) { document.getElementById('wpo-wand-tol').oninput = (e) => { appState.wandTolerance = e.target.value; document.getElementById('wpo-wand-tol-val').innerText = appState.wandTolerance; }; } } function createTrackerHUD() { if(document.getElementById('wme-paint-hud')) return; const hud = document.createElement('div'); hud.id = 'wme-paint-hud'; hud.style.cssText = 'position:fixed; bottom:30px; right:350px; background:rgba(0,0,0,0.85); color:#00A1F1; padding:8px; font-family:monospace; font-size:11px; z-index:9999999; border-radius:4px; border:1px solid #00A1F1; min-width:210px; pointer-events:auto; display:block;'; hud.innerHTML = ` <div id="wme-paint-hud-header" style="font-weight:bold; margin-bottom:4px; color:#fff; font-size:11px; cursor:move; padding-bottom:3px; border-bottom:1px solid #555; display:flex; align-items:center;"> <i class="fa fa-crosshairs" style="margin-right:4px;"></i> Geo-Math Tracker <div style="margin-left:auto; display:flex; align-items:center; gap:6px;"> <span style="font-size:9px; color:#888; font-weight:normal;">FPS: <span id="hud-fps-val" style="color:#26CC9A; font-weight:bold;">0</span></span> <span class="wpo-blinking-dot"></span> </div> </div> <div style="color: #26CC9A; font-weight:bold; margin-top:4px;">-- LIVE DATA --</div> <div>Px: [<span id="hud-px" style="color:#fff;">0, 0</span>]</div> <div>Geo: [<span id="hud-gps" style="color:#fff;">0.00000, 0.00000</span>]</div> <div style="margin-top:2px;">Layer: <span id="hud-layer" style="color:#FFC000;">None</span></div> <div style="margin-top: 6px; border-top: 1px solid #00A1F1; padding-top: 4px;"> <span style="color:#26CC9A; font-weight:bold;">-- GEOMETRY --</span> <div id="hud-geo-math" style="color: #FFC000; margin-top: 2px;"><em>No shape active</em></div> </div> `; document.body.appendChild(hud); const hudHeader = document.getElementById('wme-paint-hud-header'); let isDraggingHud = false, curHudX, curHudY, initHudX, initHudY, xHudOffset = 0, yHudOffset = 0; hudHeader.addEventListener("mousedown", e => { initHudX = e.clientX - xHudOffset; initHudY = e.clientY - yHudOffset; isDraggingHud = true; }); document.addEventListener("mouseup", () => { initHudX = curHudX; initHudY = curHudY; isDraggingHud = false; }); document.addEventListener("mousemove", e => { if (isDraggingHud) { e.preventDefault(); curHudX = e.clientX - initHudX; curHudY = e.clientY - initHudY; xHudOffset = curHudX; yHudOffset = curHudY; hud.style.transform = `translate(${curHudX}px, ${curHudY}px)`; } }); } // --- 4. CANVAS INJECTION & EVENTS --- function injectCanvasOverlay() { if (document.getElementById('wme-paint-overlay')) return; const canvasContainer = document.createElement('div'); canvasContainer.id = 'wme-paint-overlay'; canvasContainer.style.cssText = 'position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:' + CANVAS_OVERLAY_Z_INDEX + '; pointer-events:none;'; canvasElement = document.createElement('canvas'); canvasElement.id = 'native-paint-canvas'; canvasElement.style.cssText = 'width:100%; height:100%; pointer-events:none;'; canvasContainer.appendChild(canvasElement); document.body.appendChild(canvasContainer); ctx = canvasElement.getContext('2d'); syncCanvasSize(); setupDrawingEvents(); window.addEventListener('resize', syncCanvasSize); } function syncCanvasSize() { if (!canvasElement) return; canvasElement.width = window.innerWidth; canvasElement.height = window.innerHeight; requestRender(); } function updateCanvasOnMapChange() { refreshPlacesPreviewFromMap(); rebuildPlacesListDom(); requestRender(); updateTrackerHUD(); } function requestRender() { if (!renderFrameRequested) { renderFrameRequested = true; requestAnimationFrame(() => { renderCanvas(); renderFrameRequested = false; }); } } function scheduleLiveMouseHudUpdate() { const hud = document.getElementById('wme-paint-hud'); if (!hud || hud.style.display === 'none') return; if (liveMouseHudFrameRequested) return; liveMouseHudFrameRequested = true; requestAnimationFrame(() => { liveMouseHudFrameRequested = false; updateLiveMouseHUD(currentMousePixel); }); } function setupDrawingEvents() { // If mouseup happens outside the overlay canvas, the stroke/draw can get "stuck" // (canvas keeps capturing events and WME feels hung). Finalize globally. let lastGlobalPointerUpAt = 0; let lastGlobalBrushMoveAt = 0; const onGlobalBrushMove = (e) => { if (!brushPainting) return; if (appState.isDraftActive) return; if (!(appState.tool === 'brush' || appState.tool === 'eraser')) return; if (!e) return; const now = Date.now(); // Limit cross-document sampling overhead; canvas mousemove still renders preview. if (now - lastGlobalBrushMoveAt < 12) return; lastGlobalBrushMoveAt = now; const x = e.clientX; const y = e.clientY; if (x == null || y == null) return; currentMousePixel = { x, y }; scheduleLiveMouseHudUpdate(); const g = globalPixelToGps(x, y); if (!g) return; const radiusM = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || 12)); if (appState.brushStyle === 'line') { if (brushStrokeSamples.length < 2) brushStrokeSamples.push([g.lon, g.lat]); else brushStrokeSamples[1] = [g.lon, g.lat]; } else { appendBrushSamplesAlongGeodesic(brushStrokeSamples, g.lon, g.lat, radiusM); } }; const onGlobalPointerUp = (e) => { if (appState.tool === 'pan' && !appState.isDraftActive && draftAction === 'none' && draggingVertexIndex === -1) return; const now = Date.now(); if (now - lastGlobalPointerUpAt < 40) return; lastGlobalPointerUpAt = now; // Avoid double-finalizing when the canvas handler will run. if (canvasElement && (e?.target === canvasElement)) return; if ((appState.tool === 'brush' || appState.tool === 'eraser') && brushPainting) { endBrushPainting(); return; } if (draftAction !== 'none') { draftAction = 'none'; draggingDraftVertexIndex = -1; dragStartDraftRingsPx = null; requestRender(); updateTrackerHUD(); return; } if (draggingVertexIndex !== -1) { draggingVertexIndex = -1; requestRender(); updateTrackerHUD(); return; } if (appState.tool === 'move') { isDrawingShape = false; dragStartMasterPixels = null; requestRender(); updateTrackerHUD(); return; } if (appState.tool === 'measure') { isDrawingShape = false; requestRender(); updateTrackerHUD(); return; } if (isDrawingShape && appState.tool !== 'polygon' && appState.tool !== 'revert') { isDrawingShape = false; if (tempVertices.length > 2) { draftInteriorRings = []; draftVertices = [...tempVertices]; setDraftActive(true); logToUI("Draft ready."); } tempVertices = []; requestRender(); updateTrackerHUD(); } }; document.addEventListener('mouseup', onGlobalPointerUp, true); document.addEventListener('pointerup', onGlobalPointerUp, true); document.addEventListener('mousemove', onGlobalBrushMove, true); document.addEventListener('pointermove', onGlobalBrushMove, true); window.addEventListener('blur', () => { if (brushPainting) endBrushPainting(); if (spacePanActive) { spacePanActive = false; changeTool(spacePanRestoreTool, { quiet: true }); } }, true); document.addEventListener('mousedown', function(e) { if (appState.tool !== 'pan') return; if (e.button != null && e.button !== 0) return; if (isCandyPaintUiRoot(e.target)) return; if (isTextEditingTarget(e.target)) return; if (tryPopShapeToDraftAt(e.clientX, e.clientY, e)) { e.preventDefault(); e.stopPropagation(); } }, true); canvasElement.addEventListener('wheel', function(e) { if (appState.tool === 'pan') return; e.preventDefault(); canvasElement.style.pointerEvents = 'none'; let target = document.elementFromPoint(e.clientX, e.clientY); let wheelEvent = new WheelEvent('wheel', { clientX: e.clientX, clientY: e.clientY, deltaY: e.deltaY, deltaX: e.deltaX, deltaMode: e.deltaMode, bubbles: true, cancelable: true }); if (target) target.dispatchEvent(wheelEvent); canvasElement.style.pointerEvents = 'auto'; }, { passive: false }); // Pointer events + capture: fixes "first stroke is single stamp" and ensures we never miss drags. // Prevents mouse-compat events from duplicating strokes. canvasElement.style.touchAction = 'none'; let pointerCaptureId = null; const pointerToGps = (e) => globalPixelToGps(e.clientX, e.clientY); canvasElement.addEventListener('pointerdown', function(e) { if (appState.tool === 'pan') return; if (e.button != null && e.button !== 0) return; const gps = pointerToGps(e); if (!gps) return; if ((appState.tool === 'brush' || appState.tool === 'eraser') && !appState.isDraftActive) { if (!isGeometryLibraryReady()) { logToUI('Brush/eraser need jsts.', true); return; } if (appState.tool === 'eraser' && masterPolygons.length === 0) return; brushPainting = true; brushIsEraser = appState.tool === 'eraser'; brushStrokeSamples = [[gps.lon, gps.lat]]; if (appState.brushStyle === 'line') brushStrokeSamples.push([gps.lon, gps.lat]); try { pointerCaptureId = e.pointerId; canvasElement.setPointerCapture(pointerCaptureId); } catch (err) { /* ignore */ } e.preventDefault(); e.stopPropagation(); requestRender(); updateTrackerHUD(); return; } }, true); canvasElement.addEventListener('pointermove', function(e) { currentMousePixel = { x: e.clientX, y: e.clientY }; scheduleLiveMouseHudUpdate(); if (appState.tool === 'pan') return; if ((appState.tool === 'brush' || appState.tool === 'eraser') && brushPainting && !appState.isDraftActive) { const g = pointerToGps(e); if (g) { const radiusM = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || 12)); if (appState.brushStyle === 'line') { if (brushStrokeSamples.length < 2) brushStrokeSamples.push([g.lon, g.lat]); else brushStrokeSamples[1] = [g.lon, g.lat]; } else { appendBrushSamplesAlongGeodesic(brushStrokeSamples, g.lon, g.lat, radiusM); } } e.preventDefault(); e.stopPropagation(); requestRender(); updateTrackerHUD(); return; } }, true); canvasElement.addEventListener('pointerup', function(e) { if (appState.tool === 'pan') return; if ((appState.tool === 'brush' || appState.tool === 'eraser') && brushPainting) { endBrushPainting(); try { if (pointerCaptureId != null) canvasElement.releasePointerCapture(pointerCaptureId); } catch (err) { /* ignore */ } pointerCaptureId = null; e.preventDefault(); e.stopPropagation(); return; } }, true); canvasElement.addEventListener('mousedown', function(e) { // If pointer events are in play, avoid duplicate strokes from mouse-compat events. if (pointerCaptureId != null) return; if (appState.tool === 'pan' && tryPopShapeToDraftAt(e.clientX, e.clientY, e)) return; const gps = globalPixelToGps(e.clientX, e.clientY); if (!gps) return; if (appState.tool === 'pan' && !appState.isDraftActive) return; // Handle Active Draft Manipulations if (appState.isDraftActive) { let hit = getDraftHitRegion(e.clientX, e.clientY); if (hit) { draftAction = hit; if (hit === 'vertex') { draggingDraftVertexIndex = hoveredDraftVertexIndex; } else if (hit === 'midpoint') { const insertIdx = hoveredDraftMidpointIndex + 1; draftVertices.splice(insertIdx, 0, gps); draggingDraftVertexIndex = insertIdx; draftAction = 'vertex'; } dragStartMouse = { x: e.clientX, y: e.clientY }; dragStartGlobalCoords = draftVertices.map(v => gpsToGlobalPixel(v.lon, v.lat)); dragStartDraftRingsPx = (hit === 'vertex' || hit === 'midpoint') ? null : captureDraftRingsPixelCoords(); dragStartBBox = getDraftBBox(); return; } else if (appState.tool !== 'revert' && appState.tool !== 'link') { // Commit only: do not fall through — otherwise tempVertices/isDrawingShape // stay set and replace-mode rendering hides the master layer (near-transparent). commitDraft(); requestRender(); updateTrackerHUD(); return; } else { return; } } // Move Tool Logic if (appState.tool === 'move') { if (masterPolygons.length > 0) { commitBeforeChange('move'); isDrawingShape = true; dragStartMouse = { x: e.clientX, y: e.clientY }; dragStartMasterPixels = masterPolygons.map(poly => poly.map(ring => ring.map(pt => gpsToGlobalPixel(pt[0], pt[1])) ) ); } } // Shape Shifter (Revert) Logic else if (appState.tool === 'revert' && !appState.isDraftActive) { if (hoveredMasterPolyIndex !== -1 && masterPolygons[hoveredMasterPolyIndex]) { commitBeforeChange('pop_draft'); alignShapeLinksToMaster(); draftPopInsertMasterIndex = hoveredMasterPolyIndex; revertOriginalLink = shapePlaceLinks.splice(hoveredMasterPolyIndex, 1)[0] ?? null; const poppedPoly = masterPolygons.splice(hoveredMasterPolyIndex, 1)[0]; revertOriginalPolygon = [poppedPoly]; assignDraftFromPoppedMasterPart(poppedPoly); setDraftActive(true); logToUI('Change to Transform Tool.'); } } else if (appState.tool === 'link' && !appState.isDraftActive) { if (hoveredMasterPolyIndex >= 0 && masterPolygons[hoveredMasterPolyIndex]) { if (e.shiftKey || e.altKey) { tryUnlinkHovered(); } else { tryLinkHoveredToSelection(); } } } else if (appState.tool === 'wand' && !appState.isDraftActive) { if (!isGeometryLibraryReady()) { logToUI('Magic wand needs the geometry library (JSTS).', true); return; } const tolPx = wandToleranceToPixels(); const widx = pickMasterPolygonAtWithTolerance(e.clientX, e.clientY, tolPx); if (widx < 0) { logToUI('Magic wand: no shape near the pointer (increase tolerance to snap to edges).', false); return; } const Lw = getActiveUserLayer(); if (!Lw) return; ensureShapeIdsForLayer(Lw); const sidW = Lw.shapeIds[widx]; csLayersSelection.clear(); csLayersSelection.add(csShapeKey(Lw.id, sidW)); popActiveLayerShapeToDraftFromIndex(widx); return; } else if ((appState.tool === 'brush' || appState.tool === 'eraser') && !appState.isDraftActive) { if (!isGeometryLibraryReady()) { logToUI('Brush/eraser need jsts.', true); return; } if (appState.tool === 'eraser' && masterPolygons.length === 0) return; brushPainting = true; brushIsEraser = appState.tool === 'eraser'; brushStrokeSamples = [[gps.lon, gps.lat]]; if (appState.brushStyle === 'line') brushStrokeSamples.push([gps.lon, gps.lat]); requestRender(); updateTrackerHUD(); return; } // Standard Tools else if (appState.tool === 'polygon') { if (hoveredTempVertexIndex !== -1) { draggingVertexIndex = hoveredTempVertexIndex; } else { tempVertices.push(gps); } } else if (appState.tool === 'measure') { tempVertices = [gps, gps]; isDrawingShape = true; } else if (appState.tool !== 'brush' && appState.tool !== 'eraser' && appState.tool !== 'wand') { isDrawingShape = true; tempVertices = [gps]; dragStartMouse = { x: e.clientX, y: e.clientY }; } requestRender(); updateTrackerHUD(); }); canvasElement.addEventListener('mousemove', function(e) { if (pointerCaptureId != null) return; currentMousePixel = { x: e.clientX, y: e.clientY }; scheduleLiveMouseHudUpdate(); if (appState.tool === 'pan' && !appState.isDraftActive) return; if ((appState.tool === 'brush' || appState.tool === 'eraser') && !appState.isDraftActive && draftAction === 'none') { canvasElement.style.cursor = 'crosshair'; } if (draftAction !== 'none') { executeDraftTransform(e.clientX, e.clientY); } else if ((appState.tool === 'brush' || appState.tool === 'eraser') && brushPainting && !appState.isDraftActive) { canvasElement.style.cursor = 'crosshair'; const g = globalPixelToGps(e.clientX, e.clientY); if (g) { const radiusM = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || 12)); if (appState.brushStyle === 'line') { if (brushStrokeSamples.length < 2) brushStrokeSamples.push([g.lon, g.lat]); else brushStrokeSamples[1] = [g.lon, g.lat]; } else { appendBrushSamplesAlongGeodesic(brushStrokeSamples, g.lon, g.lat, radiusM); } } } else if (appState.tool === 'polygon' && !appState.isDraftActive && tempVertices.length > 0) { const g = globalPixelToGps(e.clientX, e.clientY); if (draggingVertexIndex !== -1 && g) { tempVertices[draggingVertexIndex] = g; canvasElement.style.cursor = 'grabbing'; } else { hoveredTempVertexIndex = -1; for (let i = 0; i < tempVertices.length; i++) { const px = gpsToGlobalPixel(tempVertices[i].lon, tempVertices[i].lat); if (px) { const dx = e.clientX - px.x; const dy = e.clientY - px.y; if (dx * dx + dy * dy <= VERTEX_HIT_RADIUS_SQ) { hoveredTempVertexIndex = i; break; } } } canvasElement.style.cursor = hoveredTempVertexIndex !== -1 ? 'grab' : 'crosshair'; } } else if (appState.isDraftActive) { let hit = getDraftHitRegion(e.clientX, e.clientY); canvasElement.style.cursor = getCursorForHit(hit); } else if (appState.tool === 'move' && isDrawingShape && dragStartMasterPixels) { let dx = e.clientX - dragStartMouse.x; let dy = e.clientY - dragStartMouse.y; masterPolygons = dragStartMasterPixels.map(poly => poly.map(ring => ring.map(px => { if(!px) return [0,0]; let newGps = globalPixelToGps(px.x + dx, px.y + dy); return newGps ? [newGps.lon, newGps.lat] : [0,0]; }) ) ); flushBrushGeometryToActiveLayer(); } else if (appState.tool === 'revert' && !appState.isDraftActive) { hoveredMasterPolyIndex = pickMasterPolygonAt(currentMousePixel.x, currentMousePixel.y); canvasElement.style.cursor = hoveredMasterPolyIndex >= 0 ? 'pointer' : 'crosshair'; } else if (appState.tool === 'link' && !appState.isDraftActive) { hoveredMasterPolyIndex = pickMasterPolygonAt(currentMousePixel.x, currentMousePixel.y); canvasElement.style.cursor = hoveredMasterPolyIndex >= 0 ? 'pointer' : 'crosshair'; } else if (isDrawingShape) { const gps = globalPixelToGps(e.clientX, e.clientY); const startGps = globalPixelToGps(dragStartMouse ? dragStartMouse.x : e.clientX, dragStartMouse ? dragStartMouse.y : e.clientY); if (gps && appState.tool === 'measure') { tempVertices[1] = gps; } else if (gps && startGps) { const shiftM = !!(e.shiftKey || isShiftDown); const altM = !!(e.altKey || isAltDown); if (appState.tool === 'rectangle') { tempVertices = rectangleVerticesFromDrag(startGps, gps, { square: shiftM, fromCenter: altM }); } else if (appState.tool === 'ellipse') { tempVertices = ellipseVerticesFromDrag(startGps, gps, { square: shiftM, fromCenter: !altM }); } else if (appState.tool === 'lasso') { let last = tempVertices[tempVertices.length-1]; if (Math.abs(gps.lon - last.lon) > 0.00005 || Math.abs(gps.lat - last.lat) > 0.00005) { tempVertices.push(gps); } } } } requestRender(); if (isDrawingShape || draftAction !== 'none') updateTrackerHUD(); }); canvasElement.addEventListener('mouseup', function(e) { if (appState.tool === 'pan' && !appState.isDraftActive) return; if (pointerCaptureId != null) return; if ((appState.tool === 'brush' || appState.tool === 'eraser') && brushPainting) { endBrushPainting(); updateTrackerHUD(); return; } if (draftAction !== 'none') { draftAction = 'none'; draggingDraftVertexIndex = -1; dragStartDraftRingsPx = null; } else if (draggingVertexIndex !== -1) { draggingVertexIndex = -1; } else if (appState.tool === 'move') { isDrawingShape = false; dragStartMasterPixels = null; } else if (appState.tool === 'measure') { isDrawingShape = false; } else if (isDrawingShape && appState.tool !== 'polygon' && appState.tool !== 'revert') { isDrawingShape = false; if (tempVertices.length > 2) { draftInteriorRings = []; draftVertices = [...tempVertices]; setDraftActive(true); logToUI("Draft ready."); } tempVertices = []; } requestRender(); updateTrackerHUD(); }); canvasElement.addEventListener('dblclick', function(e) { if (appState.tool === 'pan' && !appState.isDraftActive) return; if (appState.tool === 'polygon' && tempVertices.length > 2) { draftInteriorRings = []; draftVertices = [...tempVertices]; setDraftActive(true); tempVertices = []; logToUI("Polygon drafted."); requestRender(); updateTrackerHUD(); } }); window.addEventListener('keydown', function(e) { if (e.key === 'Escape') { cancelDraft(); requestRender(); updateTrackerHUD(); } else if (e.key === 'Enter' && appState.isDraftActive) { const tEnter = e.target; if (tEnter && (tEnter.tagName === 'INPUT' || tEnter.tagName === 'TEXTAREA' || tEnter.tagName === 'SELECT' || tEnter.isContentEditable)) return; e.preventDefault(); e.stopPropagation(); commitDraft(); requestRender(); updateTrackerHUD(); } else if ((e.key === 'd' || e.key === 'D') && appState.isDraftActive && hoveredDraftVertexIndex !== -1) { const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return; e.preventDefault(); if (draftVertices.length > 3) { draftVertices.splice(hoveredDraftVertexIndex, 1); hoveredDraftVertexIndex = -1; hoveredDraftMidpointIndex = -1; requestRender(); updateTrackerHUD(); } else { e.preventDefault(); logToUI("Cannot delete vertex: polygon must have at least 3 points.", true); } } }); } // --- 5. LOGIC HELPERS & PURE MATH --- function logToUI(msg, isError = false) { const logBox = document.getElementById('wme-paint-log'); if (logBox) { const time = new Date().toLocaleTimeString([], { hour12: false }); const color = isError ? '#FF5B5B' : '#26CC9A'; const row = document.createElement('div'); row.style.color = color; const ts = document.createElement('span'); ts.style.color = '#888'; ts.textContent = `[${time}] `; row.appendChild(ts); row.appendChild(document.createTextNode(String(msg))); logBox.appendChild(row); logBox.scrollTop = logBox.scrollHeight; } if (DEBUG) console.log(`[WME Candy Shop] ${msg}`); } function cancelDraft() { endBrushPainting(); hoveredTempVertexIndex = -1; draggingVertexIndex = -1; draftAction = 'none'; hoveredDraftVertexIndex = -1; draggingDraftVertexIndex = -1; hoveredDraftMidpointIndex = -1; dragStartDraftRingsPx = null; if (isDrawingShape || tempVertices.length > 0) { isDrawingShape = false; tempVertices = []; } else if (appState.isDraftActive) { if (revertOriginalPolygon) { commitBeforeChange('draft_cancel_restore'); let insertIdx = draftPopInsertMasterIndex >= 0 && draftPopInsertMasterIndex <= masterPolygons.length ? draftPopInsertMasterIndex : masterPolygons.length; masterPolygons.splice(insertIdx, 0, ...revertOriginalPolygon); if (revertOriginalPolygon.length) { const L = cloneLinkEntry(revertOriginalLink); shapePlaceLinks.splice(insertIdx, 0, ...revertOriginalPolygon.map((_, i) => i === 0 ? L : null)); } logToUI("Shape restored."); } else { logToUI("Draft cancelled."); } revertOriginalPolygon = null; revertOriginalLink = null; setDraftActive(false); } else if (appState.tool !== 'pan') { changeTool('pan'); } } function clearCanvasState() { endBrushPainting(); commitBeforeChange('clear'); csLayersSelection.clear(); userLayers = []; activeLayerId = createUserLayer(DEFAULT_SHAPE_LAYER_NAME).id; syncActiveLayerAliases(); tempVertices = []; draftVertices = []; draftInteriorRings = []; hoveredTempVertexIndex = -1; draggingVertexIndex = -1; hoveredDraftVertexIndex = -1; draggingDraftVertexIndex = -1; hoveredDraftMidpointIndex = -1; revertOriginalPolygon = null; revertOriginalLink = null; draftPopInsertMasterIndex = -1; setDraftActive(false); isDrawingShape = false; rebuildLayerSelectDom(); refreshPlacesPreviewFromMap(); rebuildPlacesListDom(); scheduleDocumentSave(); requestRender(); updateTrackerHUD(); logToUI('Canvas cleared (one default layer).'); } function formatLength(meters, isImperial) { if (isImperial) { let ft = meters * 3.28084; return (ft > 5280) ? (ft / 5280).toFixed(2) + ' mi' : ft.toFixed(1) + ' ft'; } else { return (meters > 1000) ? (meters / 1000).toFixed(2) + ' km' : meters.toFixed(1) + ' m'; } } function formatArea(sqMeters, isImperial) { if (isImperial) return Math.round(sqMeters * 10.7639).toLocaleString() + ' ft²'; return Math.round(sqMeters).toLocaleString() + ' m²'; } function getPixelArea(pts) { let area = 0; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { area += (pts[j].x + pts[i].x) * (pts[j].y - pts[i].y); } return Math.abs(area / 2); } function wgs84ToMercator(lon, lat) { const x = (lon * 20037508.34) / 180; let y = Math.log(Math.tan(((90 + lat) * Math.PI) / 360)) / (Math.PI / 180); return { x: x, y: (y * 20037508.34) / 180 }; } function mercatorToWgs84(x, y) { const lon = (x / 20037508.34) * 180; const y_deg = (y / 20037508.34) * 180; return { lon: lon, lat: (180 / Math.PI) * (2 * Math.atan(Math.exp(y_deg * Math.PI / 180)) - Math.PI / 2) }; } /** * Screen ↔ WGS84 for the visible map viewport (overlay canvas uses document pixels). * Assumptions: WME keeps the editor map in `#map`; center matches `wmeSDK.Map.getMapCenter()`; scale matches * `getHostMap().getResolution()` for that same view. Uses spherical Web Mercator math (wgs84ToMercator) aligned * with WME’s projection — if any of map div / SDK / OL map / resolution is missing, returns null. */ function globalPixelToGps(globalX, globalY) { const mapDiv = document.getElementById('map'); if (!mapDiv || !getHostMap() || !wmeSDK) return null; const centerGps = wmeSDK.Map.getMapCenter(); const res = getMapResolution(); if (!centerGps || !res) return null; const mapRect = mapDiv.getBoundingClientRect(); const localX = globalX - mapRect.left; const localY = globalY - mapRect.top; const centerMerc = wgs84ToMercator(centerGps.lon, centerGps.lat); const mapW = mapDiv.clientWidth; const mapH = mapDiv.clientHeight; const targetMercX = centerMerc.x + ((localX - (mapW / 2)) * res); const targetMercY = centerMerc.y - ((localY - (mapH / 2)) * res); return mercatorToWgs84(targetMercX, targetMercY); } /** Inverse of globalPixelToGps; same `#map` + center + resolution coupling. Returns null if inputs unavailable. */ function gpsToGlobalPixel(lon, lat) { const mapDiv = document.getElementById('map'); if (!mapDiv || !getHostMap() || !wmeSDK) return null; const centerGps = wmeSDK.Map.getMapCenter(); const res = getMapResolution(); if (!centerGps || !res) return null; const targetMerc = wgs84ToMercator(lon, lat); const centerMerc = wgs84ToMercator(centerGps.lon, centerGps.lat); const mapW = mapDiv.clientWidth; const mapH = mapDiv.clientHeight; const localX = (mapW / 2) + ((targetMerc.x - centerMerc.x) / res); const localY = (mapH / 2) - ((targetMerc.y - centerMerc.y) / res); const mapRect = mapDiv.getBoundingClientRect(); return { x: localX + mapRect.left, y: localY + mapRect.top }; } /** Viewport of `#map` in document pixels — clip canvas drawing so paint never covers WME chrome. */ function getMapClipRect() { const el = document.getElementById('map'); if (!el) return null; const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) return null; return { x: r.left, y: r.top, w: r.width, h: r.height }; } const EARTH_RADIUS_M = 6371008.8; function haversineMeters(lon1, lat1, lon2, lat2) { const r = Math.PI / 180; const dLat = (lat2 - lat1) * r; const dLon = (lon2 - lon1) * r; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin(dLon / 2) ** 2; return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a))); } function geoBearingRad(lon1, lat1, lon2, lat2) { const r = Math.PI / 180; const y = Math.sin((lon2 - lon1) * r) * Math.cos(lat2 * r); const x = Math.cos(lat1 * r) * Math.sin(lat2 * r) - Math.sin(lat1 * r) * Math.cos(lat2 * r) * Math.cos((lon2 - lon1) * r); return Math.atan2(y, x); } function destinationPointMeters(lon, lat, bearingRad, distM) { const r = Math.PI / 180; const lat1 = lat * r; const lon1 = lon * r; const ang = distM / EARTH_RADIUS_M; const lat2 = Math.asin(Math.sin(lat1) * Math.cos(ang) + Math.cos(lat1) * Math.sin(ang) * Math.cos(bearingRad)); const lon2 = lon1 + Math.atan2(Math.sin(bearingRad) * Math.sin(ang) * Math.cos(lat1), Math.cos(ang) - Math.sin(lat1) * Math.sin(lat2)); let lonDeg = lon2 * 180 / Math.PI; lonDeg = ((lonDeg + 540) % 360) - 180; return { lon: lonDeg, lat: lat2 * 180 / Math.PI }; } function interpolateGeodesicLonLat(lon1, lat1, lon2, lat2, t) { const d = haversineMeters(lon1, lat1, lon2, lat2); if (d < 1e-6) return [lon2, lat2]; const br = geoBearingRad(lon1, lat1, lon2, lat2); const p = destinationPointMeters(lon1, lat1, br, d * t); return [p.lon, p.lat]; } /** * Inscribed regular N-gon on geodesic circle radius R; max distance from true circle to edge midpoints ≤ δ (sagitta). * @returns {{ n: number, nRaw: number }} n clamped to [BRUSH_CIRCLE_SIDES_MIN, BRUSH_CIRCLE_SIDES_MAX]; nRaw before clamp. */ function brushCircleSidesRawAndClamped(radiusM) { const R = Math.max(1e-9, Number(radiusM) || 0); const delta = Math.max( BRUSH_CIRCLE_SAGITTA_MIN_M, Math.min(BRUSH_CIRCLE_SAGITTA_MAX_M, Number(appState.brushCircleSagittaM) || BRUSH_CIRCLE_SAGITTA_DEFAULT_M) ); const x = 1 - delta / R; const clamped = Math.max(-1, Math.min(1, x)); const ang = Math.acos(clamped); let nRaw = BRUSH_CIRCLE_SIDES_MAX; if (Number.isFinite(ang) && ang >= 1e-12) { nRaw = Math.ceil(Math.PI / ang); } const n = Math.max(BRUSH_CIRCLE_SIDES_MIN, Math.min(BRUSH_CIRCLE_SIDES_MAX, nRaw)); return { n, nRaw }; } function brushCircleSidesForRadiusMeters(radiusM) { return brushCircleSidesRawAndClamped(radiusM).n; } function brushVertexReadoutText(sides) { const rm = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || BRUSH_METERS_MIN)); if (appState.brushProfile === 'circle') { const s = sides || brushCircleSidesRawAndClamped(rm); let extra = ''; if (s.nRaw > BRUSH_CIRCLE_SIDES_MAX) extra = ' (max)'; else if (s.nRaw < BRUSH_CIRCLE_SIDES_MIN) extra = ' (min)'; return `~${s.n} verts / stamp${extra}`; } const n = brushStampRingVertexCount(rm); return `${n} verts / stamp`; } function brushVertexReadoutTitle(sides) { const rm = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || BRUSH_METERS_MIN)); if (appState.brushProfile === 'circle') { const s = sides || brushCircleSidesRawAndClamped(rm); return `Approximate vertices on one stamp (curve δ; clamped ${BRUSH_CIRCLE_SIDES_MIN}–${BRUSH_CIRCLE_SIDES_MAX}). Ideal N: ${s.nRaw}. Strokes union many stamps.`; } if (appState.brushProfile === 'rounded_square') { return 'Vertices on one rounded-square stamp (flat-earth corners). Quarter circles use δ for segment count. Strokes union stamps along the path.'; } return `Vertices on one ${appState.brushProfile} stamp. Strokes union overlapping stamps along the path.`; } function updateBrushVertexReadout() { const el = document.getElementById('wpo-brush-verts'); if (!el) return; const d = brushCircleSidesRawAndClamped(appState.brushSize); el.textContent = brushVertexReadoutText(d); el.title = brushVertexReadoutTitle(d); } /** * JSTS buffer(distance, quadrantSegments): ~4× segments per full circle. Match geodesic disk angular resolution. */ function bufferPathQuadrantSegmentsForRadius(radiusM) { const n = brushCircleSidesForRadiusMeters(radiusM); const q = Math.round(n / 4); return Math.max(2, Math.min(16, q)); } function geodesicDiskRing(lon, lat, radiusM, sideCountOverride) { const nFull = brushCircleSidesForRadiusMeters(radiusM); const n = sideCountOverride != null ? Math.max(BRUSH_CIRCLE_SIDES_MIN, Math.min(Math.round(sideCountOverride), nFull)) : nFull; const ring = []; for (let i = 0; i < n; i++) { const br = (i / n) * Math.PI * 2; const p = destinationPointMeters(lon, lat, br, radiusM); ring.push([p.lon, p.lat]); } if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) ring.push([ring[0][0], ring[0][1]]); return ring; } function brushUsesCircularBuffer() { return appState.brushProfile === 'circle'; } function brushProfileUsesAlignControl() { const p = appState.brushProfile; return p === 'square' || p === 'diamond' || p === 'rounded_square'; } function brushProfileShowsCurveSlider() { const p = appState.brushProfile; return p === 'circle' || p === 'rounded_square'; } function brushProfileChipLabel(profile) { switch (profile) { case 'circle': return 'Circle'; case 'square': return 'Square'; case 'diamond': return 'Diamond'; case 'hexagon': return 'Hex'; case 'rounded_square': return 'Round sq'; default: return ''; } } function brushStrokeBearingRad(path, index) { if (!path || path.length < 2) return 0; const n = path.length; if (index <= 0) return geoBearingRad(path[0][0], path[0][1], path[1][0], path[1][1]); if (index >= n - 1) return geoBearingRad(path[n - 2][0], path[n - 2][1], path[n - 1][0], path[n - 1][1]); return geoBearingRad(path[index - 1][0], path[index - 1][1], path[index][0], path[index][1]); } function brushBaseBearingRad(path, index) { if (appState.brushAlign !== 'stroke' || !brushProfileUsesAlignControl()) return 0; return brushStrokeBearingRad(path, index); } /** Rotate local shape (x east, y north) by bearing b from geographic north. */ function rotateLocalEastNorthMeters(x, y, bearingRad) { const c = Math.cos(bearingRad); const s = Math.sin(bearingRad); return { east: x * c - y * s, north: x * s + y * c }; } /** Closed meter-space polyline for rounded square; half-size R, corner radius r. */ function roundedSquareMeterRingClosed(R, rCorner) { const Rm = Math.max(1e-6, R); let r = Math.min(Math.max(rCorner, 0), Rm * 0.45); const pts = []; const arc = (cx, cy, a0, a1) => { const n = BRUSH_ROUNDED_SQUARE_ARC_SEGS; for (let i = 1; i <= n; i++) { const t = i / n; const ang = a0 + (a1 - a0) * t; pts.push([cx + r * Math.cos(ang), cy + r * Math.sin(ang)]); } }; pts.push([-Rm + r, -Rm]); pts.push([Rm - r, -Rm]); arc(Rm - r, -Rm + r, -Math.PI / 2, 0); pts.push([Rm, Rm - r]); arc(Rm - r, Rm - r, 0, Math.PI / 2); pts.push([-Rm + r, Rm]); arc(-Rm + r, Rm - r, Math.PI / 2, Math.PI); pts.push([-Rm, -Rm + r]); arc(-Rm + r, -Rm + r, Math.PI, 1.5 * Math.PI); pts.push([pts[0][0], pts[0][1]]); return pts; } function meterRingToLonLatRing(centerLon, centerLat, refLat, xyPairs, baseBearingRad) { const ring = []; for (let i = 0; i < xyPairs.length; i++) { const [x, y] = xyPairs[i]; const { east, north } = rotateLocalEastNorthMeters(x, y, baseBearingRad); ring.push(localMetersToLonLat(east, north, centerLon, centerLat)); } return ring; } /** * Closed geodesic-ish ring for current brush profile (R = radius / apothem per tooltips). * @param {number|null} circleSideOverride only for circle live preview */ function geodesicBrushProfileRing(lon, lat, radiusM, refLat, path, pathIndex, circleSideOverride) { const R = Math.max(1e-6, Number(radiusM) || 0); const ref = refLat != null ? refLat : lat; const prof = appState.brushProfile; const base = brushBaseBearingRad(path, pathIndex); if (prof === 'circle') { return geodesicDiskRing(lon, lat, R, circleSideOverride); } if (prof === 'square') { const d = R * Math.SQRT2; const ring = []; for (let k = 0; k < 4; k++) { const br = base + Math.PI / 4 + k * (Math.PI / 2); const p = destinationPointMeters(lon, lat, br, d); ring.push([p.lon, p.lat]); } ring.push([ring[0][0], ring[0][1]]); return ring; } if (prof === 'diamond') { const ring = []; for (let k = 0; k < 4; k++) { const br = base + k * (Math.PI / 2); const p = destinationPointMeters(lon, lat, br, R); ring.push([p.lon, p.lat]); } ring.push([ring[0][0], ring[0][1]]); return ring; } if (prof === 'hexagon') { const rv = R / Math.cos(Math.PI / 6); const ring = []; for (let k = 0; k < 6; k++) { const br = base + Math.PI / 6 + k * (Math.PI / 3); const p = destinationPointMeters(lon, lat, br, rv); ring.push([p.lon, p.lat]); } ring.push([ring[0][0], ring[0][1]]); return ring; } if (prof === 'rounded_square') { const ratio = Math.max(BRUSH_CORNER_RATIO_MIN, Math.min(BRUSH_CORNER_RATIO_MAX, Number(appState.brushCornerRatio) || BRUSH_CORNER_RATIO_DEFAULT)); const rCorner = ratio * R; const xy = roundedSquareMeterRingClosed(R, rCorner); return meterRingToLonLatRing(lon, lat, ref, xy, base); } return geodesicDiskRing(lon, lat, R, circleSideOverride); } function brushStampRingVertexCount(radiusM) { const R = Math.max(1e-6, Number(radiusM) || 0); const prof = appState.brushProfile; if (prof === 'circle') return brushCircleSidesForRadiusMeters(R); if (prof === 'square' || prof === 'diamond') return 4; if (prof === 'hexagon') return 6; if (prof === 'rounded_square') { const ratio = Math.max(BRUSH_CORNER_RATIO_MIN, Math.min(BRUSH_CORNER_RATIO_MAX, Number(appState.brushCornerRatio) || BRUSH_CORNER_RATIO_DEFAULT)); const rCorner = ratio * R; const xy = roundedSquareMeterRingClosed(R, rCorner); return Math.max(4, xy.length - 1); } return 0; } function densifyOpenPolylineForBrushStamps(path, maxSegM) { if (!path || path.length < 2) return path ? path.map(p => p.slice()) : []; const maxStep = Math.max(1.5, maxSegM); const out = [[path[0][0], path[0][1]]]; for (let i = 0; i < path.length - 1; i++) { const a = path[i]; const b = path[i + 1]; const d = haversineMeters(a[0], a[1], b[0], b[1]); const steps = Math.max(1, Math.ceil(d / maxStep)); for (let k = 1; k <= steps; k++) { const t = k / steps; out.push(interpolateGeodesicLonLat(a[0], a[1], b[0], b[1], t)); } } return out; } function buildPolylineStampUnionMultipolygon(pc, openPath, radiusM, refLat, logCtx, circleSideOverride) { if (!openPath || !openPath.length) return null; const R = Math.max(BRUSH_METERS_MIN, Number(radiusM) || 0); const ref = refLat != null ? refLat : openPath[0][1]; if (openPath.length < 2) { const p = openPath[0]; const ring = geodesicBrushProfileRing(p[0], p[1], R, ref, null, 0, circleSideOverride); return [[ring]]; } const maxSeg = Math.max(2, R * BRUSH_POLY_STAMP_SPACING_FACTOR); const dense = densifyOpenPolylineForBrushStamps(openPath, maxSeg); if (!dense.length) return null; const parts = []; for (let i = 0; i < dense.length; i++) { const q = dense[i]; const ring = geodesicBrushProfileRing(q[0], q[1], R, ref, dense, i, circleSideOverride); parts.push([[ring]]); } if (!parts.length) return null; if (parts.length === 1) return parts[0]; return unionMultipolysWithFallback(pc, parts, Object.assign({ reason: 'polyline_stamp_union', stampCount: parts.length }, logCtx || {})); } function subsampleClosedRingForPreview(ring, maxVerts) { if (!ring || ring.length <= 2 || maxVerts == null || ring.length <= maxVerts) return ring; const inner = ring[ring.length - 1][0] === ring[0][0] && ring[ring.length - 1][1] === ring[0][1] ? ring.slice(0, -1) : ring.slice(); const n = inner.length; if (n <= maxVerts) return ring; const out = []; const step = n / maxVerts; for (let i = 0; i < maxVerts; i++) { const idx = Math.min(n - 1, Math.floor(i * step)); out.push(inner[idx]); } out.push([out[0][0], out[0][1]]); return out; } function trimBrushStrokeSamplesIfNeeded(samples) { while (samples.length > BRUSH_STROKE_SAMPLES_MAX) { const last = samples[samples.length - 1]; const next = []; for (let i = 0; i < samples.length; i += 2) next.push(samples[i]); const L = next[next.length - 1]; if (!L || L[0] !== last[0] || L[1] !== last[1]) next.push(last); samples.length = 0; next.forEach(p => samples.push(p)); } } /** Keep stamp centers close enough that disk unions overlap (handles fast mouse jumps). */ function appendBrushSamplesAlongGeodesic(samples, gLon, gLat, radiusM) { if (!samples.length) { samples.push([gLon, gLat]); return; } const last = samples[samples.length - 1]; const d = haversineMeters(last[0], last[1], gLon, gLat); if (d < 0.06) return; const maxSeg = Math.max(2, radiusM * 1.5); if (d <= maxSeg) { samples.push([gLon, gLat]); trimBrushStrokeSamplesIfNeeded(samples); return; } let steps = Math.max(1, Math.ceil(d / maxSeg)); steps = Math.min(steps, BRUSH_APPEND_MAX_STEPS_PER_EVENT); for (let k = 1; k <= steps; k++) { const t = k / steps; samples.push(interpolateGeodesicLonLat(last[0], last[1], gLon, gLat, t)); } trimBrushStrokeSamplesIfNeeded(samples); } /** Union many multipolygon parts in a balanced tree (avoids deep linear pc.union chains). */ function balancedUnionMultipolys(pc, parts) { if (!parts || !parts.length) return null; let layer = parts.filter(p => p != null); while (layer.length > 1) { const next = []; for (let i = 0; i < layer.length; i += 2) { if (i + 1 < layer.length) next.push(pc.union(layer[i], layer[i + 1])); else next.push(layer[i]); } layer = next; } return layer[0]; } /** Linear union; sometimes succeeds when balanced tree hits jsts bugs. */ function sequentialUnionMultipolys(pc, parts) { if (!parts || !parts.length) return null; const layer = parts.filter(p => p != null); if (!layer.length) return null; let acc = layer[0]; for (let i = 1; i < layer.length; i++) acc = pc.union(acc, layer[i]); return acc; } /** Union one batch: balanced → sequential → binary split (disk+capsule can be hundreds of parts). */ function unionOneBatchWithFallback(pc, batch) { if (!batch || !batch.length) return null; if (batch.length === 1) return batch[0]; try { return balancedUnionMultipolys(pc, batch); } catch (e1) { try { return sequentialUnionMultipolys(pc, batch); } catch (e2) { if (batch.length <= 2) throw e2; const mid = Math.ceil(batch.length / 2); const left = unionOneBatchWithFallback(pc, batch.slice(0, mid)); const right = unionOneBatchWithFallback(pc, batch.slice(mid)); return pc.union(left, right); } } } /** Fold many parts in waves of at most BRUSH_STAMP_UNION_BATCH_SIZE per union group. */ function batchedFoldUnionMultipolys(pc, parts) { let layer = parts.filter(p => p != null); if (!layer.length) return null; const B = BRUSH_STAMP_UNION_BATCH_SIZE; while (layer.length > 1) { const next = []; for (let i = 0; i < layer.length; i += B) { const batch = layer.slice(i, i + B); if (batch.length === 1) next.push(batch[0]); else next.push(unionOneBatchWithFallback(pc, batch)); } layer = next; } return layer[0]; } function batchedFoldUnionSequentialOnly(pc, parts) { let layer = parts.filter(p => p != null); if (!layer.length) return null; const B = BRUSH_STAMP_UNION_BATCH_SIZE; while (layer.length > 1) { const next = []; for (let i = 0; i < layer.length; i += B) { const batch = layer.slice(i, i + B); if (batch.length === 1) next.push(batch[0]); else next.push(sequentialUnionMultipolys(pc, batch)); } layer = next; } return layer[0]; } /** Try balanced union first, then sequential (chunk outline unions can throw on complex geometry). */ function unionMultipolysWithFallback(pc, parts, logCtx) { if (!parts || !parts.length) return null; const layer = parts.filter(p => p != null); if (!layer.length) return null; if (layer.length === 1) return layer[0]; const tryPrimary = () => (layer.length <= BRUSH_STAMP_UNION_BATCH_SIZE ? balancedUnionMultipolys(pc, layer) : batchedFoldUnionMultipolys(pc, layer)); const trySecondary = () => (layer.length <= BRUSH_STAMP_UNION_BATCH_SIZE ? sequentialUnionMultipolys(pc, layer) : batchedFoldUnionSequentialOnly(pc, layer)); try { return tryPrimary(); } catch (e1) { logBrushGeom('warn', 'stamp_union_balanced_throw', Object.assign({ message: e1 && e1.message ? String(e1.message) : String(e1), partCount: layer.length }, logCtx || {})); try { return trySecondary(); } catch (e2) { logBrushGeom('error', 'stamp_union_sequential_throw', Object.assign({ message: e2 && e2.message ? String(e2.message) : String(e2), partCount: layer.length }, logCtx || {})); return null; } } } /** Normalize angle to (-π, π]. */ function normalizeAngleRad(a) { let x = a; while (x <= -Math.PI) x += 2 * Math.PI; while (x > Math.PI) x -= 2 * Math.PI; return x; } /** Local equirectangular meters from ref (good for single-stroke brush footprints). */ function lonLatToLocalMeters(lon, lat, refLon, refLat) { const cosLat = Math.cos(refLat * Math.PI / 180); const x = (lon - refLon) * (Math.PI / 180) * EARTH_RADIUS_M * cosLat; const y = (lat - refLat) * (Math.PI / 180) * EARTH_RADIUS_M; return { x, y }; } function localMetersToLonLat(x, y, refLon, refLat) { const cosLat = Math.cos(refLat * Math.PI / 180); const lon = refLon + x / (EARTH_RADIUS_M * cosLat * Math.PI / 180); const lat = refLat + y / (EARTH_RADIUS_M * Math.PI / 180); return [lon, lat]; } function vec2Normalize(vx, vy) { const L = Math.hypot(vx, vy); if (L < 1e-18) return { x: 1, y: 0 }; return { x: vx / L, y: vy / L }; } /** Left perpendicular (CCW 90° in x-east, y-north plane). */ function perpLeftXY(vx, vy) { return vec2Normalize(-vy, vx); } function ringAreaAbsDeg(ring) { let a = 0; const n = ring.length; for (let i = 0, j = n - 1; i < n; j = i++) { a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]; } return Math.abs(a); } /** Sum outer-ring areas (deg² proxy) for relative before/after checks. */ function sumOuterRingAreaDeg(mp) { if (!mp || !mp.length) return 0; let s = 0; for (let i = 0; i < mp.length; i++) { const part = mp[i]; if (part && part[0] && part[0].length >= 3) s += ringAreaAbsDeg(part[0]); } return s; } /** Total rings (outer + holes) across multipolygon parts. */ function totalRingCount(mp) { if (!mp || !mp.length) return 0; let n = 0; for (let i = 0; i < mp.length; i++) { if (mp[i]) n += mp[i].length; } return n; } /** Count sharp turn vertices (figure-8 / crossings tend to have large bearing deltas). */ function countStrokeSharpTurns(strokeLonLat, minDeltaRad) { if (!strokeLonLat || strokeLonLat.length < 3) return 0; let c = 0; for (let i = 1; i < strokeLonLat.length - 1; i++) { const a = strokeLonLat[i - 1], b = strokeLonLat[i], d = strokeLonLat[i + 1]; const br1 = geoBearingRad(a[0], a[1], b[0], b[1]); const br2 = geoBearingRad(b[0], b[1], d[0], d[1]); let delta = Math.abs(br2 - br1); if (delta > Math.PI) delta = 2 * Math.PI - delta; if (delta >= minDeltaRad) c++; } return c; } function ringCentroidLonLat(ring) { let sx = 0, sy = 0; const n = ring.length - (ring.length > 1 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1] ? 1 : 0); const lim = Math.max(0, n); for (let i = 0; i < lim; i++) { sx += ring[i][0]; sy += ring[i][1]; } return lim ? [sx / lim, sy / lim] : [0, 0]; } function closestPointOnRingEdgeToPoint(ring, lon, lat, edgeInterior) { let best = null; let bestD = Infinity; const closed = ring.length > 1 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]; const m = closed ? ring.length - 1 : ring.length; const trim = edgeInterior ? 0.1 : 0; for (let i = 0; i < m; i++) { const a = ring[i], b = ring[(i + 1) % m]; const segM = haversineMeters(a[0], a[1], b[0], b[1]); const steps = Math.max(6, Math.ceil(segM / 1.2)); const lo = Math.max(0, Math.floor(trim * steps)); const hi = Math.min(steps, Math.ceil((1 - trim) * steps)); for (let s = lo; s <= hi; s++) { const t = s / steps; const ll = interpolateGeodesicLonLat(a[0], a[1], b[0], b[1], t); const d = haversineMeters(lon, lat, ll[0], ll[1]); if (d < bestD) { bestD = d; best = ll; } } } return best || [ring[0][0], ring[0][1]]; } function geodesicCorridorRing(lon1, lat1, lon2, lat2, halfWidthM, steps) { const pc = getGeometryEngine(); if (pc && pc.bufferPath) { try { const buff = pc.bufferPath([[lon1, lat1], [lon2, lat2]], halfWidthM, steps); if (buff && buff.length > 0 && buff[0] && buff[0].length > 0) { return buff[0][0]; } } catch (e) { // fallback to minimal ring on failure } } return [[lon1, lat1], [lon2, lat2], [lon1, lat1]]; } function pickLargestPartByArea(multipoly) { let best = null; let bestA = -1; if (!multipoly) return null; for (let p = 0; p < multipoly.length; p++) { const part = multipoly[p]; if (!part || !part[0]) continue; const a = ringAreaAbsDeg(part[0]); if (a > bestA) { bestA = a; best = part; } } return best; } function bridgePartHoles(pc, part) { if (!part || !part[0] || part.length < 2) return part; let rings = part.map(r => r.map(p => [p[0], p[1]])); let safety = 0; while (rings.length > 1 && safety++ < 40) { const outer = rings[0]; const inner = rings[1]; const hc = ringCentroidLonLat(inner); const pOut = closestPointOnRingEdgeToPoint(outer, hc[0], hc[1], true); const pIn = closestPointOnRingEdgeToPoint(inner, pOut[0], pOut[1], true); const dist = haversineMeters(pIn[0], pIn[1], pOut[0], pOut[1]); let halfW = Math.max(0.35, Math.min(2.5, dist * 0.07)); const stepN = Math.max(10, Math.min(56, Math.ceil(dist / 2.5))); let slit = geodesicCorridorRing(pIn[0], pIn[1], pOut[0], pOut[1], halfW, stepN); let merged; try { merged = pc.union([rings], [[slit]]); } catch (e) { break; } let next = pickLargestPartByArea(merged); if (next && next.length < rings.length) { rings = next.map(r => r.map(p => [p[0], p[1]])); continue; } halfW = Math.min(4, halfW * 2.2); slit = geodesicCorridorRing(pIn[0], pIn[1], pOut[0], pOut[1], halfW, stepN); try { merged = pc.union([rings], [[slit]]); } catch (e2) { break; } next = pickLargestPartByArea(merged); if (next && next.length < rings.length) { rings = next.map(r => r.map(p => [p[0], p[1]])); continue; } break; } return rings; } function bridgeHolesAfterBrushOp(pc) { const mapped = masterPolygons.map(part => bridgePartHoles(pc, part)).filter(p => p && p[0] && p[0].length > 2); const L = getActiveUserLayer(); if (!L) return; L.polygons = mapped; masterPolygons = L.polygons; alignShapeLinksToMaster(); } function flattenSanitizedDrawnParts(drawnParts, pc) { if (!drawnParts || !drawnParts.length || !pc || typeof pc.sanitizePartToParts !== 'function') return []; const out = []; for (const p of drawnParts) { const expanded = pc.sanitizePartToParts(p); if (expanded && expanded.length) { for (let k = 0; k < expanded.length; k++) out.push(expanded[k]); } } return out; } /** * buffer(0) each part; drops empty parts. * Active non-broken link + multi-part split → splitGroupId on siblings; j>0 get inactive+broken for UI/Apply (§1.9). * sourceIndices[k] = original input row index for output part k (for competing-link cleanup on Apply). */ function expandSanitizedPolygonsAndLinks(pc, polygonsIn, linksIn) { if (!polygonsIn.length) { return { polygons: [], links: [], sourceIndices: [] }; } if (!pc || typeof pc.sanitizePartToParts !== 'function') { return { polygons: polygonsIn, links: linksIn, sourceIndices: polygonsIn.map((_, i) => i) }; } const links = linksIn.slice(); while (links.length < polygonsIn.length) links.push(null); links.length = polygonsIn.length; const newPolys = []; const newLinks = []; const newSourceIndices = []; for (let i = 0; i < polygonsIn.length; i++) { const link = normalizeShapeLink(links[i]); const expanded = pc.sanitizePartToParts(polygonsIn[i]); if (!expanded || expanded.length === 0) continue; const hasActiveSplit = link && link.venueId != null && link.active !== false && link.broken !== true; let splitGroupId = null; if (expanded.length > 1 && hasActiveSplit) { splitGroupId = 'sg_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10); } for (let j = 0; j < expanded.length; j++) { newPolys.push(expanded[j]); newSourceIndices.push(i); if (!link) { newLinks.push(null); } else if (expanded.length === 1) { newLinks.push(cloneLinkEntry(link)); } else if (!hasActiveSplit) { newLinks.push(j === 0 ? cloneLinkEntry(link) : null); } else { const base = cloneLinkEntry(link); base.splitGroupId = splitGroupId; if (j === 0) { base.active = true; base.broken = false; } else { base.active = false; base.broken = true; } newLinks.push(base); } } } return { polygons: newPolys, links: newLinks, sourceIndices: newSourceIndices }; } /** buffer(0) each master part on the active layer; first split keeps shapePlaceLinks[i], rest null. */ function expandSanitizedMasterPolygons(pc) { if (!masterPolygons.length) return; alignShapeLinksToMaster(); const ex = expandSanitizedPolygonsAndLinks(pc, masterPolygons, shapePlaceLinks); const L = getActiveUserLayer(); if (!L) return; L.polygons = ex.polygons; L.links = ex.links; masterPolygons = L.polygons; shapePlaceLinks = L.links; ensureShapeIdsForLayer(L); } function perpendicularDistanceXY(p, p1, p2) { let x = p1[0], y = p1[1]; let dx = p2[0] - x, dy = p2[1] - y; if (dx !== 0 || dy !== 0) { let t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy); if (t > 1) { x = p2[0]; y = p2[1]; } else if (t > 0) { x += dx * t; y += dy * t; } } dx = p[0] - x; dy = p[1] - y; return Math.sqrt(dx * dx + dy * dy); } function rdpXY(points, epsilon) { if (points.length < 3) return points; let dmax = 0; let index = 0; const end = points.length - 1; for (let i = 1; i < end; i++) { let d = perpendicularDistanceXY(points[i], points[0], points[end]); if (d > dmax) { index = i; dmax = d; } } if (dmax > epsilon) { let recResults1 = rdpXY(points.slice(0, index + 1), epsilon); let recResults2 = rdpXY(points.slice(index), epsilon); return recResults1.slice(0, recResults1.length - 1).concat(recResults2); } else { return [points[0], points[end]]; } } /** * Douglas–Peucker on an open lon/lat polyline (brush centerline). Preserves endpoints. * Tightens epsilon up to 12 passes until vertex count is at most maxVerts (when maxVerts is finite). */ function simplifyOpenPolylineLonLatRDP(strokeLonLat, epsilonMeters, refLat, maxVerts) { if (!strokeLonLat || strokeLonLat.length < 3) return strokeLonLat ? strokeLonLat.map(p => [p[0], p[1]]) : []; const latRad = (refLat * Math.PI) / 180; const mPerDegLon = 111320 * Math.max(0.2, Math.cos(latRad)); const mPerDegLat = 110540; const toXY = p => [p[0] * mPerDegLon, p[1] * mPerDegLat]; const toLL = xy => [xy[0] / mPerDegLon, xy[1] / mPerDegLat]; let eps = epsilonMeters; const cap = maxVerts != null && Number.isFinite(maxVerts) ? maxVerts : Infinity; const a0 = strokeLonLat[0]; const a1 = strokeLonLat[strokeLonLat.length - 1]; for (let iter = 0; iter < 12; iter++) { const xy = strokeLonLat.map(toXY); const simplifiedXY = rdpXY(xy, eps); let ll = simplifiedXY.map(toLL); if (ll.length && (ll[0][0] !== a0[0] || ll[0][1] !== a0[1])) ll = [[a0[0], a0[1]]].concat(ll.slice(1)); if (ll.length && (ll[ll.length - 1][0] !== a1[0] || ll[ll.length - 1][1] !== a1[1])) ll = ll.slice(0, -1).concat([[a1[0], a1[1]]]); if (ll.length < 2) return strokeLonLat.map(p => [p[0], p[1]]); if (ll.length <= cap || iter >= 11) return ll; eps *= 1.35; } return strokeLonLat.map(p => [p[0], p[1]]); } /** Unique vertices in a closed ring (drops duplicate closing point). */ function ringOpenVertices(ring) { if (!ring || ring.length < 2) return null; const closed = ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]; return closed ? ring.slice(0, -1) : ring.slice(); } function simplifyRingRDP(ring, epsilonMeters, refLat) { if (!ring || ring.length < 4) return ring; const minVerts = 8; // safety floor (unique vertices along boundary) const open = ringOpenVertices(ring); if (!open || open.length < 3) return ring; const latRad = (refLat * Math.PI) / 180; const mPerDegLon = 111320 * Math.max(0.2, Math.cos(latRad)); const mPerDegLat = 110540; const toXY = p => [p[0] * mPerDegLon, p[1] * mPerDegLat]; const toLL = xy => [xy[0] / mPerDegLon, xy[1] / mPerDegLat]; const simplifyOnce = (eps) => { const xy = open.map(toXY); // IMPORTANT: run RDP on an OPEN polyline v0..v_{n-1}. Do NOT duplicate v0 at the end: // xy.concat([xy[0]]) makes first==last for RDP and collapses complex loops (figure-8, multi-loop). const simplifiedXY = rdpXY(xy, eps); if (!simplifiedXY || simplifiedXY.length < 2) return null; let ll = simplifiedXY.map(toLL); const first = ll[0]; const last = ll[ll.length - 1]; if (first[0] !== last[0] || first[1] !== last[1]) ll = ll.concat([[first[0], first[1]]]); return ll; }; const openVertCount = (closedRing) => { const o = ringOpenVertices(closedRing); return o ? o.length : 0; }; let simplified = simplifyOnce(epsilonMeters); if (simplified == null || openVertCount(simplified) < minVerts) simplified = simplifyOnce(Math.max(0.05, epsilonMeters * 0.5)); if (simplified == null || openVertCount(simplified) < minVerts) simplified = open.slice(); const first = simplified[0]; const last = simplified[simplified.length - 1]; if (first[0] !== last[0] || first[1] !== last[1]) simplified = simplified.concat([[first[0], first[1]]]); return simplified; } function cloneMasterPolygons(src) { if (!src || !src.length) return []; return src.map(part => part.map(ring => ring.map(p => [p[0], p[1]]))); } /** True if multipolygon is missing or has no valid outer ring. */ function isMultipolyEmptyOrInvalid(mp) { if (!mp || !mp.length) return true; for (let p = 0; p < mp.length; p++) { const part = mp[p]; if (!part || !part[0] || part[0].length < 4) return true; } return false; } /** Paint brush: valid geometry but suspicious loss vs pre-stroke (area or holes). */ function brushGeometryRegression(before, after, beforeWasEmpty) { if (!after || isMultipolyEmptyOrInvalid(after)) return true; if (beforeWasEmpty) return false; const sumB = sumOuterRingAreaDeg(before); const sumA = sumOuterRingAreaDeg(after); if (sumB > 1e-12 && sumA < sumB * BRUSH_REGRESSION_AREA_RATIO) return true; if (before.some(p => p && p.length > 1) && totalRingCount(after) < totalRingCount(before)) return true; return false; } /** Compact metrics for brush/eraser diagnostics (deg² proxy, not true geodesic area). */ function brushGeomSnapshot(mp) { return { parts: mp && mp.length ? mp.length : 0, sumOuterDeg: sumOuterRingAreaDeg(mp), rings: totalRingCount(mp), invalid: isMultipolyEmptyOrInvalid(mp) }; } /** * @param {'trace'|'warn'|'error'} level * @param {string} event * @param {object} [data] */ function logBrushGeom(level, event, data) { if (!DEBUG) return; const payload = Object.assign({ event }, data || {}); if (level === 'error') console.error('[CandyPaint][brushGeom]', payload); else if (level === 'warn') console.warn('[CandyPaint][brushGeom]', payload); else console.info('[CandyPaint][brushGeom]', payload); } function readBrushSimplifyParams(refLat) { let vertexMinM = 5; let minTurnDeg = 12; try { const a = localStorage.getItem(LS_BRUSH_VERTEX_MIN_M); const b = localStorage.getItem(LS_BRUSH_MIN_TURN_DEG); if (a != null && a !== '') vertexMinM = Math.max(0.25, Number(a) || vertexMinM); if (b != null && b !== '') minTurnDeg = Math.max(0, Math.min(180, Number(b) || minTurnDeg)); } catch (e) { /* ignore */ } const latRad = ((refLat != null ? refLat : 50) * Math.PI) / 180; const mPerDegLon = 111320 * Math.max(0.2, Math.cos(latRad)); const mPerDegLat = 110540; return { vertexMinM, minTurnDeg, mPerDegLon, mPerDegLat }; } function simplifyMultipolygonRDP(multipoly, epsilonMeters, refLat) { if (!multipoly) return null; return multipoly.map(part => { if (!part) return part; return part.map(ring => simplifyRingRDP(ring, epsilonMeters, refLat)); }); } function reconcileLinksAfterPartCountChange(prevLinks, prevCount) { const nowCount = masterPolygons.length; const prevNonNull = prevLinks.filter(l => l && l.venueId != null).length; alignShapeLinksToMaster(); if (prevNonNull === 0) return; if (prevNonNull === 1 && nowCount > prevCount) { let best = -1; let bestA = -1; for (let i = 0; i < nowCount; i++) { const a = ringAreaAbsDeg(masterPolygons[i][0]); if (a > bestA) { bestA = a; best = i; } } const found = prevLinks.find(l => l && l.venueId != null); if (found && best >= 0) { shapePlaceLinks.fill(null); shapePlaceLinks[best] = normalizeShapeLink(found); rebalanceVenueLinkActives({ layer: getActiveUserLayer(), shapeIndex: best }); logToUI('Link kept on largest fragment after erase.', false); } return; } shapePlaceLinks.fill(null); logToUI('Shape–place links cleared (part count changed).', false); } /** Overlapping windows along an open lon/lat polyline; each slice has at least two points when possible. */ function sliceOpenPolylineIntoChunks(path, chunkSize, overlap) { const chunks = []; if (!path || path.length < 2) return chunks; const stride = Math.max(1, chunkSize - overlap); for (let start = 0; start < path.length; start += stride) { const end = Math.min(path.length, start + chunkSize); const slice = path.slice(start, end); if (slice.length >= 2) chunks.push(slice); if (end >= path.length) break; } return chunks; } /** One-part multipolygon: single brush stamp (profile from appState), tap / zero-length stroke. */ function diskStampMultipolygon(lon, lat, radiusM, refLat) { const ref = refLat != null ? refLat : lat; const ring = geodesicBrushProfileRing(lon, lat, radiusM, ref, null, 0, undefined); return [[ring]]; } /** * Brush stamp: one bufferPath or chunked bufferPaths unioned via unionMultipolysWithFallback. * Stamps are not RDP-simplified here — quadrant segments follow brushCircleSidesForRadiusMeters (sagitta δ); RDP was coarsening results. * @param {object} logCtx passed to unionMultipolysWithFallback * @param {boolean} [_skipSimplify] unused (API compat for callers) */ function buildBrushStampMultipolygonFromPath(pc, strokePathForStamp, radiusM, _epsilonMeters, refLat, logCtx, _skipSimplify) { if (!strokePathForStamp || strokePathForStamp.length === 0) return null; const quadSeg = bufferPathQuadrantSegmentsForRadius(radiusM); if (strokePathForStamp.length < 2) { const p = strokePathForStamp[0]; return diskStampMultipolygon(p[0], p[1], radiusM, refLat); } const chunkV = Math.min(BRUSH_STAMP_UNION_MAX_VERTS, BRUSH_STAMP_BUFFER_CHUNK_VERTS); const overlap = BRUSH_STAMP_UNION_CHUNK_OVERLAP; const slices = strokePathForStamp.length <= chunkV ? [strokePathForStamp] : sliceOpenPolylineIntoChunks(strokePathForStamp, chunkV, overlap); if (!slices.length) return null; const parts = []; for (let s = 0; s < slices.length; s++) { let stamp = pc.bufferPath(slices[s], radiusM, quadSeg); if (!stamp || !stamp.length) continue; parts.push(stamp); } if (!parts.length) return null; if (parts.length === 1) return parts[0]; const merged = unionMultipolysWithFallback(pc, parts, Object.assign({ chunkSlices: slices.length }, logCtx || {})); return merged; } /** Circle: JSTS buffer along path; other profiles: union overlapping geodesic stamps. */ function buildAnyBrushStrokeStampMultipolygon(pc, openPath, radiusM, refLat, logCtx) { if (brushUsesCircularBuffer()) { return buildBrushStampMultipolygonFromPath(pc, openPath, radiusM, 0, refLat, logCtx, false); } return buildPolylineStampUnionMultipolygon(pc, openPath, radiusM, refLat, logCtx, undefined); } function applyBrushGeometryOnce(centerLineLonLat, radiusM, isEraser, doPostSimplifyOuter) { const pc = getGeometryEngine(); if (!pc || !centerLineLonLat.length) { logBrushGeom('warn', 'early_exit', { reason: !pc ? 'no_polygon_clipping' : 'empty_path', isEraser, pathLen: centerLineLonLat ? centerLineLonLat.length : 0 }); return; } const brushTargetLayer = getActiveUserLayer(); if (!brushTargetLayer) { logBrushGeom('warn', 'early_exit', { reason: 'no_active_layer', isEraser }); return; } syncActiveLayerAliases(); // Dynamic tolerance based on brush size R (reduced aggressiveness by 50%) const epsilonMeters = Math.max(0.2, 0.5 * (radiusM > 40 ? radiusM / 6 : radiusM / 4)); const refLat = centerLineLonLat[0][1]; const preStrokeEpsilon = Math.max(0.1, Math.min(epsilonMeters * 0.52, radiusM * 0.14)); const vertBudget = Math.max(200, Math.floor(BRUSH_STAMP_UNION_MAX_VERTS * 0.92)); const strokePathForStamp = simplifyOpenPolylineLonLatRDP(centerLineLonLat, preStrokeEpsilon, refLat, vertBudget); let stampWorkPath = strokePathForStamp; if (stampWorkPath.length >= 2) { const dSpan = haversineMeters( stampWorkPath[0][0], stampWorkPath[0][1], stampWorkPath[stampWorkPath.length - 1][0], stampWorkPath[stampWorkPath.length - 1][1]); if (dSpan < Math.max(0.12, radiusM * 0.00035)) stampWorkPath = [stampWorkPath[0]]; } try { const prevCount = masterPolygons.length; const prevLinks = cloneShapePlaceLinks(shapePlaceLinks); const masterBeforeBrush = !isEraser ? cloneMasterPolygons(masterPolygons) : null; const sharpTurns = !isEraser ? countStrokeSharpTurns(centerLineLonLat, BRUSH_SHARP_TURN_MIN_RAD) : 0; const complexStroke = !isEraser && ( centerLineLonLat.length >= BRUSH_COMPLEX_VERT_THRESHOLD || sharpTurns >= BRUSH_SHARP_TURN_COUNT_THRESHOLD ); logBrushGeom('trace', 'stroke_start', { isEraser, radiusM, pathLen: centerLineLonLat.length, pathLenStamp: strokePathForStamp.length, pathLenStampWork: stampWorkPath.length, preStrokeEpsilonMeters: preStrokeEpsilon, epsilonMeters, masterWideSimplify: false, sharpTurns, complexStroke, masterPartsBefore: masterPolygons.length, sumOuterBefore: sumOuterRingAreaDeg(masterPolygons) }); try { if (isEraser) { if (!masterPolygons.length) { logBrushGeom('warn', 'eraser_skip_no_master', {}); return; } const quadSeg = bufferPathQuadrantSegmentsForRadius(radiusM); if (stampWorkPath.length > BRUSH_ERASER_DIFF_CHUNK_VERTS) { const slices = sliceOpenPolylineIntoChunks( stampWorkPath, BRUSH_ERASER_DIFF_CHUNK_VERTS, BRUSH_ERASER_DIFF_CHUNK_OVERLAP ); logBrushGeom('trace', 'eraser_chunked', { chunks: slices.length, pathLen: stampWorkPath.length }); if (!slices.length) { logBrushGeom('warn', 'eraser_chunked_empty_slices', { pathLen: stampWorkPath.length }); return; } for (let si = 0; si < slices.length; si++) { let stampPoly = slices[si].length >= 2 ? (brushUsesCircularBuffer() ? pc.bufferPath(slices[si], radiusM, quadSeg) : buildPolylineStampUnionMultipolygon(pc, slices[si], radiusM, refLat, { reason: 'eraser_chunk', chunkIndex: si })) : diskStampMultipolygon(slices[si][0][0], slices[si][0][1], radiusM, refLat); if (!stampPoly || !stampPoly.length) { logBrushGeom('warn', 'eraser_stamp_buffer_null', { chunkIndex: si, pathLen: slices[si].length, radiusM }); continue; } masterPolygons = pc.difference(masterPolygons, stampPoly); if (!masterPolygons.length) break; } } else { let stampPoly = stampWorkPath.length >= 2 ? (brushUsesCircularBuffer() ? pc.bufferPath(stampWorkPath, radiusM, quadSeg) : buildPolylineStampUnionMultipolygon(pc, stampWorkPath, radiusM, refLat, { reason: 'eraser_main' })) : diskStampMultipolygon(stampWorkPath[0][0], stampWorkPath[0][1], radiusM, refLat); if (!stampPoly || !stampPoly.length) { logBrushGeom('warn', 'eraser_stamp_buffer_null', { pathLen: centerLineLonLat.length, radiusM }); return; } masterPolygons = pc.difference(masterPolygons, stampPoly); } } else { let stampPoly = buildAnyBrushStrokeStampMultipolygon(pc, stampWorkPath, radiusM, refLat, { reason: 'brush_main' }); if (!stampPoly || !stampPoly.length) { logBrushGeom('warn', 'brush_stamp_buffer_null', { pathLen: centerLineLonLat.length, radiusM, reason: 'bufferPath_returned_null_or_empty' }); logToUI('Brush: stamp build failed. Set localStorage.wmeCandyPaintDebug=1 for [CandyPaint][brushGeom] details.', true); return; } logBrushGeom('trace', 'brush_stamp_built', { stamp: brushGeomSnapshot(stampPoly) }); logBrushGeom('trace', 'brush_stamp_simplified', { stamp: brushGeomSnapshot(stampPoly) }); const masterSnapBeforeBool = brushGeomSnapshot(masterPolygons.length ? masterPolygons : null); masterPolygons = masterPolygons.length ? pc.union(masterPolygons, stampPoly) : stampPoly; logBrushGeom('trace', 'after_boolean_union', { masterBeforeUnion: masterSnapBeforeBool, masterAfterUnion: brushGeomSnapshot(masterPolygons) }); } } catch (e) { logBrushGeom('error', 'boolean_op_threw', { message: e && e.message ? String(e.message) : String(e), stack: e && e.stack ? String(e.stack) : undefined, isEraser }); logToUI('Brush geometry error. Set localStorage.wmeCandyPaintDebug=1 for [CandyPaint][brushGeom] boolean_op_threw.', true); return; } // Do not RDP the full master after union/difference: epsilonMeters scales with brush R (meters) and would // collapse thin pre-existing strokes and round buffer output into coarse polygons. // Brush: closed-ring RDP / union can collapse multi-loop strokes; union could return empty. Retry with minimal simplification. if (!isEraser && isMultipolyEmptyOrInvalid(masterPolygons) && masterBeforeBrush != null) { logBrushGeom('warn', 'brush_empty_invalid_after_main_path', { master: brushGeomSnapshot(masterPolygons), masterBefore: brushGeomSnapshot(masterBeforeBrush) }); masterPolygons = cloneMasterPolygons(masterBeforeBrush); try { const stampPoly = buildAnyBrushStrokeStampMultipolygon( pc, centerLineLonLat, radiusM, refLat, { reason: 'brush_empty_fallback' } ); if (stampPoly && stampPoly.length) { masterPolygons = masterBeforeBrush.length ? pc.union(masterBeforeBrush, stampPoly) : stampPoly; } else { logBrushGeom('warn', 'brush_fallback_stamp_null', {}); } } catch (e2) { logBrushGeom('error', 'brush_empty_fallback_caught', { message: e2 && e2.message ? String(e2.message) : String(e2) }); } if (isMultipolyEmptyOrInvalid(masterPolygons)) { masterPolygons = cloneMasterPolygons(masterBeforeBrush); logBrushGeom('warn', 'brush_still_invalid_after_empty_fallback', { master: brushGeomSnapshot(masterPolygons) }); logToUI('Brush stroke did not merge (empty result). Try smaller radius or fewer crossings per stroke.', true); } else { logBrushGeom('trace', 'brush_recovered_after_empty_fallback', { master: brushGeomSnapshot(masterPolygons) }); logToUI('Brush: used fallback simplification for a complex loop.', false); } } // Brush: valid multipolygon but partial loss (merged loops / lost holes) — no log until now. if (!isEraser && masterBeforeBrush != null && !isMultipolyEmptyOrInvalid(masterPolygons)) { const beforeWasEmpty = masterBeforeBrush.length === 0; const sumBefore = sumOuterRingAreaDeg(masterBeforeBrush); const sumAfter = sumOuterRingAreaDeg(masterPolygons); const ringsBefore = totalRingCount(masterBeforeBrush); const ringsAfter = totalRingCount(masterPolygons); if (brushGeometryRegression(masterBeforeBrush, masterPolygons, beforeWasEmpty)) { logBrushGeom('warn', 'brush_regression_detected', { sumBefore, sumAfter, ringsBefore, ringsAfter, partsBefore: masterBeforeBrush.length, partsAfter: masterPolygons.length, sharpTurns, complexStroke, ratio: sumBefore > 1e-12 ? sumAfter / sumBefore : null }); logToUI('Brush: complex stroke — reduced simplification to preserve loops.', false); masterPolygons = cloneMasterPolygons(masterBeforeBrush); try { const stampPoly = buildAnyBrushStrokeStampMultipolygon( pc, centerLineLonLat, radiusM, refLat, { reason: 'brush_regression_retry' } ); if (stampPoly && stampPoly.length) { masterPolygons = masterBeforeBrush.length ? pc.union(masterBeforeBrush, stampPoly) : stampPoly; } else { logBrushGeom('warn', 'brush_regression_retry_stamp_null', {}); } } catch (e3) { logBrushGeom('error', 'brush_regression_retry_caught', { message: e3 && e3.message ? String(e3.message) : String(e3) }); } if (brushGeometryRegression(masterBeforeBrush, masterPolygons, beforeWasEmpty)) { masterPolygons = cloneMasterPolygons(masterBeforeBrush); try { const stampPoly = buildAnyBrushStrokeStampMultipolygon( pc, centerLineLonLat, radiusM, refLat, { reason: 'brush_regression_final' } ); if (stampPoly && stampPoly.length) masterPolygons = masterBeforeBrush.length ? pc.union(masterBeforeBrush, stampPoly) : stampPoly; } catch (e4) { logBrushGeom('error', 'brush_regression_final_caught', { message: e4 && e4.message ? String(e4.message) : String(e4) }); } if (isMultipolyEmptyOrInvalid(masterPolygons)) { masterPolygons = cloneMasterPolygons(masterBeforeBrush); logBrushGeom('warn', 'brush_regression_final_invalid', { master: brushGeomSnapshot(masterPolygons) }); logToUI('Brush could not preserve geometry after retry.', true); } else { logBrushGeom('trace', 'brush_regression_recovered_unsimplified', { master: brushGeomSnapshot(masterPolygons) }); } } } } if (!isEraser) { const fin = brushGeomSnapshot(masterPolygons); logBrushGeom(fin.invalid && centerLineLonLat.length >= 2 ? 'warn' : 'trace', 'stroke_end', { fin }); if (fin.invalid && centerLineLonLat.length >= 2) { logToUI('Brush: no valid polygon after stroke. Set localStorage.wmeCandyPaintDebug=1 for [CandyPaint][brushGeom] stroke_end.', true); } } else { logBrushGeom('trace', 'stroke_end', { isEraser: true, fin: brushGeomSnapshot(masterPolygons) }); } const afterBool = masterPolygons.length; alignShapeLinksToMaster(); if (afterBool === prevCount) { for (let i = 0; i < prevCount; i++) { if (prevLinks[i]) shapePlaceLinks[i] = prevLinks[i]; } } else { reconcileLinksAfterPartCountChange(prevLinks, prevCount); } } finally { flushBrushGeometryToActiveLayer(); } } /** Merge touching parts after a stroke; pairwise unions avoid one huge linear union (stack depth). */ function coalesceMasterPartsAfterBrushStroke(pc) { if (masterPolygons.length <= 1) return; const linkedIdx = []; for (let i = 0; i < shapePlaceLinks.length; i++) { if (shapePlaceLinks[i] && shapePlaceLinks[i].venueId != null) linkedIdx.push(i); } if (linkedIdx.length > 1) return; const savedLink = linkedIdx.length === 1 ? cloneLinkEntry(shapePlaceLinks[linkedIdx[0]]) : null; if (masterPolygons.length > 32) return; try { let layer = masterPolygons.slice(); let pass = 0; while (layer.length > 1) { if (pass >= BRUSH_COALESCE_MAX_PASSES) { logBrushGeom('warn', 'coalesce_max_passes', { pass, partCount: layer.length }); break; } pass++; const partCountBefore = layer.length; const next = []; for (let i = 0; i < layer.length; i += 2) { if (i + 1 < layer.length) { const u = pc.union([layer[i]], [layer[i + 1]]); for (let p = 0; p < u.length; p++) next.push(u[p]); } else { next.push(layer[i]); } } layer = next; if (layer.length >= partCountBefore) { logBrushGeom('trace', 'coalesce_stagnation_exit', { pass, partCount: layer.length }); break; } } masterPolygons = layer; alignShapeLinksToMaster(); if (savedLink && masterPolygons.length === 1) { shapePlaceLinks[0] = cloneLinkEntry(savedLink); rebalanceVenueLinkActives({ layer: getActiveUserLayer(), shapeIndex: 0 }); } } catch (e) { /* ignore */ } flushBrushGeometryToActiveLayer(); } /** Commits the full stroke with high-quality disk unions + boolean ops (runs once on mouse-up). */ function flushBrushStroke() { const pc = getGeometryEngine(); if (!brushStrokeSamples.length) return; if (!pc) { brushStrokeSamples = []; return; } const radiusM = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || 12)); const path = brushStrokeSamples.slice(); commitBeforeChange(brushIsEraser ? 'eraser' : 'brush'); applyBrushGeometryOnce(path, radiusM, brushIsEraser, !brushIsEraser); // Coalescing unions parts back together, which will "round" crisp shapes like rectangles. // Only do this after erasing where fragments are expected. if (brushIsEraser) coalesceMasterPartsAfterBrushStroke(pc); brushStrokeSamples = []; } function endBrushPainting() { if (!brushPainting) return; brushPainting = false; flushBrushStroke(); requestRender(); updateTrackerHUD(); } /** GeoJSON Polygon (lon/lat) → one Multipolygon part for jsts: one polygon with one or more rings. */ function sdkPolygonToClippingPart(poly) { if (!poly || poly.type !== "Polygon" || !poly.coordinates || !poly.coordinates.length) return null; return [poly.coordinates.map(ring => ring.map(([lon, lat]) => [lon, lat]))]; } /** SDK Polygon or MultiPolygon → array of Candy parts (each part = array of rings). */ function sdkGeometryToClippingParts(geometry) { if (!geometry) return []; if (geometry.type === 'Polygon' && geometry.coordinates && geometry.coordinates.length) { const one = sdkPolygonToClippingPart(geometry); return one ? [one] : []; } if (geometry.type === 'MultiPolygon' && geometry.coordinates && geometry.coordinates.length) { const out = []; for (let p = 0; p < geometry.coordinates.length; p++) { const polyCoords = geometry.coordinates[p]; if (!polyCoords || !polyCoords.length) continue; out.push(polyCoords.map(ring => ring.map(([lon, lat]) => [lon, lat]))); } return out; } return []; } /** One polygon part from masterPolygons (array of rings) → SDK Polygon GeoJSON. */ function masterPolyPartToSdkPolygon(polyPart) { const coordinates = polyPart.map(ring => { let r = ring.map(([lon, lat]) => [lon, lat]); if (r.length && (r[0][0] !== r[r.length - 1][0] || r[0][1] !== r[r.length - 1][1])) r = [...r, r[0]]; return r; }); return { type: "Polygon", coordinates }; } function getSelectedAreaVenues() { const selected = []; if (!wmeSDK || !wmeSDK.Editing || !wmeSDK.DataModel || !wmeSDK.DataModel.Venues) return selected; const sel = wmeSDK.Editing.getSelection(); if (!sel || sel.objectType !== "venue" || !sel.ids || !sel.ids.length) return selected; sel.ids.forEach(id => { const venue = wmeSDK.DataModel.Venues.getById({ venueId: id }); if (!venue) return; const g = venue.geometry; if (!g) return; if (g.type === "Point") return; if (g.type === "Polygon" && g.coordinates && g.coordinates.length) selected.push(venue); else if (g.type === "MultiPolygon" && g.coordinates && g.coordinates.length) selected.push(venue); }); return selected; } function normalizeVenueIdForSdk(id) { if (typeof id === 'number' && Number.isFinite(id)) return id; if (typeof id === 'string' && id.trim() !== '') { const n = Number(id); if (Number.isFinite(n) && String(n) === id.trim()) return n; } return id; } function venueIdsMatch(a, b) { if (a == null || b == null) return false; return String(a) === String(b); } /** * Shape–place link: { venueId, active?, broken?, isInvalid?, splitGroupId? }. * Legacy { venueId } ⇒ active true, not broken. */ function normalizeShapeLink(raw) { if (!raw || raw.venueId == null) return null; const venueId = normalizeVenueIdForSdk(raw.venueId); const broken = raw.broken === true; const active = raw.active === false ? false : true; const o = { venueId, active, broken }; if (raw.splitGroupId) o.splitGroupId = raw.splitGroupId; if (raw.isInvalid) o.isInvalid = true; return o; } function cloneLinkEntry(l) { if (!l || l.venueId == null) return null; const o = { venueId: normalizeVenueIdForSdk(l.venueId), active: l.active === false ? false : true, broken: l.broken === true }; if (l.splitGroupId) o.splitGroupId = l.splitGroupId; if (l.isInvalid) o.isInvalid = true; return o; } /** True if Apply should use updateVenue for this link (not addVenue). */ function isActiveNonBrokenLink(link) { const L = normalizeShapeLink(link); return !!(L && L.venueId != null && L.active !== false && L.broken !== true); } /** Apply blocks only on active, non-broken link problems (§1.9). */ function linkBlocksApplyValidation(link) { return isActiveNonBrokenLink(link); } /** * One active (non-broken) link per venue globally. Optional preferred { layer, shapeIndex } wins for that venue. */ function rebalanceVenueLinkActives(preferred) { const prefLayer = preferred && preferred.layer; const prefIdx = preferred && typeof preferred.shapeIndex === 'number' ? preferred.shapeIndex : -1; const byVenue = new Map(); for (let li = 0; li < userLayers.length; li++) { const layer = userLayers[li]; if (layer.isPlaces) continue; const links = layer.links; for (let i = 0; i < links.length; i++) { const L = normalizeShapeLink(links[i]); if (!L || L.venueId == null || L.broken === true) continue; const k = String(normalizeVenueIdForSdk(L.venueId)); if (!byVenue.has(k)) byVenue.set(k, []); byVenue.get(k).push({ layer, i }); } } for (const [, entries] of byVenue) { if (!entries.length) continue; let winner = entries.find(e => prefLayer && e.layer === prefLayer && e.i === prefIdx) || entries[0]; for (let e = 0; e < entries.length; e++) { const ent = entries[e]; const L = normalizeShapeLink(ent.layer.links[ent.i]); const isWin = ent.layer === winner.layer && ent.i === winner.i; ent.layer.links[ent.i] = { ...L, active: isWin, broken: false }; } } syncActiveLayerAliases(); } /** * After updateVenue(V): other shapes with active non-broken link to V → broken (winner keeps link). * @param {*} updatedVenueId * @param {object|null} winnerLayer user layer object (not clone) * @param {number} winnerShapeIndex index in layer.polygons / layer.links */ function breakCompetingVenueLinksForVenue(updatedVenueId, winnerLayer, winnerShapeIndex) { const norm = normalizeVenueIdForSdk(updatedVenueId); for (let li = 0; li < userLayers.length; li++) { const layer = userLayers[li]; if (layer.isPlaces) continue; for (let i = 0; i < layer.links.length; i++) { if (winnerLayer && layer === winnerLayer && i === winnerShapeIndex) continue; const L = normalizeShapeLink(layer.links[i]); if (!L || L.venueId == null) continue; if (!venueIdsMatch(L.venueId, norm)) continue; if (L.broken === true) continue; if (L.active === false) continue; layer.links[i] = { ...L, active: false, broken: true }; } } syncActiveLayerAliases(); } function alignShapeLinksToMaster() { while (shapePlaceLinks.length < masterPolygons.length) shapePlaceLinks.push(null); shapePlaceLinks.length = masterPolygons.length; } function generateLayerId() { return 'L' + Date.now().toString(36) + Math.random().toString(36).slice(2, 9); } function generateShapeId() { return 'S' + Date.now().toString(36) + Math.random().toString(36).slice(2, 9); } function migrateLegacyLayerName(name) { if (name === 'Master' || name === 'master') return DEFAULT_SHAPE_LAYER_NAME; return name || DEFAULT_SHAPE_LAYER_NAME; } function getUserLayerById(id) { return userLayers.find(l => l.id === id) || null; } function getActiveUserLayer() { return activeLayerId ? getUserLayerById(activeLayerId) : null; } function getActiveUserLayerShapeCount() { const L = getActiveUserLayer(); return L && L.polygons ? L.polygons.length : 0; } function hasAnyUserLayerPolygons() { for (let i = 0; i < userLayers.length; i++) { if (userLayers[i].polygons && userLayers[i].polygons.length) return true; } return false; } function syncActiveLayerAliases() { const L = getActiveUserLayer(); if (!L) { masterPolygons = []; shapePlaceLinks = []; return; } masterPolygons = L.polygons; shapePlaceLinks = L.links; } function ensureShapeIdsForLayer(layer) { if (!layer.shapeIds) layer.shapeIds = []; while (layer.shapeIds.length < layer.polygons.length) layer.shapeIds.push(generateShapeId()); layer.shapeIds.length = layer.polygons.length; } function createUserLayer(nameOpt) { const layer = { id: generateLayerId(), isPlaces: false, visible: true, name: migrateLegacyLayerName(nameOpt != null ? nameOpt : DEFAULT_SHAPE_LAYER_NAME), polygons: [], links: [], shapeIds: [] }; userLayers.push(layer); ensureShapeIdsForLayer(layer); return layer; } function validateActiveLayerId() { if (!activeLayerId || !getUserLayerById(activeLayerId)) { activeLayerId = userLayers.length ? userLayers[0].id : null; } } function flushBrushGeometryToActiveLayer() { const L = getActiveUserLayer(); if (!L) return; L.polygons = masterPolygons; L.links = shapePlaceLinks; masterPolygons = L.polygons; shapePlaceLinks = L.links; ensureShapeIdsForLayer(L); scheduleDocumentSave(); } function getActiveLayerDisplayName() { const L = getActiveUserLayer(); return L ? L.name : DEFAULT_SHAPE_LAYER_NAME; } const PAINT_HISTORY_CAP = 200; let paintUndoStack = []; let paintRedoStack = []; let paintHistoryApplying = false; const HISTORY_TAG_TO_LABEL = { brush: 'Brush stroke', eraser: 'Eraser', ingest: 'Ingest', clear: 'Clear canvas', layer_merge: 'Layer merge', layer_add: 'Add layer', layer_del: 'Delete layer', layer_dup: 'Duplicate layer', layer_reorder: 'Reorder layer', layer_rename: 'Rename layer', layer_active: 'Active layer', layer_visibility: 'Layer visibility', shape_dup: 'Duplicate shape', shape_del: 'Delete shapes', shape_move: 'Move shape', shape_reorder: 'Reorder shapes', unlink: 'Unlink', link: 'Link', link_activate: 'Activate link', pop_draft: 'Pop to draft', draft_commit: 'Draft commit', draft_cancel_restore: 'Restore shape', move: 'Move shapes', edit: 'Edit' }; function historyLabelForTag(tag, explicitLabel) { if (explicitLabel) return explicitLabel; if (tag && HISTORY_TAG_TO_LABEL[tag]) return HISTORY_TAG_TO_LABEL[tag]; return HISTORY_TAG_TO_LABEL.edit; } function paintHistoryEntrySnapshot(entry) { if (!entry) return null; if (entry.snapshot) return entry.snapshot; return entry; } function trimUndoStackToCap() { while (paintUndoStack.length > PAINT_HISTORY_CAP) paintUndoStack.shift(); } function cloneShapePlaceLinks(src) { if (!src || !src.length) return []; return src.map(l => cloneLinkEntry(l)); } function saveDocumentStateNow() { try { localStorage.setItem(LS_DOCUMENT_KEY, JSON.stringify({ v: DOCUMENT_STATE_VERSION, activeLayerId, userLayers: userLayers.map(l => ({ id: l.id, name: l.name, visible: l.visible !== false, polygons: cloneMasterPolygons(l.polygons), links: cloneShapePlaceLinks(l.links), shapeIds: (l.shapeIds || []).slice() })) })); } catch (e) { /* ignore quota / privacy mode */ } } function scheduleDocumentSave() { if (documentSaveTimer) clearTimeout(documentSaveTimer); documentSaveTimer = setTimeout(() => { documentSaveTimer = null; saveDocumentStateNow(); }, 320); } function loadDocumentState() { try { const raw = localStorage.getItem(LS_DOCUMENT_KEY); if (!raw) return false; const data = JSON.parse(raw); if ((data.v !== DOCUMENT_STATE_VERSION && data.v !== 1) || !Array.isArray(data.userLayers) || !data.userLayers.length) return false; userLayers = data.userLayers.map(l => ({ id: l.id || generateLayerId(), isPlaces: false, visible: l.visible !== false, name: migrateLegacyLayerName(l.name || DEFAULT_SHAPE_LAYER_NAME), polygons: cloneMasterPolygons(l.polygons), links: (l.links || []).map(x => normalizeShapeLink(x)), shapeIds: Array.isArray(l.shapeIds) ? l.shapeIds.slice() : [] })); userLayers.forEach(ensureShapeIdsForLayer); activeLayerId = data.activeLayerId; validateActiveLayerId(); return true; } catch (e) { return false; } } function initPaintDocument() { placesPreviewLayer.polygons = []; placesPreviewLayer.links = []; placesPreviewLayer.shapeIds = []; if (!loadDocumentState()) { userLayers = []; activeLayerId = createUserLayer(DEFAULT_SHAPE_LAYER_NAME).id; } syncActiveLayerAliases(); } /** Cached polygon places in viewport (same source as Places preview + list UI). */ let viewportPlacesVenuesCache = []; function getMapExtentLonLat() { const map = getHostMap(); if (!map || typeof map.getExtent !== 'function') return null; const ext = map.getExtent(); const proj = getMapProjection(); const OL = window.OpenLayers; if (!ext || !proj || !OL?.Geometry?.Point || !OL?.Projection) return null; try { const p4326 = new OL.Projection('EPSG:4326'); const pts = [ new OL.Geometry.Point(ext.left, ext.bottom), new OL.Geometry.Point(ext.right, ext.bottom), new OL.Geometry.Point(ext.right, ext.top), new OL.Geometry.Point(ext.left, ext.top) ]; let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity; for (let i = 0; i < pts.length; i++) { pts[i].transform(proj, p4326); const x = pts[i].x, y = pts[i].y; minLon = Math.min(minLon, x); maxLon = Math.max(maxLon, x); minLat = Math.min(minLat, y); maxLat = Math.max(maxLat, y); } return { minLon, minLat, maxLon, maxLat }; } catch (e) { return null; } } function ringLonLatBounds(ring) { let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity; if (!ring || !ring.length) return { minLon, minLat, maxLon, maxLat }; for (let i = 0; i < ring.length; i++) { const lon = ring[i][0], lat = ring[i][1]; minLon = Math.min(minLon, lon); maxLon = Math.max(maxLon, lon); minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat); } return { minLon, minLat, maxLon, maxLat }; } function bboxesOverlap(a, b) { return a.minLon <= b.maxLon && a.maxLon >= b.minLon && a.minLat <= b.maxLat && a.maxLat >= b.minLat; } /** Union bbox of MultiPolygon outer rings (lon/lat). */ function multiPolygonLonLatBounds(g) { if (!g || g.type !== 'MultiPolygon' || !g.coordinates || !g.coordinates.length) return null; let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity; for (let p = 0; p < g.coordinates.length; p++) { const poly = g.coordinates[p]; if (!poly || !poly[0]) continue; const rb = ringLonLatBounds(poly[0]); minLon = Math.min(minLon, rb.minLon); maxLon = Math.max(maxLon, rb.maxLon); minLat = Math.min(minLat, rb.minLat); maxLat = Math.max(maxLat, rb.maxLat); } if (!Number.isFinite(minLon)) return null; return { minLon, minLat, maxLon, maxLat }; } /** Polygon area venues whose bounds intersect the current map view (WME native model). */ function getViewportPolygonVenues() { const bbox = getMapExtentLonLat(); if (!bbox) return []; const objs = window.W?.model?.venues?.objects; if (!objs) return []; const out = []; for (const k in objs) { if (!Object.prototype.hasOwnProperty.call(objs, k)) continue; const v = objs[k]; const g = v?.geometry; if (!g || !g.coordinates?.length) continue; let rb = null; if (g.type === 'Polygon') rb = ringLonLatBounds(g.coordinates[0]); else if (g.type === 'MultiPolygon') rb = multiPolygonLonLatBounds(g); if (!rb || !bboxesOverlap(rb, bbox)) continue; const id = v.attributes?.id != null ? v.attributes.id : (v.id != null ? v.id : k); const name = v.attributes?.name || v.name || String(id); out.push({ id, geometry: g, name }); } return out; } function rebuildPlacesPreviewPolygonsFromVenues(venues) { const flat = []; for (let i = 0; i < venues.length; i++) { const parts = sdkGeometryToClippingParts(venues[i].geometry); for (let j = 0; j < parts.length; j++) flat.push(parts[j]); } placesPreviewLayer.polygons = flat; placesPreviewLayer.links = new Array(flat.length).fill(null); ensureShapeIdsForLayer(placesPreviewLayer); } function refreshPlacesPreviewFromMap() { viewportPlacesVenuesCache = getViewportPolygonVenues(); rebuildPlacesPreviewPolygonsFromVenues(viewportPlacesVenuesCache); } function csShapeKey(layerId, shapeId) { return String(layerId) + ':' + String(shapeId); } function parseShapeKey(key) { const i = key.indexOf(':'); if (i <= 0) return null; return { layerId: key.slice(0, i), shapeId: key.slice(i + 1) }; } function escapeHtmlCs(s) { return String(s) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); } function isCandyPaintUiRoot(node) { return !!(node && node.closest && node.closest('#wme-paint-palette,#wme-cs-layers-panel,#wme-cs-history-panel,#wme-paint-hud,#wme-paint-log,.csl-overflow-backdrop')); } function isCsLayersPanelOpen() { const p = document.getElementById('wme-cs-layers-panel'); return !!(p && !p.classList.contains('csl-hidden')); } function demoteLinkForDuplicate(link) { const L = normalizeShapeLink(link); if (!L || L.venueId == null) return null; return normalizeShapeLink(Object.assign({}, L, { active: false, broken: false })); } function clonePolygonPart(part) { if (!part) return null; return part.map(ring => ring.map(pt => [pt[0], pt[1]])); } function layerLinkBadgeText(layer) { let n = 0, b = 0; const links = layer.links || []; for (let i = 0; i < links.length; i++) { const L = normalizeShapeLink(links[i]); if (!L || L.venueId == null) continue; n++; if (L.broken === true) b++; } if (!n) return ''; return b ? `${n} linked · ${b} broken` : `${n} linked`; } function mergeLonLatBboxes(a, b) { if (!a) return b; if (!b) return a; return { minLon: Math.min(a.minLon, b.minLon), maxLon: Math.max(a.maxLon, b.maxLon), minLat: Math.min(a.minLat, b.minLat), maxLat: Math.max(a.maxLat, b.maxLat) }; } function bboxForPartLonLat(part) { let acc = null; if (!part) return null; for (let r = 0; r < part.length; r++) { const ring = part[r]; if (!ring || !ring.length) continue; acc = mergeLonLatBboxes(acc, ringLonLatBounds(ring)); } return acc; } function zoomMapToLonLatBBox(bbox) { if (!bbox) return; const map = getHostMap(); const OL = window.OpenLayers; const proj = getMapProjection(); if (!map || !OL || !proj || typeof map.zoomToExtent !== 'function') return; try { const p4326 = new OL.Projection('EPSG:4326'); const sw = new OL.Geometry.Point(bbox.minLon, bbox.minLat); const ne = new OL.Geometry.Point(bbox.maxLon, bbox.maxLat); sw.transform(p4326, proj); ne.transform(p4326, proj); const minX = Math.min(sw.x, ne.x); const maxX = Math.max(sw.x, ne.x); const minY = Math.min(sw.y, ne.y); const maxY = Math.max(sw.y, ne.y); const res = typeof map.getResolution === 'function' ? map.getResolution() : 1; const padX = Math.max((maxX - minX) * 0.12, 64 * res); const padY = Math.max((maxY - minY) * 0.12, 64 * res); const bounds = new OL.Bounds(minX - padX, minY - padY, maxX + padX, maxY + padY); map.zoomToExtent(bounds); } catch (e) { /* ignore */ } } function pruneShapeInspectorSelection() { for (const key of [...csLayersSelection]) { const p = parseShapeKey(key); if (!p) { csLayersSelection.delete(key); continue; } const L = getUserLayerById(p.layerId); if (!L || L.isPlaces) { csLayersSelection.delete(key); continue; } ensureShapeIdsForLayer(L); const ix = (L.shapeIds || []).indexOf(p.shapeId); if (ix < 0) csLayersSelection.delete(key); } } function scheduleShapeInspectorRefresh() { if (shapeInspectorRefreshScheduled) return; shapeInspectorRefreshScheduled = true; requestAnimationFrame(() => { shapeInspectorRefreshScheduled = false; renderShapeInspectorList(); updateActiveLayerReadout(); }); } function updateActiveLayerReadout() { const el = document.getElementById('wpo-active-layer-name'); if (el) el.textContent = getActiveLayerDisplayName(); } function setActiveLayerById(id, opts) { if (!id || id === activeLayerId) return; if (!opts || !opts.skipHistory) commitBeforeChange('layer_active'); activeLayerId = id; validateActiveLayerId(); syncActiveLayerAliases(); scheduleDocumentSave(); requestRender(); updateTrackerHUD(); handleSelectionChange(); scheduleShapeInspectorRefresh(); } function swapUserLayersAt(i, j) { if (i < 0 || j < 0 || i >= userLayers.length || j >= userLayers.length || i === j) return; const t = userLayers[i]; userLayers[i] = userLayers[j]; userLayers[j] = t; } function mergeLayerDownAtIndex(topIdx) { if (topIdx <= 0 || topIdx >= userLayers.length) return; commitBeforeChange('layer_merge'); const lower = userLayers[topIdx - 1]; const upper = userLayers[topIdx]; for (let i = 0; i < upper.polygons.length; i++) { lower.polygons.push(clonePolygonPart(upper.polygons[i])); lower.links.push(cloneLinkEntry(upper.links[i])); lower.shapeIds.push(generateShapeId()); } ensureShapeIdsForLayer(lower); const removedId = upper.id; userLayers.splice(topIdx, 1); if (activeLayerId === removedId) activeLayerId = lower.id; validateActiveLayerId(); rebalanceVenueLinkActives(); syncActiveLayerAliases(); alignShapeLinksToMaster(); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); updateTrackerHUD(); logToUI(`Merged layer into "${lower.name}".`, false); } function duplicateUserLayerAtIndex(vi) { if (vi < 0 || vi >= userLayers.length) return; const src = userLayers[vi]; commitBeforeChange('layer_dup'); const copy = createUserLayer((src.name || 'Layer') + ' copy'); for (let i = 0; i < src.polygons.length; i++) { copy.polygons.push(clonePolygonPart(src.polygons[i])); copy.links.push(demoteLinkForDuplicate(src.links[i])); copy.shapeIds.push(generateShapeId()); } ensureShapeIdsForLayer(copy); userLayers.pop(); userLayers.splice(vi + 1, 0, copy); activeLayerId = copy.id; syncActiveLayerAliases(); alignShapeLinksToMaster(); rebalanceVenueLinkActives(); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); updateTrackerHUD(); logToUI(`Duplicated layer "${src.name}".`, false); } function duplicateShapeOnLayer(layer, shapeIndex) { if (!layer || layer.isPlaces || shapeIndex < 0 || shapeIndex >= layer.polygons.length) return; commitBeforeChange('shape_dup'); layer.polygons.splice(shapeIndex + 1, 0, clonePolygonPart(layer.polygons[shapeIndex])); layer.links.splice(shapeIndex + 1, 0, demoteLinkForDuplicate(layer.links[shapeIndex])); layer.shapeIds.splice(shapeIndex + 1, 0, generateShapeId()); ensureShapeIdsForLayer(layer); if (layer.id === activeLayerId) syncActiveLayerAliases(); rebalanceVenueLinkActives(); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); } function deleteShapesBySelectionKeys() { const byLayer = new Map(); for (const key of csLayersSelection) { const p = parseShapeKey(key); if (!p) continue; const L = getUserLayerById(p.layerId); if (!L || L.isPlaces) continue; ensureShapeIdsForLayer(L); const ix = L.shapeIds.indexOf(p.shapeId); if (ix < 0) continue; if (!byLayer.has(L.id)) byLayer.set(L.id, []); byLayer.get(L.id).push(ix); } if (!byLayer.size) return; commitBeforeChange('shape_del'); const layerIds = [...byLayer.keys()]; for (let li = 0; li < layerIds.length; li++) { const lid = layerIds[li]; const L = getUserLayerById(lid); if (!L) continue; const indices = byLayer.get(lid).sort((a, b) => b - a); for (let k = 0; k < indices.length; k++) { const si = indices[k]; L.polygons.splice(si, 1); L.links.splice(si, 1); L.shapeIds.splice(si, 1); } ensureShapeIdsForLayer(L); } csLayersSelection.clear(); syncActiveLayerAliases(); alignShapeLinksToMaster(); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); updateTrackerHUD(); } function unlinkShapesBySelectionKeys() { let willTouch = false; for (const key of [...csLayersSelection]) { const p = parseShapeKey(key); if (!p) continue; const L = getUserLayerById(p.layerId); if (!L || L.isPlaces) continue; const ix = (L.shapeIds || []).indexOf(p.shapeId); if (ix < 0 || !L.links[ix]) continue; willTouch = true; break; } if (!willTouch) return; commitBeforeChange('unlink'); let touched = false; for (const key of [...csLayersSelection]) { const p = parseShapeKey(key); if (!p) continue; const L = getUserLayerById(p.layerId); if (!L || L.isPlaces) continue; const ix = (L.shapeIds || []).indexOf(p.shapeId); if (ix < 0) continue; if (L.links[ix]) { L.links[ix] = null; touched = true; } } if (!touched) return; syncActiveLayerAliases(); alignShapeLinksToMaster(); rebalanceVenueLinkActives(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); } function zoomToSelectionKeysBbox() { let acc = null; for (const key of csLayersSelection) { const p = parseShapeKey(key); if (!p) continue; const L = getUserLayerById(p.layerId); if (!L || L.isPlaces) continue; const ix = (L.shapeIds || []).indexOf(p.shapeId); if (ix < 0 || !L.polygons[ix]) continue; acc = mergeLonLatBboxes(acc, bboxForPartLonLat(L.polygons[ix])); } if (acc) zoomMapToLonLatBBox(acc); } function moveShapeToLayer(fromLayer, shapeIndex, targetLayerId) { if (!fromLayer || fromLayer.isPlaces || shapeIndex < 0 || shapeIndex >= fromLayer.polygons.length) return; const tgt = getUserLayerById(targetLayerId); if (!tgt || tgt.isPlaces || tgt.id === fromLayer.id) return; commitBeforeChange('shape_move'); const part = fromLayer.polygons.splice(shapeIndex, 1)[0]; const link = fromLayer.links.splice(shapeIndex, 1)[0]; fromLayer.shapeIds.splice(shapeIndex, 1); tgt.polygons.push(part); tgt.links.push(cloneLinkEntry(link)); tgt.shapeIds.push(generateShapeId()); ensureShapeIdsForLayer(fromLayer); ensureShapeIdsForLayer(tgt); syncActiveLayerAliases(); alignShapeLinksToMaster(); rebalanceVenueLinkActives(); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); } function swapShapesInLayer(layer, i, j) { if (!layer || i === j || i < 0 || j < 0 || i >= layer.polygons.length || j >= layer.polygons.length) return; commitBeforeChange('shape_reorder'); const tp = layer.polygons[i]; layer.polygons[i] = layer.polygons[j]; layer.polygons[j] = tp; const tl = layer.links[i]; layer.links[i] = layer.links[j]; layer.links[j] = tl; const ts = layer.shapeIds[i]; layer.shapeIds[i] = layer.shapeIds[j]; layer.shapeIds[j] = ts; if (layer.id === activeLayerId) syncActiveLayerAliases(); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); } function tryActivateShapeLinkOnLayer(layer, shapeIndex) { if (!layer || layer.isPlaces || shapeIndex < 0 || shapeIndex >= layer.polygons.length) return; const cur = normalizeShapeLink(layer.links[shapeIndex]); if (!cur || cur.venueId == null) { logToUI('Shape has no place link to activate.', true); return; } commitBeforeChange('link_activate'); layer.links[shapeIndex] = Object.assign({}, cur, { active: true, broken: false }); rebalanceVenueLinkActives({ layer, shapeIndex }); scheduleDocumentSave(); requestRender(); handleSelectionChange(); scheduleShapeInspectorRefresh(); logToUI(`Active link set on shape in "${layer.name}".`, false); } function deleteUserLayerById(layerId, skipConfirm) { const vi = userLayers.findIndex(l => l.id === layerId); if (vi < 0) return; if (userLayers.length <= 1) { logToUI('Cannot remove the last user layer.', true); return; } const L = userLayers[vi]; if (L.polygons && L.polygons.length && !skipConfirm) { if (!window.confirm(`Delete layer "${L.name}" with ${L.polygons.length} shape(s)? You can undo from the toolbar.`)) return; } commitBeforeChange('layer_del'); userLayers.splice(vi, 1); if (activeLayerId === layerId) { activeLayerId = userLayers[Math.max(0, vi - 1)].id; } validateActiveLayerId(); syncActiveLayerAliases(); alignShapeLinksToMaster(); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); updateTrackerHUD(); logToUI('Layer deleted.', false); } function renderShapeInspectorList() { const root = document.getElementById('wpo-cs-layers-list-root'); if (!root) return; const filterRaw = (document.getElementById('csl-filter-input') && document.getElementById('csl-filter-input').value) || csLayersNameFilter || ''; const fl = filterRaw.trim().toLowerCase(); let html = ''; const n = userLayers.length; for (let vi = n - 1; vi >= 0; vi--) { const layer = userLayers[vi]; if (fl && !(String(layer.name || '').toLowerCase().includes(fl))) continue; const isActive = layer.id === activeLayerId; const collapsed = !!csLayersCollapsed[layer.id]; const badge = layerLinkBadgeText(layer); const headCls = 'csl-layer-head' + (isActive ? ' csl-active-layer' : ''); html += `<div class="csl-layer-block" data-layer-id="${escapeHtmlCs(layer.id)}">`; html += `<div class="${headCls}" tabindex="0" data-csl-focus="layer" data-csl-drop-layer="${vi}" data-layer-id="${escapeHtmlCs(layer.id)}">`; html += `<span class="csl-drag-h" draggable="true" data-csl-drag-layer="${vi}" title="Drag to reorder" aria-label="Drag layer row">⠿</span>`; html += `<button type="button" class="csl-icon-btn" data-csl-eye="${vi}" aria-label="${layer.visible === false ? 'Show layer' : 'Hide layer'}">${layer.visible === false ? '○' : '●'}</button>`; html += `<span class="csl-name" data-csl-rename="${vi}" title="Double-click to rename">${escapeHtmlCs(layer.name || 'Layer')}</span>`; if (badge) html += `<span class="csl-badge" title="Link summary">${escapeHtmlCs(badge)}</span>`; html += `<button type="button" class="csl-icon-btn" data-csl-layer-up="${vi}" ${vi >= n - 1 ? 'disabled' : ''} aria-label="Move layer up">↑</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-layer-dn="${vi}" ${vi <= 0 ? 'disabled' : ''} aria-label="Move layer down">↓</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-dup-layer="${vi}" aria-label="Duplicate layer">Dup</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-merge="${vi}" ${vi <= 0 ? 'disabled' : ''} title="Merge into layer below (toward map background)" aria-label="Merge down">M↓</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-del-layer="${vi}" aria-label="Delete layer">🗑</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-collapse="${vi}" aria-label="${collapsed ? 'Expand' : 'Collapse'}">${collapsed ? '▶' : '▼'}</button>`; html += `</div>`; if (!collapsed) { const polys = layer.polygons || []; ensureShapeIdsForLayer(layer); for (let si = 0; si < polys.length; si++) { const sid = layer.shapeIds[si]; const key = csShapeKey(layer.id, sid); const sel = csLayersSelection.has(key); const Ln = normalizeShapeLink(layer.links[si]); let pill = '—'; if (Ln && Ln.venueId != null) { if (Ln.isInvalid && isActiveNonBrokenLink(Ln)) pill = '! ' + String(Ln.venueId).slice(-4); else if (Ln.broken === true) pill = 'B·' + String(Ln.venueId).slice(-4); else if (Ln.active === false) pill = 'I·' + String(Ln.venueId).slice(-4); else pill = 'U·' + String(Ln.venueId).slice(-4); } html += `<div class="csl-shape-row${sel ? ' csl-sel' : ''}" tabindex="0" data-csl-focus="shape" data-shape-key="${escapeHtmlCs(key)}" data-layer-id="${escapeHtmlCs(layer.id)}" data-shape-idx="${si}">`; html += `<span class="csl-shape-lab">Shape ${si + 1}</span>`; html += `<span class="csl-link-pill">${escapeHtmlCs(pill)}</span>`; html += `<button type="button" class="csl-icon-btn" data-csl-zoom-shape="${escapeHtmlCs(key)}" aria-label="Zoom to shape">⌖</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-dup-shape="${escapeHtmlCs(key)}" aria-label="Duplicate shape">Dup</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-shape-up="${escapeHtmlCs(key)}" ${si <= 0 ? 'disabled' : ''} aria-label="Raise shape">↑</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-shape-dn="${escapeHtmlCs(key)}" ${si >= polys.length - 1 ? 'disabled' : ''} aria-label="Lower shape">↓</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-act-shape="${escapeHtmlCs(key)}" aria-label="Activate link">Act</button>`; html += `<button type="button" class="csl-icon-btn" data-csl-del-shape="${escapeHtmlCs(key)}" aria-label="Delete shape">×</button>`; html += `</div>`; } const moveOpts = '<option value="">— layer —</option>' + userLayers.filter(u => !u.isPlaces && u.id !== layer.id) .map(u => `<option value="${escapeHtmlCs(u.id)}">${escapeHtmlCs(u.name)}</option>`).join(''); if (polys.length && moveOpts) { html += `<div style="padding:2px 8px 6px;font-size:9px;color:#6c757d;">Move selected shape:</div>`; for (let si = 0; si < polys.length; si++) { const sid = layer.shapeIds[si]; const key = csShapeKey(layer.id, sid); html += `<div style="display:flex;align-items:center;gap:4px;padding:0 8px 4px 14px;">`; html += `<span style="font-size:9px;">#${si + 1}</span>`; html += `<select class="csl-move-select" data-csl-move="${escapeHtmlCs(key)}" aria-label="Move shape to layer">${moveOpts}</select>`; html += `</div>`; } } } html += `</div>`; } html += `<div class="csl-places-section">`; html += `<div class="csl-places-h">Places in view (preview, not applied)</div>`; const venues = viewportPlacesVenuesCache || []; if (!venues.length) { html += `<div class="wpo-places-empty">No polygon or multipolygon places in view</div>`; } else { for (let i = 0; i < venues.length; i++) { const v = venues[i]; const labelText = String(v.name != null ? v.name : v.id); const vid = v.id; html += `<div class="wpo-places-row">`; html += `<span class="wpo-places-row-label" title="${escapeHtmlCs(labelText)}">${escapeHtmlCs(labelText)}</span>`; html += `<button type="button" class="wpo-btn wpo-btn-action" data-csl-ingest="${escapeHtmlCs(String(vid))}">Ingest</button>`; html += `</div>`; } } html += `</div>`; root.innerHTML = html; } function persistCsLayersPanelPos(panel) { try { const r = panel.getBoundingClientRect(); localStorage.setItem(LS_CS_LAYERS_POS, JSON.stringify({ left: r.left, top: r.top })); } catch (e) { /* ignore */ } } function loadCsLayersPanelPos(panel) { try { const raw = localStorage.getItem(LS_CS_LAYERS_POS); if (!raw) return; const o = JSON.parse(raw); if (typeof o.left === 'number' && typeof o.top === 'number') { panel.style.left = Math.max(0, o.left) + 'px'; panel.style.top = Math.max(0, o.top) + 'px'; panel.style.right = 'auto'; } } catch (e) { /* ignore */ } } function toggleCsLayersPanel() { const p = document.getElementById('wme-cs-layers-panel'); if (!p) return; p.classList.toggle('csl-hidden'); const open = !p.classList.contains('csl-hidden'); try { localStorage.setItem(LS_CS_LAYERS_OPEN, open ? '1' : '0'); } catch (e2) { /* ignore */ } if (open) scheduleShapeInspectorRefresh(); } function bindCsLayersPanelOnce() { if (csLayersPanelEventsBound) return; csLayersPanelEventsBound = true; document.addEventListener('pointermove', onDocumentCsLayersMapHover, true); document.addEventListener('keydown', onDocumentCsLayersDelKeydown, true); document.addEventListener('dragover', (e) => { if (e.target && e.target.closest && e.target.closest('[data-csl-drop-layer]')) e.preventDefault(); }, true); document.addEventListener('dragenter', (e) => { if (e.target && e.target.closest && e.target.closest('[data-csl-drop-layer]')) e.preventDefault(); }, true); document.addEventListener('drop', onCsLayersLayerDrop, true); } let csLayersPendingHoverX = 0, csLayersPendingHoverY = 0; function onDocumentCsLayersMapHover(e) { if (!isCsLayersPanelOpen() || appState.tool !== 'pan' || appState.isDraftActive) { if (csLayersMapHoverShapeKey) { csLayersMapHoverShapeKey = null; requestRender(); } return; } if (isCandyPaintUiRoot(e.target)) return; csLayersPendingHoverX = e.clientX; csLayersPendingHoverY = e.clientY; if (csLayersMapHoverRaf != null) return; csLayersMapHoverRaf = requestAnimationFrame(() => { csLayersMapHoverRaf = null; const L = getActiveUserLayer(); if (!L || !ctx) { csLayersMapHoverShapeKey = null; requestRender(); return; } const idx = pickMasterPolygonAt(csLayersPendingHoverX, csLayersPendingHoverY); if (idx < 0) { csLayersMapHoverShapeKey = null; } else { ensureShapeIdsForLayer(L); csLayersMapHoverShapeKey = csShapeKey(L.id, L.shapeIds[idx]); } requestRender(); }); } function onDocumentCsLayersDelKeydown(e) { if (e.key !== 'Delete' && e.key !== 'Backspace') return; const t = e.target; if (isTextEditingTarget(t)) return; if (!isCsLayersPanelOpen()) return; if (!t || !t.closest || !t.closest('#wme-cs-layers-panel')) return; if (csLayersFocusedKind === 'shape' && csLayersFocusedShapeKey) { e.preventDefault(); e.stopPropagation(); csLayersSelection.clear(); csLayersSelection.add(csLayersFocusedShapeKey); deleteShapesBySelectionKeys(); return; } if (csLayersFocusedKind === 'layer' && csLayersFocusedLayerId) { e.preventDefault(); e.stopPropagation(); deleteUserLayerById(csLayersFocusedLayerId, false); } } function isTextEditingTarget(el) { if (!el || el.nodeType !== 1) return false; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; return !!el.isContentEditable; } function onCsLayersLayerDrop(e) { const dropEl = e.target && e.target.closest ? e.target.closest('[data-csl-drop-layer]') : null; if (!dropEl) return; e.preventDefault(); const toVi = parseInt(dropEl.getAttribute('data-csl-drop-layer'), 10); const fromVi = csLayersDragLayerIndex; csLayersDragLayerIndex = -1; if (!Number.isFinite(fromVi) || !Number.isFinite(toVi) || fromVi === toVi) return; commitBeforeChange('layer_reorder'); const moved = userLayers.splice(fromVi, 1)[0]; let insertAt = toVi; if (fromVi < toVi) insertAt = toVi - 1; userLayers.splice(insertAt, 0, moved); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); } function wireCsLayersListRootDelegation() { const root = document.getElementById('wpo-cs-layers-list-root'); if (!root || root.dataset.cslWired === '1') return; root.dataset.cslWired = '1'; root.addEventListener('focusin', (e) => { const row = e.target && e.target.closest ? e.target.closest('[data-csl-focus]') : null; if (!row) return; const k = row.getAttribute('data-csl-focus'); if (k === 'shape') { csLayersFocusedKind = 'shape'; csLayersFocusedShapeKey = row.getAttribute('data-shape-key'); csLayersFocusedLayerId = null; } else if (k === 'layer') { csLayersFocusedKind = 'layer'; csLayersFocusedLayerId = row.getAttribute('data-layer-id'); csLayersFocusedShapeKey = null; } }); root.addEventListener('click', (e) => { const t = e.target; if (t.closest('select') || t.closest('input')) return; const rename = t.closest && t.closest('[data-csl-rename]'); if (rename && e.detail === 2) { const vi = parseInt(rename.getAttribute('data-csl-rename'), 10); const L = userLayers[vi]; if (!L) return; const inp = document.createElement('input'); inp.type = 'text'; inp.className = 'csl-name-input'; inp.value = L.name || ''; rename.replaceWith(inp); inp.focus(); inp.select(); const done = () => { const v = inp.value.trim() || DEFAULT_SHAPE_LAYER_NAME; commitBeforeChange('layer_rename'); L.name = v; scheduleDocumentSave(); scheduleShapeInspectorRefresh(); }; inp.onblur = () => done(); inp.onkeydown = (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); inp.blur(); } if (ev.key === 'Escape') { inp.value = L.name; inp.blur(); } }; return; } if (t.closest('[data-csl-drag-layer]')) return; const shapeRow = t.closest && t.closest('.csl-shape-row'); if (shapeRow && !t.closest('button') && !t.closest('select')) { const key = shapeRow.getAttribute('data-shape-key'); const layerId = shapeRow.getAttribute('data-layer-id'); const si = parseInt(shapeRow.getAttribute('data-shape-idx'), 10); if (!key) return; if (e.shiftKey) { if (csLayersLastAnchorLayerId !== layerId) { csLayersLastAnchorLayerId = layerId; csLayersLastAnchorShapeIndex = si; csLayersSelection.clear(); csLayersSelection.add(key); } else { const L = getUserLayerById(layerId); if (!L) return; const a = Math.min(csLayersLastAnchorShapeIndex, si); const b = Math.max(csLayersLastAnchorShapeIndex, si); csLayersSelection.clear(); ensureShapeIdsForLayer(L); for (let j = a; j <= b; j++) csLayersSelection.add(csShapeKey(layerId, L.shapeIds[j])); } } else if (e.ctrlKey || e.metaKey) { csLayersLastAnchorLayerId = layerId; csLayersLastAnchorShapeIndex = si; if (csLayersSelection.has(key)) csLayersSelection.delete(key); else csLayersSelection.add(key); } else { csLayersLastAnchorLayerId = layerId; csLayersLastAnchorShapeIndex = si; csLayersSelection.clear(); csLayersSelection.add(key); } scheduleShapeInspectorRefresh(); requestRender(); return; } const head = t.closest && t.closest('.csl-layer-head'); if (head && !t.closest('button') && !t.closest('.csl-drag-h')) { const lid = head.getAttribute('data-layer-id'); if (lid) setActiveLayerById(lid); return; } const eye = t.closest && t.closest('[data-csl-eye]'); if (eye) { const vi = parseInt(eye.getAttribute('data-csl-eye'), 10); const L = userLayers[vi]; if (!L) return; commitBeforeChange('layer_visibility'); L.visible = L.visible === false ? true : false; syncActiveLayerAliases(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); return; } const lu = t.closest && t.closest('[data-csl-layer-up]'); if (lu && !lu.disabled) { const vi = parseInt(lu.getAttribute('data-csl-layer-up'), 10); if (vi < userLayers.length - 1) { commitBeforeChange('layer_reorder'); swapUserLayersAt(vi, vi + 1); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); } return; } const ld = t.closest && t.closest('[data-csl-layer-dn]'); if (ld && !ld.disabled) { const vi = parseInt(ld.getAttribute('data-csl-layer-dn'), 10); if (vi > 0) { commitBeforeChange('layer_reorder'); swapUserLayersAt(vi, vi - 1); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); scheduleDocumentSave(); requestRender(); } return; } const dupL = t.closest && t.closest('[data-csl-dup-layer]'); if (dupL) { duplicateUserLayerAtIndex(parseInt(dupL.getAttribute('data-csl-dup-layer'), 10)); return; } const mergeB = t.closest && t.closest('[data-csl-merge]'); if (mergeB && !mergeB.disabled) { mergeLayerDownAtIndex(parseInt(mergeB.getAttribute('data-csl-merge'), 10)); return; } const delL = t.closest && t.closest('[data-csl-del-layer]'); if (delL) { const vi = parseInt(delL.getAttribute('data-csl-del-layer'), 10); const L = userLayers[vi]; if (L) deleteUserLayerById(L.id, false); return; } const col = t.closest && t.closest('[data-csl-collapse]'); if (col) { const vi = parseInt(col.getAttribute('data-csl-collapse'), 10); const L = userLayers[vi]; if (L) { csLayersCollapsed[L.id] = !csLayersCollapsed[L.id]; scheduleShapeInspectorRefresh(); } return; } const z = t.closest && t.closest('[data-csl-zoom-shape]'); if (z) { const key = z.getAttribute('data-csl-zoom-shape'); const p = parseShapeKey(key); if (!p) return; const L = getUserLayerById(p.layerId); const ix = L ? (L.shapeIds || []).indexOf(p.shapeId) : -1; if (L && ix >= 0 && L.polygons[ix]) zoomMapToLonLatBBox(bboxForPartLonLat(L.polygons[ix])); return; } const dps = t.closest && t.closest('[data-csl-dup-shape]'); if (dps) { const key = dps.getAttribute('data-csl-dup-shape'); const p = parseShapeKey(key); if (!p) return; const L = getUserLayerById(p.layerId); const ix = L ? (L.shapeIds || []).indexOf(p.shapeId) : -1; if (L && ix >= 0) duplicateShapeOnLayer(L, ix); return; } const su = t.closest && t.closest('[data-csl-shape-up]'); if (su && !su.disabled) { const key = su.getAttribute('data-csl-shape-up'); const p = parseShapeKey(key); const L = p ? getUserLayerById(p.layerId) : null; const ix = L && p ? L.shapeIds.indexOf(p.shapeId) : -1; if (L && ix > 0) swapShapesInLayer(L, ix, ix - 1); return; } const sd = t.closest && t.closest('[data-csl-shape-dn]'); if (sd && !sd.disabled) { const key = sd.getAttribute('data-csl-shape-dn'); const p = parseShapeKey(key); const L = p ? getUserLayerById(p.layerId) : null; const ix = L && p ? L.shapeIds.indexOf(p.shapeId) : -1; if (L && ix >= 0 && ix < L.polygons.length - 1) swapShapesInLayer(L, ix, ix + 1); return; } const act = t.closest && t.closest('[data-csl-act-shape]'); if (act) { const key = act.getAttribute('data-csl-act-shape'); const p = parseShapeKey(key); const L = p ? getUserLayerById(p.layerId) : null; const ix = L && p ? L.shapeIds.indexOf(p.shapeId) : -1; if (L && ix >= 0) tryActivateShapeLinkOnLayer(L, ix); return; } const dels = t.closest && t.closest('[data-csl-del-shape]'); if (dels) { const key = dels.getAttribute('data-csl-del-shape'); csLayersSelection.clear(); csLayersSelection.add(key); deleteShapesBySelectionKeys(); return; } const ing = t.closest && t.closest('[data-csl-ingest]'); if (ing) { const vid = ing.getAttribute('data-csl-ingest'); const vm = venueModelForIngestById(vid); if (!vm) logToUI('Place not found for ingest.', true); else ingestVenuesIntoNewLayer([vm]); return; } }); root.addEventListener('change', (e) => { const sel = e.target && e.target.closest ? e.target.closest('[data-csl-move]') : null; if (!sel || sel.tagName !== 'SELECT') return; const key = sel.getAttribute('data-csl-move'); const tgtId = sel.value; const p = parseShapeKey(key); if (!p || tgtId === '' || !tgtId) return; const L = getUserLayerById(p.layerId); const ix = L ? L.shapeIds.indexOf(p.shapeId) : -1; if (L && ix >= 0) moveShapeToLayer(L, ix, tgtId); sel.selectedIndex = 0; }); root.addEventListener('mouseover', (e) => { const row = e.target && e.target.closest ? e.target.closest('.csl-shape-row') : null; if (!row || !root.contains(row)) return; const k = row.getAttribute('data-shape-key'); if (k && k !== csLayersListHoverShapeKey) { csLayersListHoverShapeKey = k; requestRender(); } }); root.addEventListener('mouseout', (e) => { const row = e.target && e.target.closest ? e.target.closest('.csl-shape-row') : null; if (!row || !root.contains(row)) return; const rel = e.relatedTarget; if (rel && row.contains(rel)) return; csLayersListHoverShapeKey = null; requestRender(); }); root.addEventListener('dragstart', (e) => { const h = e.target && e.target.closest ? e.target.closest('[data-csl-drag-layer]') : null; if (!h) return; csLayersDragLayerIndex = parseInt(h.getAttribute('data-csl-drag-layer'), 10); try { e.dataTransfer.setData('text/plain', String(csLayersDragLayerIndex)); } catch (e2) { /* ignore */ } e.dataTransfer.effectAllowed = 'move'; }); } function createCsLayersPanel() { if (document.getElementById('wme-cs-layers-panel')) return; const panel = document.createElement('div'); panel.id = 'wme-cs-layers-panel'; panel.innerHTML = ` <div class="csl-header" id="csl-header-drag"> <span>CS Layers</span> <div class="csl-header-btns"> <button type="button" class="csl-hdr-btn" id="csl-btn-min" title="Minimize">−</button> <button type="button" class="csl-hdr-btn" id="csl-btn-close" title="Hide panel">×</button> </div> </div> <div class="csl-body" id="csl-body"> <div class="csl-toolbar"> <input type="text" class="csl-filter" id="csl-filter-input" placeholder="Filter layers…" aria-label="Filter layers by name" /> <button type="button" class="csl-icon-btn" id="csl-overflow-btn" title="More">⋯</button> </div> <div id="csl-overflow-backdrop" class="csl-overflow-backdrop"></div> <div id="csl-overflow-menu" class="csl-overflow-menu" style="display:none;"> <div class="csl-overflow-item" data-csl-expand-all>Expand all layers</div> <div class="csl-overflow-item" data-csl-collapse-all>Collapse all layers</div> </div> <div class="csl-scroll"><div id="wpo-cs-layers-list-root"></div></div> <div class="csl-footer"> <button type="button" class="csl-icon-btn" id="csl-sel-none" title="Clear selection on active layer">Sel ∅</button> <button type="button" class="csl-icon-btn" id="csl-sel-all" title="Select all shapes on active layer">Sel all</button> <button type="button" class="csl-icon-btn" id="csl-del-sel" title="Delete selected shapes">Del</button> <button type="button" class="csl-icon-btn" id="csl-unlink-sel" title="Unlink selected">Unlink</button> <button type="button" class="csl-icon-btn" id="csl-zoom-sel" title="Zoom to selection">Zoom</button> <button type="button" class="wpo-btn wpo-btn-action" id="csl-btn-add-layer" title="Add empty user layer">+Layer</button> </div> </div>`; panel.style.position = 'fixed'; panel.style.left = '24px'; panel.style.top = '120px'; panel.style.right = 'auto'; document.body.appendChild(panel); loadCsLayersPanelPos(panel); try { if (localStorage.getItem(LS_CS_LAYERS_OPEN) === '0') panel.classList.add('csl-hidden'); } catch (e) { /* ignore */ } const hdr = document.getElementById('csl-header-drag'); let drag = false, sx = 0, sy = 0, sl = 0, st = 0; hdr.addEventListener('mousedown', (e) => { if (e.target.closest('.csl-header-btns')) return; drag = true; const r = panel.getBoundingClientRect(); sx = e.clientX; sy = e.clientY; sl = r.left; st = r.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!drag) return; const nl = sl + (e.clientX - sx); const nt = st + (e.clientY - sy); panel.style.left = Math.max(0, nl) + 'px'; panel.style.top = Math.max(0, nt) + 'px'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', () => { if (drag) persistCsLayersPanelPos(panel); drag = false; }); document.getElementById('csl-btn-close').onclick = () => toggleCsLayersPanel(); document.getElementById('csl-btn-min').onclick = () => { const b = document.getElementById('csl-body'); if (b) b.style.display = b.style.display === 'none' ? '' : 'none'; }; const finp = document.getElementById('csl-filter-input'); if (finp) { finp.addEventListener('input', () => { csLayersNameFilter = finp.value; scheduleShapeInspectorRefresh(); }); } const ob = document.getElementById('csl-overflow-btn'); const om = document.getElementById('csl-overflow-menu'); const obd = document.getElementById('csl-overflow-backdrop'); const placeMenu = () => { if (!ob || !om) return; const r = ob.getBoundingClientRect(); om.style.left = r.left + 'px'; om.style.top = r.bottom + 2 + 'px'; om.style.display = 'block'; om.classList.add('csl-show'); if (obd) { obd.classList.add('csl-show'); obd.style.display = 'block'; } }; const hideMenu = () => { if (om) { om.style.display = 'none'; om.classList.remove('csl-show'); } if (obd) { obd.classList.remove('csl-show'); obd.style.display = 'none'; } }; if (ob) ob.onclick = (ev) => { ev.stopPropagation(); if (om && om.classList.contains('csl-show')) hideMenu(); else placeMenu(); }; if (obd) obd.onclick = () => hideMenu(); if (om) { om.onclick = (ev) => { const it = ev.target.closest && ev.target.closest('.csl-overflow-item'); if (!it) return; hideMenu(); if (it.hasAttribute('data-csl-expand-all')) { userLayers.forEach(L => { csLayersCollapsed[L.id] = false; }); } if (it.hasAttribute('data-csl-collapse-all')) { userLayers.forEach(L => { csLayersCollapsed[L.id] = true; }); } scheduleShapeInspectorRefresh(); }; } document.getElementById('csl-btn-add-layer').onclick = () => onAddUserLayerClick(); document.getElementById('csl-sel-none').onclick = () => { const L = getActiveUserLayer(); if (!L) return; ensureShapeIdsForLayer(L); for (let i = 0; i < L.shapeIds.length; i++) { csLayersSelection.delete(csShapeKey(L.id, L.shapeIds[i])); } scheduleShapeInspectorRefresh(); requestRender(); }; document.getElementById('csl-sel-all').onclick = () => { const L = getActiveUserLayer(); if (!L) return; ensureShapeIdsForLayer(L); for (let i = 0; i < L.shapeIds.length; i++) { csLayersSelection.add(csShapeKey(L.id, L.shapeIds[i])); } scheduleShapeInspectorRefresh(); requestRender(); }; document.getElementById('csl-del-sel').onclick = () => deleteShapesBySelectionKeys(); document.getElementById('csl-unlink-sel').onclick = () => unlinkShapesBySelectionKeys(); document.getElementById('csl-zoom-sel').onclick = () => zoomToSelectionKeysBbox(); wireCsLayersListRootDelegation(); bindCsLayersPanelOnce(); renderShapeInspectorList(); updateActiveLayerReadout(); } function ensureCsLayersPanel() { if (!document.getElementById('wme-cs-layers-panel')) createCsLayersPanel(); else wireCsLayersListRootDelegation(); } function rebuildLayerSelectDom() { scheduleShapeInspectorRefresh(); updateActiveLayerReadout(); } function venueModelForIngestById(venueIdRaw) { const norm = normalizeVenueIdForSdk(venueIdRaw); if (wmeSDK?.DataModel?.Venues?.getById) { try { const v = wmeSDK.DataModel.Venues.getById({ venueId: norm }); const gt = v && v.geometry && v.geometry.type; if (v && (gt === 'Polygon' || gt === 'MultiPolygon')) return v; } catch (e) { /* ignore */ } } const objs = window.W?.model?.venues?.objects; if (!objs) return null; for (const k in objs) { if (!Object.prototype.hasOwnProperty.call(objs, k)) continue; const o = objs[k]; const oid = o.attributes?.id != null ? o.attributes.id : (o.id != null ? o.id : k); if (venueIdsMatch(oid, venueIdRaw) || venueIdsMatch(normalizeVenueIdForSdk(oid), norm)) { const gt = o.geometry?.type; if (gt === 'Polygon' || gt === 'MultiPolygon') { return { id: oid, geometry: o.geometry, name: o.attributes?.name || o.name }; } } } return null; } function rebuildPlacesListDom() { scheduleShapeInspectorRefresh(); } function deriveIngestLayerName(models) { if (models.length === 1) { const m = models[0]; const n = m.name || (m.attributes && m.attributes.name); if (n) return 'Ingest: ' + String(n).slice(0, 48); return 'Ingest: ' + String(m.id); } return `Ingest (${models.length} places)`; } function ingestVenuesIntoNewLayer(models) { if (!isGeometryLibraryReady()) { logToUI('Ingest disabled: jsts is not available.', true); return; } if (!models.length) return; const newPolys = []; const perModelPartCount = []; for (let mi = 0; mi < models.length; mi++) { const parts = sdkGeometryToClippingParts(models[mi].geometry); perModelPartCount.push(parts.length); for (let p = 0; p < parts.length; p++) newPolys.push(parts[p]); } if (!newPolys.length) return; if (!getGeometryEngine()) { logToUI('Geometry library (jsts) is still loading. Wait a moment and try Ingest again.', true); return; } commitBeforeChange('ingest'); try { const newLayer = createUserLayer(deriveIngestLayerName(models)); activeLayerId = newLayer.id; newLayer.polygons = newPolys; newLayer.links = new Array(newPolys.length).fill(null); let shapeIdx = 0; for (let mi = 0; mi < models.length; mi++) { const id = models[mi].id; const nParts = perModelPartCount[mi]; if (id != null && id !== '') { const vid = normalizeVenueIdForSdk(id); for (let p = 0; p < nParts; p++) { newLayer.links[shapeIdx + p] = normalizeShapeLink({ venueId: vid, active: true, broken: false }); } } shapeIdx += nParts; } rebalanceVenueLinkActives(); ensureShapeIdsForLayer(newLayer); syncActiveLayerAliases(); alignShapeLinksToMaster(); logToUI(`Ingested ${models.length} venue(s) into new layer "${newLayer.name}".`); changeMode('replace', { persistCommitMode: true }); logToUI('Commit mode set to Replace after ingest.', false); if (!DRAWING_STYLE_TOOLS.has(appState.tool)) { changeTool('polygon'); } else { requestRender(); updateTrackerHUD(); } handleSelectionChange(); rebuildLayerSelectDom(); scheduleDocumentSave(); } catch (e) { logToUI('Error ingesting.', true); console.error(e); } } function onAddUserLayerClick() { commitBeforeChange('layer_add'); const L = createUserLayer(DEFAULT_SHAPE_LAYER_NAME + ' ' + (userLayers.length + 1)); activeLayerId = L.id; syncActiveLayerAliases(); rebuildLayerSelectDom(); scheduleDocumentSave(); requestRender(); updateTrackerHUD(); logToUI(`Added layer: ${L.name}`); } function onLayerSelectChange(ev) { const id = ev.target && ev.target.value; if (id) setActiveLayerById(id); } function cloneUserLayersForSnapshot(layers) { return layers.map(l => ({ id: l.id, isPlaces: !!l.isPlaces, visible: l.visible !== false, name: l.name, polygons: cloneMasterPolygons(l.polygons), links: cloneShapePlaceLinks(l.links), shapeIds: (l.shapeIds || []).slice() })); } function cloneGpsVertices(verts) { if (!verts || !verts.length) return []; return verts.map(v => (v && typeof v.lon === 'number' && typeof v.lat === 'number' ? { lon: v.lon, lat: v.lat } : { lon: Number(v.lon), lat: Number(v.lat) })); } function capturePaintDocumentSnapshot() { return { userLayers: cloneUserLayersForSnapshot(userLayers), activeLayerId, masterPolygons: cloneMasterPolygons(masterPolygons), shapePlaceLinks: cloneShapePlaceLinks(shapePlaceLinks), draft: { isDraftActive: !!appState.isDraftActive, draftVertices: cloneGpsVertices(draftVertices), draftInteriorRings: (draftInteriorRings || []).map(ring => cloneGpsVertices(ring)), tempVertices: cloneGpsVertices(tempVertices), revertOriginalPolygon: revertOriginalPolygon ? cloneMasterPolygons(revertOriginalPolygon) : null, revertOriginalLink: revertOriginalLink != null ? cloneLinkEntry(revertOriginalLink) : null, draftPopInsertMasterIndex: draftPopInsertMasterIndex, isDrawingShape: !!isDrawingShape }, csLayersSelectionKeys: Array.from(csLayersSelection).sort(), tool: appState.tool, mode: appState.mode }; } const HISTORY_SNAPSHOT_TOOLS = new Set(['pan', 'measure', 'move', 'revert', 'link', 'rectangle', 'ellipse', 'lasso', 'polygon', 'brush', 'eraser', 'wand']); /** Restore toolbar boolean mode + tool after history apply (older snapshots omit tool/mode). */ function applySnapshotToolAndMode(snapshot) { if (!snapshot) return; const m = snapshot.mode; const t = snapshot.tool; if (typeof m === 'string' && COMMIT_MODE_IDS.has(m)) changeMode(m); if (typeof t === 'string' && HISTORY_SNAPSHOT_TOOLS.has(t)) changeTool(t, { preserveDrawingState: true, quiet: true }); } function applySnapshotDraftAndSelection(snapshot) { csLayersSelection.clear(); const keys = snapshot && snapshot.csLayersSelectionKeys; if (Array.isArray(keys)) { for (let i = 0; i < keys.length; i++) csLayersSelection.add(keys[i]); } const draft = snapshot && snapshot.draft; if (!draft) { tempVertices = []; isDrawingShape = false; revertOriginalPolygon = null; revertOriginalLink = null; draftPopInsertMasterIndex = -1; setDraftActive(false); return; } tempVertices = cloneGpsVertices(draft.tempVertices || []); isDrawingShape = !!draft.isDrawingShape; if (!draft.isDraftActive) { revertOriginalPolygon = null; revertOriginalLink = null; draftPopInsertMasterIndex = -1; setDraftActive(false); return; } draftVertices = cloneGpsVertices(draft.draftVertices || []); draftInteriorRings = (draft.draftInteriorRings || []).map(ring => cloneGpsVertices(ring)); revertOriginalPolygon = draft.revertOriginalPolygon ? cloneMasterPolygons(draft.revertOriginalPolygon) : null; revertOriginalLink = draft.revertOriginalLink != null ? cloneLinkEntry(draft.revertOriginalLink) : null; draftPopInsertMasterIndex = typeof draft.draftPopInsertMasterIndex === 'number' ? draft.draftPopInsertMasterIndex : -1; setDraftActive(true); } function applyRestorePaintDocument(snapshot) { if (!snapshot) return; if (snapshot.userLayers && snapshot.userLayers.length) { userLayers = snapshot.userLayers.map(l => ({ id: l.id || generateLayerId(), isPlaces: false, visible: l.visible !== false, name: migrateLegacyLayerName(l.name || DEFAULT_SHAPE_LAYER_NAME), polygons: cloneMasterPolygons(l.polygons), links: cloneShapePlaceLinks(l.links), shapeIds: Array.isArray(l.shapeIds) ? l.shapeIds.slice() : [] })); userLayers.forEach(ensureShapeIdsForLayer); activeLayerId = snapshot.activeLayerId; validateActiveLayerId(); syncActiveLayerAliases(); refreshPlacesPreviewFromMap(); rebuildLayerSelectDom(); rebuildPlacesListDom(); applySnapshotDraftAndSelection(snapshot); applySnapshotToolAndMode(snapshot); pruneShapeInspectorSelection(); scheduleDocumentSave(); return; } if (Array.isArray(snapshot.userLayers) && snapshot.userLayers.length === 0 && snapshot.masterPolygons) { if (DEBUG) console.warn('[WME Candy Shop] History snapshot had empty userLayers; rebuilt one layer from masterPolygons.'); const newId = snapshot.activeLayerId || generateLayerId(); userLayers = [{ id: newId, isPlaces: false, visible: true, name: migrateLegacyLayerName(DEFAULT_SHAPE_LAYER_NAME), polygons: cloneMasterPolygons(snapshot.masterPolygons), links: cloneShapePlaceLinks(snapshot.shapePlaceLinks || []), shapeIds: [] }]; userLayers.forEach(ensureShapeIdsForLayer); activeLayerId = newId; validateActiveLayerId(); syncActiveLayerAliases(); refreshPlacesPreviewFromMap(); rebuildLayerSelectDom(); rebuildPlacesListDom(); applySnapshotDraftAndSelection(snapshot); applySnapshotToolAndMode(snapshot); pruneShapeInspectorSelection(); scheduleDocumentSave(); return; } masterPolygons = cloneMasterPolygons(snapshot.masterPolygons); shapePlaceLinks = cloneShapePlaceLinks(snapshot.shapePlaceLinks); alignShapeLinksToMaster(); const L = getActiveUserLayer(); if (L) { L.polygons = masterPolygons; L.links = shapePlaceLinks; masterPolygons = L.polygons; shapePlaceLinks = L.links; ensureShapeIdsForLayer(L); } rebuildLayerSelectDom(); refreshPlacesPreviewFromMap(); rebuildPlacesListDom(); applySnapshotDraftAndSelection(snapshot); applySnapshotToolAndMode(snapshot); pruneShapeInspectorSelection(); scheduleDocumentSave(); } function commitBeforeChange(reasonTag, explicitLabel) { if (paintHistoryApplying) return; const label = historyLabelForTag(reasonTag, explicitLabel); paintUndoStack.push({ snapshot: capturePaintDocumentSnapshot(), label }); trimUndoStackToCap(); paintRedoStack = []; updateUndoRedoButtons(); scheduleCsHistoryRefresh(); } function clearHistory() { paintUndoStack = []; paintRedoStack = []; updateUndoRedoButtons(); scheduleCsHistoryRefresh(); } function canUndo() { return paintUndoStack.length > 0; } function canRedo() { return paintRedoStack.length > 0; } function resetTransientInteractionStateAfterHistoryRestore() { brushPainting = false; brushStrokeSamples = []; hoveredTempVertexIndex = -1; draggingVertexIndex = -1; hoveredDraftVertexIndex = -1; draggingDraftVertexIndex = -1; hoveredDraftMidpointIndex = -1; draftAction = 'none'; dragStartMouse = null; dragStartGlobalCoords = null; dragStartDraftRingsPx = null; dragStartBBox = null; dragStartMasterPixels = null; hoveredMasterPolyIndex = -1; pendingLinkVenueId = null; appState.highlightedLinkIndex = -1; } function resetEphemeralPaintStateAfterHistoryRestore() { resetTransientInteractionStateAfterHistoryRestore(); requestRender(); updateTrackerHUD(); handleSelectionChange(); updateUndoRedoButtons(); rebuildLayerSelectDom(); rebuildPlacesListDom(); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); renderOptionsRow(); scheduleCsHistoryRefresh(); } function undo() { if (!canUndo()) return; const current = capturePaintDocumentSnapshot(); const prevEntry = paintUndoStack.pop(); const prevSnap = paintHistoryEntrySnapshot(prevEntry); paintRedoStack.push({ snapshot: current, label: historyLabelForTag('edit') }); paintHistoryApplying = true; try { applyRestorePaintDocument(prevSnap); } finally { paintHistoryApplying = false; } resetEphemeralPaintStateAfterHistoryRestore(); } function redo() { if (!canRedo()) return; const current = capturePaintDocumentSnapshot(); const nextEntry = paintRedoStack.pop(); const nextSnap = paintHistoryEntrySnapshot(nextEntry); paintUndoStack.push({ snapshot: current, label: historyLabelForTag('edit') }); trimUndoStackToCap(); paintHistoryApplying = true; try { applyRestorePaintDocument(nextSnap); } finally { paintHistoryApplying = false; } resetEphemeralPaintStateAfterHistoryRestore(); } function jumpToUndoDepth(depth) { if (paintHistoryApplying) return; if (!Number.isFinite(depth) || depth < 1 || depth > paintUndoStack.length) return; const targetIx = paintUndoStack.length - depth; const targetEntry = paintUndoStack[targetIx]; const targetSnap = paintHistoryEntrySnapshot(targetEntry); paintUndoStack.splice(paintUndoStack.length - depth, depth); paintRedoStack = []; paintHistoryApplying = true; try { applyRestorePaintDocument(targetSnap); } finally { paintHistoryApplying = false; } resetEphemeralPaintStateAfterHistoryRestore(); } function updateUndoRedoButtons() { const u = document.getElementById('wpo-btn-undo'); const r = document.getElementById('wpo-btn-redo'); if (u) { u.disabled = !canUndo(); u.style.opacity = canUndo() ? '1' : '0.5'; u.style.cursor = canUndo() ? 'pointer' : 'not-allowed'; } if (r) { r.disabled = !canRedo(); r.style.opacity = canRedo() ? '1' : '0.5'; r.style.cursor = canRedo() ? 'pointer' : 'not-allowed'; } } function scheduleCsHistoryRefresh() { if (csHistoryRefreshScheduled) return; csHistoryRefreshScheduled = true; requestAnimationFrame(() => { csHistoryRefreshScheduled = false; renderCsHistoryPanel(); }); } function renderCsHistoryPanel() { const root = document.getElementById('csh-list-root'); if (!root) return; let html = '<div class="csh-row csh-current"><span class="csh-lab">Current</span></div>'; for (let i = paintUndoStack.length - 1; i >= 0; i--) { const ent = paintUndoStack[i]; const lab = ent && typeof ent === 'object' && ent.label ? ent.label : 'Edit'; const depth = paintUndoStack.length - i; html += `<div class="csh-row csh-past" tabindex="0" role="button" data-csh-depth="${depth}"><span class="csh-lab">${escapeHtmlCs(lab)}</span></div>`; } root.innerHTML = html; root.querySelectorAll('.csh-past').forEach(row => { row.onclick = () => { const d = parseInt(row.getAttribute('data-csh-depth'), 10); if (Number.isFinite(d)) jumpToUndoDepth(d); }; }); } function persistCsHistoryPanelPos(panel) { try { const r = panel.getBoundingClientRect(); localStorage.setItem(LS_CS_HISTORY_POS, JSON.stringify({ left: r.left, top: r.top })); } catch (e) { /* ignore */ } } function loadCsHistoryPanelPos(panel) { try { const raw = localStorage.getItem(LS_CS_HISTORY_POS); if (!raw) return; const o = JSON.parse(raw); if (typeof o.left === 'number' && typeof o.top === 'number') { panel.style.left = Math.max(0, o.left) + 'px'; panel.style.top = Math.max(0, o.top) + 'px'; panel.style.right = 'auto'; } } catch (e) { /* ignore */ } } function toggleCsHistoryPanel() { const p = document.getElementById('wme-cs-history-panel'); if (!p) return; p.classList.toggle('csh-hidden'); const open = !p.classList.contains('csh-hidden'); try { localStorage.setItem(LS_CS_HISTORY_OPEN, open ? '1' : '0'); } catch (e2) { /* ignore */ } if (open) scheduleCsHistoryRefresh(); } function createCsHistoryPanel() { if (document.getElementById('wme-cs-history-panel')) return; const panel = document.createElement('div'); panel.id = 'wme-cs-history-panel'; panel.className = 'csh-panel'; panel.innerHTML = ` <div class="csh-header" id="csh-header-drag"> <span>CS History</span> <div class="csh-header-btns"> <button type="button" class="csh-hdr-btn" id="csh-btn-min" title="Minimize">−</button> <button type="button" class="csh-hdr-btn" id="csh-btn-close" title="Hide panel">×</button> </div> </div> <div class="csh-body" id="csh-body"> <p class="csh-hint" title="Each row is one saved state. Choosing an older row restores it and drops newer undo steps.">Each row is one saved state; picking an older row restores it and removes newer undo steps.</p> <p class="csh-hint csh-memory-hint" title="Up to 200 full snapshots are kept; long editing sessions use more browser memory.">Tip: many undo steps use more memory in long sessions.</p> <div class="csh-scroll" id="csh-list-root"></div> </div>`; panel.style.position = 'fixed'; panel.style.left = '360px'; panel.style.top = '120px'; panel.style.right = 'auto'; document.body.appendChild(panel); loadCsHistoryPanelPos(panel); try { if (localStorage.getItem(LS_CS_HISTORY_OPEN) === '0') panel.classList.add('csh-hidden'); } catch (e) { /* ignore */ } const hdr = document.getElementById('csh-header-drag'); let drag = false, sx = 0, sy = 0, sl = 0, st = 0; if (hdr) { hdr.addEventListener('mousedown', (e) => { if (e.target.closest('.csh-header-btns')) return; drag = true; const r = panel.getBoundingClientRect(); sx = e.clientX; sy = e.clientY; sl = r.left; st = r.top; e.preventDefault(); }); } document.addEventListener('mousemove', (e) => { if (!drag) return; const nl = sl + (e.clientX - sx); const nt = st + (e.clientY - sy); panel.style.left = Math.max(0, nl) + 'px'; panel.style.top = Math.max(0, nt) + 'px'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', () => { if (drag) persistCsHistoryPanelPos(panel); drag = false; }); const cbtn = document.getElementById('csh-btn-close'); if (cbtn) cbtn.onclick = () => toggleCsHistoryPanel(); const mbtn = document.getElementById('csh-btn-min'); if (mbtn) mbtn.onclick = () => { const b = document.getElementById('csh-body'); if (b) b.style.display = b.style.display === 'none' ? '' : 'none'; }; renderCsHistoryPanel(); } function ensureCsHistoryPanel() { if (!document.getElementById('wme-cs-history-panel')) createCsHistoryPanel(); else scheduleCsHistoryRefresh(); } function candyPaintUndoHotkeysLikelyConflict(e) { const t = e.target; if (!t) return false; const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (t.isContentEditable) return true; return false; } function candyPaintUndoHotkeyEditingContext() { if (appState.isDraftActive) return true; if (isDrawingShape || brushPainting) return true; if (tempVertices.length > 0 || draftVertices.length > 0) return true; if (appState.tool !== 'pan' && appState.tool !== 'measure') return true; if (hasAnyUserLayerPolygons()) return true; return false; } /** Block V/P/M/L and Space→pan while mid drag-stroke (P10). */ function candyPaintToolHotkeyBlocksToolSwitch() { return brushPainting || isDrawingShape; } function syncKeyboardModifiersFromEvent(e) { isShiftDown = !!e.shiftKey; isAltDown = !!e.altKey; } function onDocumentModifierAndExperimentalKeydown(e) { syncKeyboardModifiersFromEvent(e); if (!getExperimentalUndoKeys()) return; onDocumentExperimentalHotkeysKeydown(e); } function onDocumentModifierKeyup(e) { syncKeyboardModifiersFromEvent(e); if (!getExperimentalUndoKeys()) return; onDocumentExperimentalHotkeysKeyup(e); } function onDocumentExperimentalHotkeysKeydown(e) { if (candyPaintUndoHotkeysLikelyConflict(e)) return; if ((e.ctrlKey || e.metaKey) && candyPaintUndoHotkeyEditingContext()) { if (e.code === 'KeyZ') { if (e.shiftKey) { if (!canRedo()) return; e.preventDefault(); e.stopPropagation(); redo(); return; } if (!canUndo()) return; e.preventDefault(); e.stopPropagation(); undo(); return; } if (e.code === 'KeyY' && !e.shiftKey) { if (!canRedo()) return; e.preventDefault(); e.stopPropagation(); redo(); return; } } if (e.ctrlKey || e.metaKey) return; if (candyPaintToolHotkeyBlocksToolSwitch()) return; if (e.code === 'KeyV') { e.preventDefault(); e.stopPropagation(); changeTool('move'); return; } if (e.code === 'KeyP') { e.preventDefault(); e.stopPropagation(); changeTool('pan'); return; } if (e.code === 'KeyM') { e.preventDefault(); e.stopPropagation(); changeTool('measure'); return; } if (e.code === 'KeyL') { e.preventDefault(); e.stopPropagation(); changeTool('link'); return; } if (e.code === 'Space') { if (spacePanActive) { e.preventDefault(); e.stopPropagation(); return; } if (!e.repeat) { spacePanRestoreTool = appState.tool; spacePanActive = true; changeTool('pan', { quiet: true }); e.preventDefault(); e.stopPropagation(); } } } function onDocumentExperimentalHotkeysKeyup(e) { if (candyPaintUndoHotkeysLikelyConflict(e)) return; if (e.code === 'Space' && spacePanActive) { spacePanActive = false; changeTool(spacePanRestoreTool, { quiet: true }); e.preventDefault(); e.stopPropagation(); } } /** Event names from WME SDK SdkEvents (production typings, e.g. web-assets.waze.com wme_sdk_docs). */ function registerPaintHistorySdkLifecycleClear() { if (!wmeSDK || !wmeSDK.Events || typeof wmeSDK.Events.on !== 'function') return; wmeSDK.Events.on({ eventName: 'wme-logged-out', eventHandler: () => clearHistory() }); } // (No brush mask: brush unions into the shared layer.) /** @returns {number} index in active layer polygons or -1 */ function pickMasterPolygonAt(clientX, clientY) { const L = getActiveUserLayer(); if (!ctx || !L || L.visible === false || !L.polygons.length) return -1; const polys = L.polygons; for (let i = polys.length - 1; i >= 0; i--) { if (!polys[i]) continue; ctx.beginPath(); polys[i].forEach(ring => { ring.forEach((pt, idx) => { const px = gpsToGlobalPixel(pt[0], pt[1]); if (px) idx === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); }); }); if (ctx.isPointInPath(clientX, clientY, 'evenodd')) return i; } return -1; } function wandToleranceToPixels() { const t = Number(appState.wandTolerance); if (!Number.isFinite(t)) return 0; return Math.max(0, Math.min(40, t * 0.4)); } function distancePointToSegmentPx(px, py, x1, y1, x2, y2) { const dx = x2 - x1, dy = y2 - y1; const len2 = dx * dx + dy * dy; if (len2 < 1e-10) return Math.hypot(px - x1, py - y1); let t = ((px - x1) * dx + (py - y1) * dy) / len2; t = Math.max(0, Math.min(1, t)); const nx = x1 + t * dx, ny = y1 + t * dy; return Math.hypot(px - nx, py - ny); } /** Min screen distance from point to outer ring edges of one Candy part (P10 magic wand). */ function minDistancePointToPolygonOuterRingPx(clientX, clientY, poly) { if (!poly || !poly[0] || poly[0].length < 2) return Infinity; const ring = poly[0]; let minD = Infinity; for (let i = 0; i < ring.length; i++) { const j = (i + 1) % ring.length; const p1 = gpsToGlobalPixel(ring[i][0], ring[i][1]); const p2 = gpsToGlobalPixel(ring[j][0], ring[j][1]); if (!p1 || !p2) continue; minD = Math.min(minD, distancePointToSegmentPx(clientX, clientY, p1.x, p1.y, p2.x, p2.y)); } return minD; } /** Like pickMasterPolygonAt but allows edge hit within tolPx (0 = inside-only). */ function pickMasterPolygonAtWithTolerance(clientX, clientY, tolPx) { const inside = pickMasterPolygonAt(clientX, clientY); if (inside >= 0) return inside; if (!(tolPx > 0)) return -1; const L = getActiveUserLayer(); if (!ctx || !L || L.visible === false || !L.polygons.length) return -1; const polys = L.polygons; let best = -1; let bestD = tolPx + 1; for (let i = polys.length - 1; i >= 0; i--) { if (!polys[i]) continue; const d = minDistancePointToPolygonOuterRingPx(clientX, clientY, polys[i]); if (d <= tolPx && d < bestD) { bestD = d; best = i; } } return best; } /** Shared pop-to-draft splice (pan click-to-draft + magic wand). */ function popActiveLayerShapeToDraftFromIndex(shapeIndex) { if (shapeIndex < 0 || shapeIndex >= masterPolygons.length || !masterPolygons[shapeIndex]) return; commitBeforeChange('pop_draft'); alignShapeLinksToMaster(); revertOriginalLink = shapePlaceLinks.splice(shapeIndex, 1)[0] ?? null; const poppedPoly = masterPolygons.splice(shapeIndex, 1)[0]; revertOriginalPolygon = [poppedPoly]; draftPopInsertMasterIndex = shapeIndex; assignDraftFromPoppedMasterPart(poppedPoly); setDraftActive(true); changeTool('revert', { quiet: true }); logToUI('Change to Transform Tool.'); scheduleShapeInspectorRefresh(); requestRender(); updateTrackerHUD(); } /** Top→bottom visible user layers; topmost shape wins (for future CS Layers map sync). */ function pickTopVisibleShapeAt(clientX, clientY) { if (!ctx) return null; for (let li = userLayers.length - 1; li >= 0; li--) { const layer = userLayers[li]; if (layer.visible === false) continue; const polys = layer.polygons; for (let i = polys.length - 1; i >= 0; i--) { if (!polys[i]) continue; ctx.beginPath(); polys[i].forEach(ring => { ring.forEach((pt, idx) => { const px = gpsToGlobalPixel(pt[0], pt[1]); if (px) idx === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); }); }); if (ctx.isPointInPath(clientX, clientY, 'evenodd')) return { layerId: layer.id, shapeIndex: i }; } } return null; } /** Pop hit active-layer shape into draft (click-to-draft). Returns true if handled (incl. Ctrl multiselect). */ function tryPopShapeToDraftAt(clientX, clientY, ev) { if (appState.tool !== 'pan') return false; if (isDrawingShape || tempVertices.length > 0 || brushPainting) return false; if (appState.tool === 'revert' || appState.tool === 'link') return false; if (appState.tool === 'brush' || appState.tool === 'eraser') return false; const idx = pickMasterPolygonAt(clientX, clientY); if (idx === -1 || !masterPolygons[idx]) return false; const Lact = getActiveUserLayer(); if (!Lact) return false; ensureShapeIdsForLayer(Lact); const sid = Lact.shapeIds[idx]; const key = csShapeKey(Lact.id, sid); const mod = ev && (ev.ctrlKey || ev.metaKey); if (mod) { if (csLayersSelection.has(key)) csLayersSelection.delete(key); else csLayersSelection.add(key); scheduleShapeInspectorRefresh(); requestRender(); return true; } csLayersSelection.clear(); csLayersSelection.add(key); if (appState.isDraftActive) { cancelDraft(); syncActiveLayerAliases(); } const idx2 = pickMasterPolygonAt(clientX, clientY); if (idx2 === -1 || !masterPolygons[idx2]) { scheduleShapeInspectorRefresh(); requestRender(); return true; } popActiveLayerShapeToDraftFromIndex(idx2); return true; } function findNativeVenueObject(venueId) { const objs = window.W?.model?.venues?.objects; if (!objs || venueId == null) return null; let nv = objs[venueId]; if (nv) return nv; nv = objs[String(venueId)]; return nv || null; } function validateLinkState() { let hasInvalidLinks = false; if (!window.W || !window.W.model || !window.W.model.venues || !window.W.model.venues.objects) return false; for (let li = 0; li < userLayers.length; li++) { const layer = userLayers[li]; if (layer.isPlaces) continue; const links = layer.links; for (let i = 0; i < links.length; i++) { const link = normalizeShapeLink(links[i]); if (!link || link.venueId == null) continue; if (!linkBlocksApplyValidation(link)) { const L = normalizeShapeLink(links[i]); if (!L) continue; const cleared = { ...L }; delete cleared.isInvalid; links[i] = normalizeShapeLink(cleared); continue; } const nativeVenue = findNativeVenueObject(link.venueId); if (!nativeVenue) { links[i] = { ...link, isInvalid: true }; hasInvalidLinks = true; } else if (typeof nativeVenue.isGeometryEditable === 'function' && !nativeVenue.isGeometryEditable()) { links[i] = { ...link, isInvalid: true }; hasInvalidLinks = true; } else { const ok = { ...link }; delete ok.isInvalid; links[i] = normalizeShapeLink(ok); } } } syncActiveLayerAliases(); return hasInvalidLinks; } function modeChipLabel(mode) { const map = { replace: 'Replace', union: 'Union', difference: 'Subtract', intersection: 'Intersect', xor: 'XOR' }; return map[mode] || String(mode); } function modeChipTextColor(mode) { if (mode === 'union') return '#1a8f6a'; if (mode === 'difference') return '#c43d3d'; if (mode === 'intersection') return '#b38600'; if (mode === 'xor') return '#6b2fb8'; return '#334155'; } function updateModeChip() { const el = document.getElementById('wpo-mode-chip'); if (!el) return; if (appState.tool === 'brush' || appState.tool === 'eraser') { el.style.display = 'inline-flex'; el.style.color = appState.tool === 'eraser' ? '#c43d3d' : '#1a8f6a'; const r = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || 12)); const pl = brushProfileChipLabel(appState.brushProfile); el.textContent = (appState.tool === 'eraser' ? 'Eraser' : 'Brush') + ' · R ' + r + ' m · ' + pl + ' (ignores commit mode)'; el.title = 'Brush and eraser always union / subtract; commit mode buttons do not apply.'; return; } el.style.display = 'inline-flex'; el.style.color = modeChipTextColor(appState.mode); el.textContent = 'Commit mode: ' + modeChipLabel(appState.mode); el.title = hasAnyUserLayerPolygons() ? 'How the next committed draft combines with shapes on user layers' : 'Applies when you commit a drawn or draft shape'; } function scheduleAutoIngest() { if (autoIngestTimer) clearTimeout(autoIngestTimer); if (!getAutoIngest()) return; autoIngestTimer = setTimeout(() => { autoIngestTimer = null; if (!getAutoIngest()) return; if (!isGeometryLibraryReady()) return; if (getActiveUserLayerShapeCount() > 0) return; const models = getSelectedAreaVenues(); if (models.length !== 1) return; ingestWmeSelection(); }, 280); } function updateApplyButtonState() { const applyBtn = document.getElementById('wpo-btn-apply'); if (!applyBtn) return; const ready = isGeometryLibraryReady(); let hasInvalidLinks; if (brushPainting) { hasInvalidLinks = applyHudInvalidLinksCache; } else { hasInvalidLinks = validateLinkState(); applyHudInvalidLinksCache = hasInvalidLinks; } if (!ready) { applyBtn.disabled = true; applyBtn.style.opacity = '0.5'; applyBtn.style.cursor = 'not-allowed'; applyBtn.title = 'Geometry library not ready'; } else if (hasInvalidLinks) { applyBtn.disabled = true; applyBtn.style.opacity = '0.5'; applyBtn.style.cursor = 'not-allowed'; applyBtn.title = 'Blocked: An active (non-broken) linked venue is missing or not geometry-editable'; const targetText = document.getElementById('wpo-target-text'); if (targetText && !targetText.innerHTML.includes('Invalid links')) { targetText.innerHTML += ` <span style="color:#FF5B5B; font-weight:bold;">(Invalid links)</span>`; } } else { applyBtn.disabled = false; applyBtn.style.opacity = '1'; applyBtn.style.cursor = 'pointer'; applyBtn.title = 'Apply shapes to WME'; } } function tryLinkHoveredToSelection() { if (hoveredMasterPolyIndex < 0) { logToUI('Hover a master-layer shape first.', true); return; } const models = getSelectedAreaVenues(); let id = null; if (models.length === 1 && models[0].id != null && models[0].id !== '') { id = normalizeVenueIdForSdk(models[0].id); } else if (pendingLinkVenueId != null) { id = normalizeVenueIdForSdk(pendingLinkVenueId); } if (id == null || id === '') { logToUI('Select exactly one polygon/multipolygon place in WME (Link tool remembers the target while you hover).', true); return; } const nativeVenue = findNativeVenueObject(id); if (nativeVenue && typeof nativeVenue.isGeometryEditable === 'function' && !nativeVenue.isGeometryEditable()) { logToUI('Cannot link: Selected place is locked or un-editable.', true); return; } commitBeforeChange('link'); alignShapeLinksToMaster(); shapePlaceLinks[hoveredMasterPolyIndex] = normalizeShapeLink({ venueId: id, active: true, broken: false }); const L = getActiveUserLayer(); rebalanceVenueLinkActives(L ? { layer: L, shapeIndex: hoveredMasterPolyIndex } : null); pendingLinkVenueId = null; logToUI(`Linked shape ${hoveredMasterPolyIndex + 1} to venue ${shapePlaceLinks[hoveredMasterPolyIndex].venueId}.`, false); scheduleDocumentSave(); requestRender(); handleSelectionChange(); } /** CS Layers “Activate” (palette): hovered shape becomes the active link for its venue. */ function tryActivateHoveredShapeLink() { if (hoveredMasterPolyIndex < 0) { logToUI('Hover a shape on the active layer first.', true); return; } alignShapeLinksToMaster(); const cur = normalizeShapeLink(shapePlaceLinks[hoveredMasterPolyIndex]); if (!cur || cur.venueId == null) { logToUI('Hovered shape has no place link to activate.', true); return; } commitBeforeChange('link_activate'); const L = getActiveUserLayer(); shapePlaceLinks[hoveredMasterPolyIndex] = { ...cur, active: true, broken: false }; rebalanceVenueLinkActives(L ? { layer: L, shapeIndex: hoveredMasterPolyIndex } : null); logToUI(`Active link: shape ${hoveredMasterPolyIndex + 1} for venue ${cur.venueId}.`, false); scheduleDocumentSave(); requestRender(); handleSelectionChange(); } function tryUnlinkHovered() { if (hoveredMasterPolyIndex < 0) { logToUI('Hover a master-layer shape first.', true); return; } alignShapeLinksToMaster(); if (!shapePlaceLinks[hoveredMasterPolyIndex]) return; commitBeforeChange('unlink'); shapePlaceLinks[hoveredMasterPolyIndex] = null; logToUI(`Unlinked shape ${hoveredMasterPolyIndex + 1}.`, false); scheduleDocumentSave(); requestRender(); handleSelectionChange(); } function assignDraftFromPoppedMasterPart(part) { draftInteriorRings = []; if (!part || !part.length || !part[0] || part[0].length < 3) { draftVertices = []; return; } draftVertices = part[0].map(pt => ({ lon: pt[0], lat: pt[1] })); for (let r = 1; r < part.length; r++) { const ring = part[r]; if (!ring || ring.length < 3) continue; draftInteriorRings.push(ring.map(pt => ({ lon: pt[0], lat: pt[1] }))); } } function captureDraftRingsPixelCoords() { const rings = [draftVertices, ...draftInteriorRings]; const out = []; for (const ring of rings) { const row = []; for (const v of ring) { const p = gpsToGlobalPixel(v.lon, v.lat); if (p) row.push({ x: p.x, y: p.y }); } out.push(row); } return out; } function mapDraftRingsFromPx(mapPoint) { const pxRings = dragStartDraftRingsPx; if (!pxRings || !pxRings.length) return; const nv0 = pxRings[0].map(mapPoint).filter(Boolean); if (nv0.length >= 3) draftVertices = nv0; for (let i = 1; i < pxRings.length; i++) { const nv = pxRings[i].map(mapPoint).filter(Boolean); if (i - 1 < draftInteriorRings.length && nv.length >= 3) draftInteriorRings[i - 1] = nv; } } function ringLonLatClosedFromDraftVerts(verts) { let pts = verts.map(v => [v.lon, v.lat]); if (pts.length < 3) return null; if (pts[0][0] !== pts[pts.length - 1][0] || pts[0][1] !== pts[pts.length - 1][1]) pts = pts.concat([[pts[0][0], pts[0][1]]]); return pts; } function buildClosedDraftPartLonLat() { const outer = ringLonLatClosedFromDraftVerts(draftVertices); if (!outer) return null; const holes = []; for (const hole of draftInteriorRings) { const h = ringLonLatClosedFromDraftVerts(hole); if (h && h.length >= 4) holes.push(h); } return [outer, ...holes]; } function addAllDraftRingsToCanvasPath(targetCtx) { const rings = [draftVertices, ...draftInteriorRings]; for (const ring of rings) { if (!ring || ring.length < 2) continue; let started = false; for (const v of ring) { const px = gpsToGlobalPixel(v.lon, v.lat); if (!px) continue; if (!started) { targetCtx.moveTo(px.x, px.y); started = true; } else targetCtx.lineTo(px.x, px.y); } if (started) targetCtx.closePath(); } } /** P10: axis-aligned rectangle in Web Mercator; shift = square, alt = first point is center. */ function rectangleVerticesFromDrag(p1, p2, opts) { const square = !!(opts && opts.square); const fromCenter = !!(opts && opts.fromCenter); const ax = wgs84ToMercator(p1.lon, p1.lat); const bx = wgs84ToMercator(p2.lon, p2.lat); let x0, y0, x1, y1; if (fromCenter) { const cx = ax.x, cy = ax.y; let vx = bx.x - cx; let vy = bx.y - cy; if (square) { const s = Math.max(Math.abs(vx), Math.abs(vy)); vx = Math.sign(vx || 1) * s; vy = Math.sign(vy || 1) * s; } x0 = cx - Math.abs(vx); x1 = cx + Math.abs(vx); y0 = cy - Math.abs(vy); y1 = cy + Math.abs(vy); } else { let vx = bx.x - ax.x; let vy = bx.y - ax.y; if (square) { const s = Math.max(Math.abs(vx), Math.abs(vy)); vx = Math.sign(vx || 1) * s; vy = Math.sign(vy || 1) * s; } const xB = ax.x + vx; const yB = ax.y + vy; x0 = Math.min(ax.x, xB); x1 = Math.max(ax.x, xB); y0 = Math.min(ax.y, yB); y1 = Math.max(ax.y, yB); } const c1 = mercatorToWgs84(x0, y0); const c2 = mercatorToWgs84(x1, y0); const c3 = mercatorToWgs84(x1, y1); const c4 = mercatorToWgs84(x0, y1); return [{ lon: c1.lon, lat: c1.lat }, { lon: c2.lon, lat: c2.lat }, { lon: c3.lon, lat: c3.lat }, { lon: c4.lon, lat: c4.lat }]; } /** P10: shift = circle (uniform radius); default fromCenter = legacy (first point = center). Alt = diameter by two clicks. */ function ellipseVerticesFromDrag(p1, p2, opts) { const square = !!(opts && opts.square); const fromCenter = !!(opts && opts.fromCenter); const a = wgs84ToMercator(p1.lon, p1.lat); const b = wgs84ToMercator(p2.lon, p2.lat); let cx, cy, r; if (fromCenter) { cx = a.x; cy = a.y; let vx = b.x - a.x; let vy = b.y - a.y; r = Math.hypot(vx, vy); if (square) r = Math.max(Math.abs(vx), Math.abs(vy)); } else { cx = (a.x + b.x) / 2; cy = (a.y + b.y) / 2; r = Math.hypot(b.x - a.x, b.y - a.y) / 2; if (square) r = Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y)) / 2; } const pts = []; for (let i = 0; i < 36; i++) { const ang = (i / 36) * Math.PI * 2; pts.push(mercatorToWgs84(cx + r * Math.cos(ang), cy + r * Math.sin(ang))); } return pts; } function generateRectangle(p1, p2) { return rectangleVerticesFromDrag(p1, p2, { square: false, fromCenter: false }); } function generateEllipse(center, edge) { return ellipseVerticesFromDrag(center, edge, { square: false, fromCenter: true }); } function getDraftBBox() { const pxs = []; const pushRing = (ring) => { for (const v of ring) { const p = gpsToGlobalPixel(v.lon, v.lat); if (p) pxs.push(p); } }; pushRing(draftVertices); for (const h of draftInteriorRings) pushRing(h); if (!pxs.length) return null; const xs = pxs.map(p => p.x), ys = pxs.map(p => p.y); return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) }; } function getDraftHitRegion(x, y) { hoveredDraftVertexIndex = -1; hoveredDraftMidpointIndex = -1; for (let i = 0; i < draftVertices.length; i++) { const px = gpsToGlobalPixel(draftVertices[i].lon, draftVertices[i].lat); if (px) { const dx = x - px.x; const dy = y - px.y; if (dx * dx + dy * dy <= VERTEX_HIT_RADIUS_SQ) { hoveredDraftVertexIndex = i; return 'vertex'; } } } for (let i = 0; i < draftVertices.length; i++) { const nextIdx = (i + 1) % draftVertices.length; const px1 = gpsToGlobalPixel(draftVertices[i].lon, draftVertices[i].lat); const px2 = gpsToGlobalPixel(draftVertices[nextIdx].lon, draftVertices[nextIdx].lat); if (px1 && px2) { const midX = (px1.x + px2.x) / 2; const midY = (px1.y + px2.y) / 2; const dx = x - midX; const dy = y - midY; if (dx * dx + dy * dy <= VERTEX_HIT_RADIUS_SQ) { hoveredDraftMidpointIndex = i; return 'midpoint'; } } } let b = getDraftBBox(); if (!b) return null; const pad = DRAFT_BBOX_PAD_PX; const pb = { minX: b.minX - pad, maxX: b.maxX + pad, minY: b.minY - pad, maxY: b.maxY + pad }; let cx = (pb.minX + pb.maxX) / 2, cy = (pb.minY + pb.maxY) / 2, H = 6; if (Math.hypot(x - cx, y - (pb.minY - 25)) <= H * 1.5) return 'rotate'; if (Math.abs(x - pb.minX) <= H && Math.abs(y - pb.minY) <= H) return 'resize-nw'; if (Math.abs(x - pb.maxX) <= H && Math.abs(y - pb.minY) <= H) return 'resize-ne'; if (Math.abs(x - pb.minX) <= H && Math.abs(y - pb.maxY) <= H) return 'resize-sw'; if (Math.abs(x - pb.maxX) <= H && Math.abs(y - pb.maxY) <= H) return 'resize-se'; ctx.beginPath(); addAllDraftRingsToCanvasPath(ctx); if (ctx.isPointInPath(x, y, 'evenodd')) return 'move'; return null; } function getCursorForHit(hit) { if (hit === 'vertex' || hit === 'midpoint') return draggingDraftVertexIndex !== -1 ? 'grabbing' : 'grab'; if (hit === 'rotate') return 'crosshair'; if (hit === 'move') return 'move'; if (hit === 'resize-nw' || hit === 'resize-se') return 'nwse-resize'; if (hit === 'resize-ne' || hit === 'resize-sw') return 'nesw-resize'; return 'crosshair'; } function executeDraftTransform(curX, curY) { if (draftAction === 'vertex' && draggingDraftVertexIndex !== -1) { const newGps = globalPixelToGps(curX, curY); if (newGps) draftVertices[draggingDraftVertexIndex] = newGps; return; } let b = dragStartBBox; if (!b) return; const pad = DRAFT_BBOX_PAD_PX; const pb = { minX: b.minX - pad, maxX: b.maxX + pad, minY: b.minY - pad, maxY: b.maxY + pad }; let cx = (pb.minX + pb.maxX) / 2, cy = (pb.minY + pb.maxY) / 2; if (!dragStartDraftRingsPx || dragStartDraftRingsPx.length === 0) return; if (draftAction === 'move') { const dx = curX - dragStartMouse.x, dy = curY - dragStartMouse.y; mapDraftRingsFromPx((p) => globalPixelToGps(p.x + dx, p.y + dy)); } else if (draftAction === 'rotate') { const da = Math.atan2(curY - cy, curX - cx) - Math.atan2(dragStartMouse.y - cy, dragStartMouse.x - cx); mapDraftRingsFromPx((p) => globalPixelToGps( cx + (p.x - cx) * Math.cos(da) - (p.y - cy) * Math.sin(da), cy + (p.x - cx) * Math.sin(da) + (p.y - cy) * Math.cos(da) )); } else if (draftAction.startsWith('resize-')) { const h = draftAction.split('-')[1]; let origin = { x: cx, y: cy }; if (h.includes('n')) origin.y = pb.maxY; if (h.includes('s')) origin.y = pb.minY; if (h.includes('w')) origin.x = pb.maxX; if (h.includes('e')) origin.x = pb.minX; const scaleX = Math.abs(curX - origin.x) / (Math.abs(dragStartMouse.x - origin.x) || 1); const scaleY = Math.abs(curY - origin.y) / (Math.abs(dragStartMouse.y - origin.y) || 1); const signX = Math.sign(curX - origin.x) === Math.sign(dragStartMouse.x - origin.x) ? 1 : -1; const signY = Math.sign(curY - origin.y) === Math.sign(dragStartMouse.y - origin.y) ? 1 : -1; mapDraftRingsFromPx((p) => globalPixelToGps( origin.x + (p.x - origin.x) * (scaleX * signX), origin.y + (p.y - origin.y) * (scaleY * signY) )); } } /** * P10: Shift = union draft with parts that intersect drawnPoly; Alt (without Shift) = difference those parts minus draft. * Non-intersecting parts + links are preserved in order. New geometry from the op gets null links. * @returns {boolean} true if applied (caller skips default boolean path). */ function applyCommitDraftIntersectingModifiers(pc, drawnPoly, prevParts, prevLinks, shiftWins) { const n = prevParts.length; const hitSet = new Set(); for (let i = 0; i < n; i++) { if (prevParts[i] && pc.geometriesIntersect(prevParts[i], drawnPoly)) hitSet.add(i); } const missParts = []; const missLinks = []; const hitParts = []; for (let i = 0; i < n; i++) { if (hitSet.has(i)) hitParts.push(prevParts[i]); else { missParts.push(prevParts[i]); missLinks.push(prevLinks[i]); } } let mergedTail; if (hitParts.length === 0) { mergedTail = drawnPoly; } else { const gHit = hitParts.length === 1 ? hitParts[0] : pc.union(...hitParts); mergedTail = shiftWins ? pc.union(gHit, drawnPoly) : pc.difference(gHit, drawnPoly); } if (!mergedTail || !mergedTail.length) { masterPolygons = missParts.slice(); shapePlaceLinks = missLinks.slice(); logToUI('P10 commit: boolean result empty on intersecting parts; non-intersecting shapes kept.', false); return true; } masterPolygons = missParts.concat(mergedTail); shapePlaceLinks = missLinks.concat(new Array(mergedTail.length).fill(null)); logToUI(shiftWins ? 'Committed (Shift): union with intersecting shapes on active layer.' : 'Committed (Alt): subtract draft from intersecting shapes on active layer.', false); return true; } function commitDraft() { if (!appState.isDraftActive || draftVertices.length < 3) return; const pc = getGeometryEngine(); if (!pc) { logToUI("Geometry library (jsts) is still loading. Wait a moment and try again.", true); return; } const closedPart = buildClosedDraftPartLonLat(); if (!closedPart || !closedPart[0] || closedPart[0].length < 4) return; let drawnPoly = pc.union([closedPart]); drawnPoly = flattenSanitizedDrawnParts(drawnPoly, pc); if (!drawnPoly.length) { logToUI('Draft geometry collapsed after cleanup; nothing saved.', true); return; } commitBeforeChange('draft_commit'); syncActiveLayerAliases(); const prevParts = masterPolygons.slice(); const prevLinks = cloneShapePlaceLinks(shapePlaceLinks); try { if (revertOriginalPolygon) { masterPolygons = [...prevParts]; shapePlaceLinks = [...prevLinks]; const insertIdx = draftPopInsertMasterIndex >= 0 && draftPopInsertMasterIndex <= prevParts.length ? draftPopInsertMasterIndex : prevParts.length; if (appState.mode === 'replace') { // Re-insert the original at its slot, then swap with committed draft (same index + links). masterPolygons.splice(insertIdx, 0, ...revertOriginalPolygon); const ro = cloneLinkEntry(revertOriginalLink); const linksToInsert = revertOriginalPolygon.map((_, i) => (i === 0 ? ro : null)); shapePlaceLinks.splice(insertIdx, 0, ...linksToInsert); masterPolygons.splice(insertIdx, revertOriginalPolygon.length, ...drawnPoly); const rb = cloneLinkEntry(revertOriginalLink); let newLinks; if (!rb) { newLinks = drawnPoly.map(() => null); } else if (drawnPoly.length <= 1) { newLinks = [cloneLinkEntry(rb)]; } else { const splitGroupId = 'sg_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10); newLinks = drawnPoly.map((_, i) => ({ ...cloneLinkEntry(rb), splitGroupId, active: i === 0, broken: i !== 0 })); } shapePlaceLinks.splice(insertIdx, revertOriginalPolygon.length, ...newLinks); logToUI('Committed shape edit.'); } else { // Boolean modes: the popped geometry is already absent from masterPolygons. Do not re-insert it // before union/XOR/etc. — that left a copy at the old position plus the moved draft. const preBoolN = masterPolygons.length; const preBoolLinks = cloneShapePlaceLinks(shapePlaceLinks); if (preBoolN === 0) { masterPolygons = drawnPoly; const rb = cloneLinkEntry(revertOriginalLink); if (!rb) { shapePlaceLinks = drawnPoly.map(() => null); } else if (drawnPoly.length <= 1) { shapePlaceLinks = [cloneLinkEntry(rb)]; } else { const splitGroupId = 'sg_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10); shapePlaceLinks = drawnPoly.map((_, i) => ({ ...cloneLinkEntry(rb), splitGroupId, active: i === 0, broken: i !== 0 })); } logToUI(`Committed shape edit (${appState.mode}).`); } else { if (appState.mode === 'union') { masterPolygons = pc.union(masterPolygons, drawnPoly); } else if (appState.mode === 'difference') { masterPolygons = pc.difference(masterPolygons, drawnPoly); } else if (appState.mode === 'intersection') { masterPolygons = pc.intersection(masterPolygons, drawnPoly); } else if (appState.mode === 'xor') { masterPolygons = pc.xor(masterPolygons, drawnPoly); } const newCount = masterPolygons.length; if (newCount === preBoolN + drawnPoly.length) { const rb = cloneLinkEntry(revertOriginalLink); const tail = !rb ? drawnPoly.map(() => null) : drawnPoly.length <= 1 ? [cloneLinkEntry(rb)] : (() => { const splitGroupId = 'sg_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10); return drawnPoly.map((_, i) => ({ ...cloneLinkEntry(rb), splitGroupId, active: i === 0, broken: i !== 0 })); })(); shapePlaceLinks = preBoolLinks.concat(tail); } else if (newCount === preBoolN) { shapePlaceLinks = preBoolLinks.slice(0, newCount); } else { shapePlaceLinks = new Array(newCount).fill(null); logToUI('Shape–place links cleared (geometry part count changed).', false); } logToUI(`Committed edit via ${appState.mode.toUpperCase()}`); } } } else { const shiftWins = isShiftDown; // both modifiers: shift wins (union) — P10 const useP10Intersect = prevParts.length > 0 && appState.mode !== 'replace' && (isShiftDown || isAltDown); let p10Handled = false; if (useP10Intersect) { p10Handled = applyCommitDraftIntersectingModifiers(pc, drawnPoly, prevParts, prevLinks, shiftWins); } if (!p10Handled) { if (appState.mode === 'replace' || masterPolygons.length === 0) { masterPolygons = drawnPoly; // For brand new replace, wipe everything and initialize links shapePlaceLinks = new Array(masterPolygons.length).fill(null); // Edge case: they popped a shape, then switched to union/intersection, but canvas was empty so it fell back to drawnPoly if (revertOriginalPolygon && prevParts.length === 0 && drawnPoly.length === 1) { const cl = cloneLinkEntry(revertOriginalLink); shapePlaceLinks = [cl || null]; } } else if (appState.mode === 'union') { masterPolygons = pc.union(masterPolygons, drawnPoly); } else if (appState.mode === 'difference') { masterPolygons = pc.difference(masterPolygons, drawnPoly); } else if (appState.mode === 'intersection') { masterPolygons = pc.intersection(masterPolygons, drawnPoly); } else if (appState.mode === 'xor') { masterPolygons = pc.xor(masterPolygons, drawnPoly); } if (!revertOriginalPolygon || appState.mode !== 'replace') { logToUI(`Committed via ${appState.mode.toUpperCase()}`); } const newCount = masterPolygons.length; if (appState.mode !== 'replace' && masterPolygons.length > 0) { if (newCount !== prevParts.length) { shapePlaceLinks = new Array(newCount).fill(null); logToUI('Shape–place links cleared (geometry part count changed).', false); } else { shapePlaceLinks = prevLinks.slice(0, newCount); } } } } expandSanitizedMasterPolygons(pc); alignShapeLinksToMaster(); flushBrushGeometryToActiveLayer(); } catch (e) { logToUI("Math Error. Crossed lines?", true); } revertOriginalPolygon = null; revertOriginalLink = null; setDraftActive(false); pruneShapeInspectorSelection(); scheduleShapeInspectorRefresh(); } // --- 6. RENDERING & TRACKER MATH --- /** Mean map-pixel radius of a geodesic disk (for canvas preview line width). */ function brushMeanRadiusPixels(lon, lat, radiusM) { const c = gpsToGlobalPixel(lon, lat); if (!c) return 6; const pN = destinationPointMeters(lon, lat, 0, radiusM); const pE = destinationPointMeters(lon, lat, Math.PI / 2, radiusM); const pxN = gpsToGlobalPixel(pN.lon, pN.lat); const pxE = gpsToGlobalPixel(pE.lon, pE.lat); if (!pxN || !pxE) return 6; return Math.max(2, (Math.hypot(pxN.x - c.x, pxN.y - c.y) + Math.hypot(pxE.x - c.x, pxE.y - c.y)) / 2); } /** * While dragging: fast Canvas2D “sausage” (round stroke) along sampled GPS path. * Committed geometry is still built once on mouse-up via jsts. */ function subsampleStrokeLonLatForPreview(pts) { if (pts.length <= BRUSH_PREVIEW_MAX_VERTICES) return pts; const stride = Math.ceil(pts.length / BRUSH_PREVIEW_MAX_VERTICES); const out = []; for (let i = 0; i < pts.length - 1; i += stride) out.push(pts[i]); out.push(pts[pts.length - 1]); return out; } /** Stride-subsample a closed lon/lat ring for faster Canvas draws (preview only). */ function decimateLonLatRingForCanvas(ring, maxVerts) { if (!ring || ring.length <= maxVerts) return ring; const closed = ring.length > 1 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]; const open = closed ? ring.slice(0, -1) : ring.slice(); if (open.length <= maxVerts) return ring; const stride = Math.ceil(open.length / maxVerts); const out = []; for (let i = 0; i < open.length; i += stride) out.push(open[i]); if (out.length < 3) return ring; out.push([out[0][0], out[0][1]]); return out; } function masterPartRingsForCanvasDraw(part) { if (!brushPainting || !part || !part.length) return part; return part.map(r => decimateLonLatRingForCanvas(r, BRUSH_LIVE_RENDER_MAX_RING_VERTS)); } function renderBrushLivePreview(ctx) { if (!brushPainting || brushStrokeSamples.length === 0) return; const previewPts = subsampleStrokeLonLatForPreview(brushStrokeSamples); const rm = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || 12)); const mid = previewPts[Math.floor(previewPts.length / 2)]; const rPx = brushMeanRadiusPixels(mid[0], mid[1], rm); const lineW = Math.max(4, rPx * 2); ctx.save(); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.lineWidth = lineW; ctx.globalAlpha = brushIsEraser ? 0.36 : 0.4; ctx.strokeStyle = brushIsEraser ? 'rgba(196,61,61,1)' : 'rgba(0,161,241,1)'; ctx.beginPath(); if (previewPts.length === 1) { const px = gpsToGlobalPixel(previewPts[0][0], previewPts[0][1]); if (px) { ctx.arc(px.x, px.y, rPx, 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); return; } let started = false; previewPts.forEach(pt => { const px = gpsToGlobalPixel(pt[0], pt[1]); if (!px) return; if (!started) { ctx.moveTo(px.x, px.y); started = true; } else { ctx.lineTo(px.x, px.y); } }); if (started) ctx.stroke(); ctx.restore(); } function renderCanvas() { if (!ctx || !canvasElement) return; updateApplyButtonState(); updateModeChip(); const cw = canvasElement.width; const ch = canvasElement.height; ctx.clearRect(0, 0, cw, ch); const mapClip = getMapClipRect(); ctx.save(); if (mapClip) { ctx.beginPath(); ctx.rect(mapClip.x, mapClip.y, mapClip.w, mapClip.h); ctx.clip(); } if (appState.tool !== 'pan') { ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; if (mapClip) ctx.fillRect(mapClip.x, mapClip.y, mapClip.w, mapClip.h); else ctx.fillRect(0, 0, cw, ch); } ctx.lineJoin = 'round'; ctx.lineCap = 'round'; if (appState.tool === 'measure' && tempVertices.length === 2) { let p1 = gpsToGlobalPixel(tempVertices[0].lon, tempVertices[0].lat); let p2 = gpsToGlobalPixel(tempVertices[1].lon, tempVertices[1].lat); if(p1 && p2) { let pxDist = Math.hypot(p2.x - p1.x, p2.y - p1.y); const realDist = hostGeodesicLineLengthMeters( tempVertices[0].lon, tempVertices[0].lat, tempVertices[1].lon, tempVertices[1].lat ); const isImp = isImperialUnits(); let realStr = realDist != null ? formatLength(realDist, isImp) : '—'; ctx.strokeStyle = '#FF5B5B'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(p2.x + 10, p2.y + 10, 130, 40); ctx.fillStyle = '#fff'; ctx.font = '11px monospace'; ctx.fillText(`Px: ${Math.round(pxDist)}`, p2.x + 15, p2.y + 25); ctx.fillText(`Geo: ${realStr}`, p2.x + 15, p2.y + 40); } } const isReplacing = (appState.mode === 'replace') && (isDrawingShape || appState.isDraftActive || tempVertices.length > 0) && !brushPainting; if (placesPreviewLayer.visible && placesPreviewLayer.polygons.length > 0) { ctx.fillStyle = 'rgba(90, 120, 100, 0.06)'; ctx.strokeStyle = 'rgba(70, 100, 85, 0.42)'; ctx.lineWidth = 1; ctx.setLineDash([5, 4]); for (let p = 0; p < placesPreviewLayer.polygons.length; p++) { const drawPart = masterPartRingsForCanvasDraw(placesPreviewLayer.polygons[p]); ctx.beginPath(); for (let r = 0; r < drawPart.length; r++) { const pts = drawPart[r]; for (let i = 0; i < pts.length; i++) { const px = gpsToGlobalPixel(pts[i][0], pts[i][1]); if (px) i === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); } ctx.closePath(); } ctx.fill('evenodd'); ctx.stroke(); } ctx.setLineDash([]); } for (let li = 0; li < userLayers.length; li++) { const uLayer = userLayers[li]; if (uLayer.visible === false) continue; const polys = uLayer.polygons; const links = uLayer.links; const isActiveLayer = uLayer.id === activeLayerId; if (!polys.length) continue; ctx.fillStyle = isReplacing ? 'rgba(0, 161, 241, 0.1)' : 'rgba(0, 161, 241, 0.4)'; ctx.strokeStyle = isReplacing ? 'rgba(0, 161, 241, 0.2)' : '#00A1F1'; ctx.lineWidth = 2; for (let p = 0; p < polys.length; p++) { const drawPart = masterPartRingsForCanvasDraw(polys[p]); ctx.beginPath(); for (let r = 0; r < drawPart.length; r++) { let pts = drawPart[r]; for (let i = 0; i < pts.length; i++) { let px = gpsToGlobalPixel(pts[i][0], pts[i][1]); if (px) i === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); } ctx.closePath(); } ctx.fill('evenodd'); ctx.stroke(); if (isActiveLayer && appState.highlightedLinkIndex === p) { ctx.strokeStyle = '#FFC000'; ctx.lineWidth = 4; ctx.beginPath(); for (let r = 0; r < drawPart.length; r++) { let pts = drawPart[r]; for (let i = 0; i < pts.length; i++) { let px = gpsToGlobalPixel(pts[i][0], pts[i][1]); if (px) i === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); } ctx.closePath(); } ctx.stroke(); ctx.lineWidth = 2; ctx.strokeStyle = isReplacing ? 'rgba(0, 161, 241, 0.2)' : '#00A1F1'; } const ext = polys[p][0]; if (!brushPainting && ext && ext.length && isActiveLayer) { let sx = 0, sy = 0, n = 0; ext.forEach(pt => { const px = gpsToGlobalPixel(pt[0], pt[1]); if (px) { sx += px.x; sy += px.y; n++; } }); if (n) { const bx = sx / n + 8; const by = sy / n - 8; const link = links[p]; const Ln = normalizeShapeLink(link); let prefix = '+'; let isError = false; if (Ln && Ln.venueId != null) { if (Ln.isInvalid && isActiveNonBrokenLink(Ln)) { prefix = '!·'; isError = true; } else if (Ln.broken === true) { prefix = 'B·'; } else if (Ln.active === false) { prefix = 'I·'; } else { prefix = 'U·'; } } const tail = Ln && Ln.venueId != null ? (prefix + String(Ln.venueId).replace(/\D/g, '').slice(-4) || String(Ln.venueId)) : '+'; const text = (p + 1) + ' ' + tail; ctx.save(); ctx.font = 'bold 10px "Helvetica Neue", Helvetica, Arial, sans-serif'; const padX = 5; const padY = 3; const tw = ctx.measureText(text).width; const bw = tw + padX * 2; const bh = 11 + padY * 2; const hx = bx - bw / 2; const hy = by - bh / 2; const hoverHere = (appState.tool === 'revert' || appState.tool === 'link') && hoveredMasterPolyIndex === p; if (isError) { ctx.fillStyle = '#FF5B5B'; } else { ctx.fillStyle = hoverHere ? 'rgba(0,0,0,0.82)' : 'rgba(0,0,0,0.68)'; } ctx.strokeStyle = appState.highlightedLinkIndex === p ? '#FFC000' : (hoverHere ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.35)'); ctx.lineWidth = appState.highlightedLinkIndex === p ? 2.2 : (hoverHere ? 1.6 : 1); ctx.beginPath(); if (typeof ctx.roundRect === 'function') ctx.roundRect(hx, hy, bw, bh, 4); else ctx.rect(hx, hy, bw, bh); ctx.fill(); ctx.stroke(); ctx.fillStyle = '#fff'; ctx.textBaseline = 'middle'; ctx.fillText(text, hx + padX, hy + bh / 2); ctx.restore(); ctx.strokeStyle = isReplacing ? 'rgba(0, 161, 241, 0.2)' : '#00A1F1'; ctx.lineWidth = 2; } } } } for (let li = 0; li < userLayers.length; li++) { const uLayer = userLayers[li]; if (uLayer.visible === false) continue; ensureShapeIdsForLayer(uLayer); const polys = uLayer.polygons; for (let p = 0; p < polys.length; p++) { const csk = csShapeKey(uLayer.id, uLayer.shapeIds[p]); const drawPart = masterPartRingsForCanvasDraw(polys[p]); const hl = csk === csLayersListHoverShapeKey || csk === csLayersMapHoverShapeKey; const sel = csLayersSelection.has(csk); if (!hl && !sel) continue; const trace = () => { ctx.beginPath(); for (let r = 0; r < drawPart.length; r++) { const pts = drawPart[r]; for (let i = 0; i < pts.length; i++) { const px = gpsToGlobalPixel(pts[i][0], pts[i][1]); if (px) i === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); } ctx.closePath(); } }; if (hl) { trace(); ctx.fillStyle = 'rgba(255, 235, 59, 0.3)'; ctx.fill('evenodd'); ctx.strokeStyle = '#f9a825'; ctx.lineWidth = 2; ctx.stroke(); } if (sel) { trace(); ctx.strokeStyle = 'rgba(0, 229, 255, 0.95)'; ctx.lineWidth = 3; ctx.stroke(); } } } renderBrushLivePreview(ctx); // Shape Shifter / link tool hover highlight if ((appState.tool === 'revert' || appState.tool === 'link') && hoveredMasterPolyIndex !== -1 && !appState.isDraftActive) { const hoveredPoly = masterPolygons[hoveredMasterPolyIndex]; if (hoveredPoly && Array.isArray(hoveredPoly)) { ctx.fillStyle = 'rgba(255, 200, 0, 0.5)'; ctx.strokeStyle = '#FFC000'; ctx.lineWidth = 3; ctx.beginPath(); hoveredPoly.forEach(ring => { ring.forEach((pt, idx) => { let px = gpsToGlobalPixel(pt[0], pt[1]); if (px) idx === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); }); }); ctx.fill("evenodd"); ctx.stroke(); } } let modeColor = appState.mode==='union'?'#26CC9A':appState.mode==='difference'?'#FF5B5B':appState.mode==='intersection'?'#FFC000':appState.mode==='xor'?'#a451fa':'#000'; if (appState.isDraftActive && draftVertices.length > 0) { ctx.fillStyle = modeColor; ctx.globalAlpha = 0.3; ctx.beginPath(); addAllDraftRingsToCanvasPath(ctx); ctx.fill('evenodd'); ctx.globalAlpha = 1.0; ctx.strokeStyle = modeColor; ctx.stroke(); for (let i = 0; i < draftVertices.length; i++) { let px = gpsToGlobalPixel(draftVertices[i].lon, draftVertices[i].lat); if (px) { ctx.beginPath(); let isH = (i === hoveredDraftVertexIndex || i === draggingDraftVertexIndex); ctx.arc(px.x, px.y, isH ? 6 : 4, 0, Math.PI * 2); ctx.fillStyle = isH ? modeColor : '#fff'; ctx.strokeStyle = modeColor; ctx.lineWidth = 2; ctx.fill(); ctx.stroke(); } } for (let i = 0; i < draftVertices.length; i++) { const nextIdx = (i + 1) % draftVertices.length; const px1 = gpsToGlobalPixel(draftVertices[i].lon, draftVertices[i].lat); const px2 = gpsToGlobalPixel(draftVertices[nextIdx].lon, draftVertices[nextIdx].lat); if (px1 && px2) { const midX = (px1.x + px2.x) / 2; const midY = (px1.y + px2.y) / 2; ctx.beginPath(); let isH = (i === hoveredDraftMidpointIndex); ctx.arc(midX, midY, isH ? 5 : 3, 0, Math.PI * 2); ctx.fillStyle = isH ? modeColor : 'rgba(255, 255, 255, 0.6)'; ctx.strokeStyle = isH ? modeColor : 'rgba(0, 0, 0, 0.3)'; ctx.lineWidth = 1; ctx.fill(); ctx.stroke(); } } let b = getDraftBBox(); if (b) { const pad = DRAFT_BBOX_PAD_PX; const pb = { minX: b.minX - pad, maxX: b.maxX + pad, minY: b.minY - pad, maxY: b.maxY + pad }; ctx.setLineDash([4, 4]); ctx.strokeRect(pb.minX, pb.minY, pb.maxX - pb.minX, pb.maxY - pb.minY); ctx.setLineDash([]); ctx.fillStyle = '#fff'; ctx.strokeStyle = '#000'; let cx = (pb.minX + pb.maxX) / 2, cy = (pb.minY + pb.maxY) / 2, H = 4; ctx.beginPath(); ctx.moveTo(cx, pb.minY); ctx.lineTo(cx, pb.minY - 25); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, pb.minY - 25, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); [[pb.minX, pb.minY], [cx, pb.minY], [pb.maxX, pb.minY], [pb.maxX, cy], [pb.maxX, pb.maxY], [cx, pb.maxY], [pb.minX, pb.maxY], [pb.minX, cy]].forEach(p => { ctx.fillRect(p[0] - H, p[1] - H, H * 2, H * 2); ctx.strokeRect(p[0] - H, p[1] - H, H * 2, H * 2); }); } } if (tempVertices.length > 0 && appState.tool !== 'measure') { ctx.strokeStyle = modeColor; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.beginPath(); for (let i = 0; i < tempVertices.length; i++) { let px = gpsToGlobalPixel(tempVertices[i].lon, tempVertices[i].lat); if (px) i === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); } if (appState.tool === 'polygon' && currentMousePixel) ctx.lineTo(currentMousePixel.x, currentMousePixel.y); else if (tempVertices.length > 2) ctx.closePath(); ctx.stroke(); ctx.setLineDash([]); if (appState.tool === 'polygon') { for (let i = 0; i < tempVertices.length; i++) { let px = gpsToGlobalPixel(tempVertices[i].lon, tempVertices[i].lat); if (px) { ctx.beginPath(); let isH = (i === hoveredTempVertexIndex || i === draggingVertexIndex); ctx.arc(px.x, px.y, isH ? 6 : 4, 0, Math.PI * 2); ctx.fillStyle = isH ? modeColor : '#fff'; ctx.strokeStyle = modeColor; ctx.lineWidth = 2; ctx.fill(); ctx.stroke(); } } } } if ((appState.tool === 'brush' || appState.tool === 'eraser') && currentMousePixel) { const g = globalPixelToGps(currentMousePixel.x, currentMousePixel.y); if (g) { const rm = Math.max(BRUSH_METERS_MIN, Math.min(BRUSH_METERS_MAX, Number(appState.brushSize) || 12)); const nFull = brushCircleSidesForRadiusMeters(rm); const circCap = brushPainting ? Math.min(BRUSH_LIVE_CURSOR_MAX_SIDES, nFull) : undefined; let ring = geodesicBrushProfileRing(g.lon, g.lat, rm, g.lat, null, 0, circCap); if (appState.brushProfile !== 'circle' && brushPainting) { ring = subsampleClosedRingForPreview(ring, BRUSH_LIVE_CURSOR_MAX_SIDES); } ctx.beginPath(); ring.forEach((pt, idx) => { const px = gpsToGlobalPixel(pt[0], pt[1]); if (px) idx === 0 ? ctx.moveTo(px.x, px.y) : ctx.lineTo(px.x, px.y); }); ctx.closePath(); ctx.strokeStyle = appState.tool === 'eraser' ? 'rgba(196,61,61,0.9)' : 'rgba(26,143,106,0.9)'; ctx.setLineDash([5, 5]); ctx.lineWidth = 2; ctx.stroke(); ctx.setLineDash([]); } } ctx.restore(); } function updateLiveMouseHUD(globalPixel) { const hud = document.getElementById('wme-paint-hud'); if (!hud || !globalPixel || hud.style.display === 'none') return; document.getElementById('hud-px').innerText = `${Math.round(globalPixel.x)}, ${Math.round(globalPixel.y)}`; const liveGps = globalPixelToGps(globalPixel.x, globalPixel.y); if (liveGps) { document.getElementById('hud-gps').innerText = `${liveGps.lon.toFixed(5)}, ${liveGps.lat.toFixed(5)}`; document.getElementById('hud-layer').innerText = `${getActiveLayerDisplayName()} · WME: ${getHoveredLayerLabel()}`; } } function updateTrackerHUD() { const hud = document.getElementById('wme-paint-hud'); if (!hud || hud.style.display === 'none') return; let displayVertices = []; let isCircle = false; if (appState.isDraftActive) { displayVertices = draftVertices; } else if (isDrawingShape && tempVertices.length > 0) { displayVertices = tempVertices; if (appState.tool === 'ellipse') isCircle = true; } else if (masterPolygons.length > 0 && masterPolygons[0][0]) { displayVertices = masterPolygons[0][0].map(pt => ({lon: pt[0], lat: pt[1]})); } const geoContainer = document.getElementById('hud-geo-math'); if (displayVertices.length < 3) { if (appState.targetVenueObj) { geoContainer.innerHTML = '<em>No canvas polygon yet — use Ingest to load the place geometry from WME.</em>'; } else { geoContainer.innerHTML = '<em>No shape active</em>'; } return; } try { const isImp = isImperialUnits(); let pxList = displayVertices.map(v => gpsToGlobalPixel(v.lon, v.lat)).filter(p=>p); let pxArea = getPixelArea(pxList); const geo = hostGeodesicPolygonAreaAndPerimeter(displayVertices); if (!geo) { geoContainer.innerHTML = '<em style="color:#FF5B5B;">Calc Error</em>'; return; } const geoArea = geo.area; const geoPerimeter = geo.perimeter; if (isCircle) { let b = geo.olPoly.getBounds(); let geoRadius = (b.right - b.left) / 2; let pxRadiusText = "N/A"; if(pxList.length > 0) { let xs = pxList.map(p=>p.x); let w = Math.max(...xs) - Math.min(...xs); pxRadiusText = `${Math.round(w/2)} px`; } geoContainer.innerHTML = ` <div>Radius: ${formatLength(geoRadius, isImp)} (${pxRadiusText})</div> <div>Area: ${formatArea(geoArea, isImp)}</div> <div>Px Area: ${Math.round(pxArea).toLocaleString()} px²</div> `; } else { geoContainer.innerHTML = ` <div>Area: ${formatArea(geoArea, isImp)}</div> <div>Perimeter: ${formatLength(geoPerimeter, isImp)}</div> <div>Px Area: ${Math.round(pxArea).toLocaleString()} px²</div> `; } } catch (e) { geoContainer.innerHTML = '<em style="color:#FF5B5B;">Calc Error</em>'; } } // --- 7. WME I/O --- function ingestWmeSelection() { ingestVenuesIntoNewLayer(getSelectedAreaVenues()); } /** Expand + bridge holes; keep links/sourceIndices aligned with surviving parts (Apply path). */ function sanitizeAndBridgeLayerForApply(pc, polysIn, linksIn) { const ex = expandSanitizedPolygonsAndLinks(pc, polysIn, linksIn); const polys = []; const links = []; const sourceIndices = []; for (let i = 0; i < ex.polygons.length; i++) { let bridged; try { bridged = bridgePartHoles(pc, ex.polygons[i]); } catch (e) { bridged = null; } if (bridged && bridged[0] && bridged[0].length > 2) { polys.push(bridged); links.push(ex.links[i] || null); sourceIndices.push(ex.sourceIndices[i]); } } return { polys, links, sourceIndices }; } /** Geodesic area preferred; fallback deg² shoelace on outer ring (same as pickLargestPartByArea). */ function outerRingAreaMetricForApply(polyPart) { if (!polyPart || !polyPart[0] || polyPart[0].length < 3) return -1; try { const geo = hostGeodesicPolygonAreaAndPerimeter(polyPart[0].map(([lon, lat]) => ({ lon, lat }))); if (geo && Number.isFinite(geo.area) && geo.area > 0) return geo.area; } catch (e) { /* ignore */ } return ringAreaAbsDeg(polyPart[0]); } function getVenueDisplayNameForSdkApply(venueId) { try { const v = wmeSDK.DataModel.Venues.getById({ venueId: normalizeVenueIdForSdk(venueId) }); if (!v) return null; return v.name || v.attributes?.name || null; } catch (e) { return null; } } function addVenueFromApply(sdkPolygon, optionalName) { const base = { category: 'OTHER', geometry: sdkPolygon }; if (optionalName) base.name = optionalName; try { const r = wmeSDK.DataModel.Venues.addVenue(base); if (r == null) return null; if (r.venueId != null) return normalizeVenueIdForSdk(r.venueId); if (typeof r === 'number' || typeof r === 'string') return normalizeVenueIdForSdk(r); if (r.id != null) return normalizeVenueIdForSdk(r.id); } catch (e1) { try { const r2 = wmeSDK.DataModel.Venues.addVenue({ category: 'OTHER', geometry: sdkPolygon }); if (r2 && r2.venueId != null) return normalizeVenueIdForSdk(r2.venueId); } catch (e2) { /* ignore */ } } return null; } function injectToWaze() { endBrushPainting(); if (appState.isDraftActive) commitDraft(); if (!isGeometryLibraryReady()) { logToUI('Apply disabled: jsts is not available.', true); return; } if (!hasAnyUserLayerPolygons()) { logToUI('Nothing to inject.', true); return; } if (!wmeSDK || !wmeSDK.DataModel || !wmeSDK.DataModel.Venues) { logToUI('Injection Failed: SDK not ready.', true); return; } const pc = getGeometryEngine(); if (!pc) { logToUI('Apply disabled: geometry engine unavailable.', true); return; } const missing = []; for (let li = 0; li < userLayers.length; li++) { const layer = userLayers[li]; if (layer.isPlaces || layer.visible === false) continue; let polys = cloneMasterPolygons(layer.polygons); let links = cloneShapePlaceLinks(layer.links); if (!polys.length) continue; let sanitized; try { sanitized = sanitizeAndBridgeLayerForApply(pc, polys, links); } catch (e) { console.error('Apply: layer cleanup failed', layer.name, e); continue; } polys = sanitized.polys; links = sanitized.links; for (let i = 0; i < links.length; i++) { const link = normalizeShapeLink(links[i]); if (!link || link.venueId == null || !linkBlocksApplyValidation(link)) continue; const v = wmeSDK.DataModel.Venues.getById({ venueId: link.venueId }); if (!v) missing.push({ layer: layer.name, i, id: link.venueId }); } } if (missing.length) { logToUI('Apply blocked: active linked venue(s) not found: ' + missing.map(x => `${x.layer} part ${x.i + 1} → ${x.id}`).join(', '), true); return; } try { let updateCounter = 0; for (let li = 0; li < userLayers.length; li++) { const layer = userLayers[li]; if (layer.isPlaces || layer.visible === false) continue; let polys = cloneMasterPolygons(layer.polygons); let links = cloneShapePlaceLinks(layer.links); if (!polys.length) continue; let sanitized; try { sanitized = sanitizeAndBridgeLayerForApply(pc, polys, links); } catch (e) { console.error('Apply: layer cleanup failed', layer.name, e); continue; } polys = sanitized.polys; links = sanitized.links; const sourceIndices = sanitized.sourceIndices; if (!polys.length) continue; const splitGroups = new Map(); for (let i = 0; i < links.length; i++) { const lk = links[i]; if (lk && lk.splitGroupId) { if (!splitGroups.has(lk.splitGroupId)) splitGroups.set(lk.splitGroupId, []); splitGroups.get(lk.splitGroupId).push(i); } } const handled = new Set(); for (let i = 0; i < polys.length; i++) { if (handled.has(i)) continue; const linkRaw = links[i]; const link = normalizeShapeLink(linkRaw); const sg = link && link.splitGroupId; if (sg && splitGroups.has(sg)) { const idxs = splitGroups.get(sg); let bestIdx = idxs[0]; let bestA = -1; for (let j = 0; j < idxs.length; j++) { const ii = idxs[j]; const a = outerRingAreaMetricForApply(polys[ii]); if (a > bestA) { bestA = a; bestIdx = ii; } } const srcVenueId = normalizeVenueIdForSdk(links[bestIdx].venueId); const winnerShapeIdx = sourceIndices[bestIdx]; breakCompetingVenueLinksForVenue(srcVenueId, layer, winnerShapeIdx); wmeSDK.DataModel.Venues.updateVenue({ venueId: srcVenueId, geometry: masterPolyPartToSdkPolygon(polys[bestIdx]) }); updateCounter++; const baseName = getVenueDisplayNameForSdkApply(srcVenueId) || 'Place'; const losers = idxs.filter(ii => ii !== bestIdx).sort((a, b) => outerRingAreaMetricForApply(polys[b]) - outerRingAreaMetricForApply(polys[a])); const usedShapeIdxForSplit = new Set([winnerShapeIdx]); for (let ord = 0; ord < losers.length; ord++) { const ii = losers[ord]; const suffix = ord + 2; const cloneName = `${baseName} (${suffix})`; const newId = addVenueFromApply(masterPolyPartToSdkPolygon(polys[ii]), cloneName); if (newId != null) { const sh = sourceIndices[ii]; if (sh >= 0 && !usedShapeIdxForSplit.has(sh) && sh < layer.links.length) { usedShapeIdxForSplit.add(sh); layer.links[sh] = normalizeShapeLink({ venueId: newId, active: true, broken: false }); } else { const partCopy = cloneMasterPolygons([polys[ii]])[0]; if (partCopy) { layer.polygons.push(partCopy); layer.links.push(normalizeShapeLink({ venueId: newId, active: true, broken: false })); } } } updateCounter++; } for (let j = 0; j < idxs.length; j++) handled.add(idxs[j]); continue; } const sdkPolygon = masterPolyPartToSdkPolygon(polys[i]); const sh = sourceIndices[i]; if (isActiveNonBrokenLink(link)) { breakCompetingVenueLinksForVenue(link.venueId, layer, sh); wmeSDK.DataModel.Venues.updateVenue({ venueId: link.venueId, geometry: sdkPolygon }); } else if (link && link.venueId != null) { const baseName = getVenueDisplayNameForSdkApply(link.venueId); const nm = link.broken === true && baseName ? `${baseName} (split)` : baseName; const newId = addVenueFromApply(sdkPolygon, nm || undefined); if (newId != null && sh >= 0 && sh < layer.links.length) { layer.links[sh] = normalizeShapeLink({ venueId: newId, active: true, broken: false }); } } else { addVenueFromApply(sdkPolygon); } updateCounter++; } ensureShapeIdsForLayer(layer); } if (!updateCounter) { logToUI('Apply: no valid geometry after cleanup.', true); return; } rebalanceVenueLinkActives(); syncActiveLayerAliases(); scheduleDocumentSave(); logToUI(`Injected ${updateCounter} venue(s).`); clearHistory(); changeTool('pan'); } catch (e) { let detail = 'Injection failed.'; const m = e && e.message ? String(e.message) : ''; if (e && e.name === 'InvalidStateError' || m.includes('Not allowed to update geometry')) { detail = 'WME blocked geometry update (venue locked, not selected for edit, or editing disabled). Select an editable venue and try Apply again.'; } logToUI(detail, true); console.error(e); } } bootstrap(); })();