diff --git a/server/mapcontrol_server/main.py b/server/mapcontrol_server/main.py index 8d96a77..d4f44d8 100644 --- a/server/mapcontrol_server/main.py +++ b/server/mapcontrol_server/main.py @@ -513,12 +513,14 @@ def _thumbnail_url(entry) -> str: }} * {{ margin: 0; padding: 0; box-sizing: border-box; }} html, body, #map {{ width: 100%; height: 100%; font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; }} - /* Theme the canvas surround ("space" around the globe / letterboxing): + /* The canvas surround ("space" around the globe / letterboxing): MapLibre's canvas is transparent where the style paints nothing, so - the page background shows through. Bind it to the theme tokens so a - dark map is visibly dark even at globe zoom, live-updating with - data-theme flips from set_theme / prefers-color-scheme. */ - html, body, #map {{ background: var(--eo-bg); }} + the page background shows through. Space is SPACE in both themes — + a deep navy; binding it to --eo-bg made light mode render a white + void behind the planet. Page chrome (html/body) keeps the theme + background for anything outside the map element. */ + html, body {{ background: var(--eo-bg); }} + #map {{ background: #0b1220; }} /* ─── ?ui=none — the naked canvas ───────────────────────────────── The canonical ESIP design: a bare map that only renders assets and emits interaction events. No draw toolbar, no basemap picker, no @@ -572,6 +574,25 @@ def _thumbnail_url(entry) -> str: }} .draw-toolbar button svg {{ width: 18px; height: 18px; display: block; }} .delete-mode-active {{ cursor: crosshair !important; }} + /* Crosshair while a draw mode is armed — MapLibre's grab cursor read + as "pan", hiding that the map was ready for sketching. Same + class-on-canvas pattern as delete mode; !important outranks the + inline cursor MapLibre / the hover-highlight code set. */ + .draw-mode-active {{ cursor: crosshair !important; }} + /* MapLibre's stock control hover (.maplibregl-ctrl button:hover → + translucent rgba(0,0,0,.05)) outranks our themed hovers on + specificity, blanking the trigger's solid surface over the map on + hover. Re-assert the themed surfaces at higher specificity. */ + .maplibregl-ctrl button.basemap-trigger:not(:disabled):hover {{ + background-color: var(--eo-surface); + border-color: var(--eo-accent); + }} + .maplibregl-ctrl button.basemap-tile:not(:disabled):hover {{ + background-color: var(--eo-surface-hover); + }} + .maplibregl-ctrl.draw-toolbar button:not(:disabled):hover {{ + background-color: var(--eo-surface-hover); + }} /* ─── Basemap picker (collapsed dropdown) ───────────────────────── A trigger button that expands into a grouped thumbnail grid. Styled to match the EO-GPT palette via the theme tokens above. @@ -889,6 +910,45 @@ class BasemapPickerControl {{ panel.appendChild(grid); }} + // ── Availability probe ───────────────────────────────── + // A basemap whose preview tile can't load almost certainly + // can't serve map tiles either (invalid/limited API key, + // plan-gated style, provider outage) — config-load only + // drops entries whose key env var is UNSET, so a set-but- + // broken key still lists them. Probe each thumbnail and + // remove failing tiles instead of showing blank previews + // that break when clicked. The current basemap is never + // removed (it's demonstrably rendering). + const containerEl = this._container; + const pruneEmptyGroups = () => {{ + panel.querySelectorAll('.basemap-group-label').forEach((label) => {{ + const grid = label.nextElementSibling; + if (grid && grid.classList.contains('basemap-group-grid') && + grid.children.length === 0) {{ + grid.remove(); + label.remove(); + }} + }}); + if (Object.keys(_basemapTiles).length < 2) {{ + containerEl.style.display = 'none'; + }} + }}; + for (const id of basemapIds) {{ + const entry = BASEMAPS[id]; + if (!entry.thumbnail) continue; + const probe = new Image(); + probe.onerror = () => {{ + if (id === currentBasemap) return; + const tile = _basemapTiles[id]; + if (!tile) return; + tile.remove(); + delete _basemapTiles[id]; + pruneEmptyGroups(); + console.warn('Basemap preview tile failed — hidden from picker:', id); + }}; + probe.src = entry.thumbnail; + }} + const setOpen = (open) => {{ trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); panel.setAttribute('data-open', open ? 'true' : 'false'); @@ -1651,6 +1711,7 @@ class BasemapPickerControl {{ // static value while running. if (s.opacity !== undefined && s.opacity !== null) applyStaticOpacity(assetId, s.opacity); if ((Array.isArray(s.animate) && s.animate.length > 0) || s.glow) registerAnim(assetId, s); + raiseDrawLayers(); }} // ─── Add Image Overlay (GeoTIFF) ─── @@ -1699,6 +1760,7 @@ class BasemapPickerControl {{ }}, beforeId); const b = new maplibregl.LngLatBounds([bounds[0], bounds[1]], [bounds[2], bounds[3]]); assetRegistry[assetId] = {{ layerIds: [layerId], bounds: b, srcId, name: name || null, asset_type: assetType || 'geotiff' }}; + raiseDrawLayers(); }} // ─── deck.gl overlay: true 3D arcs (sketch 003 → deck ArcLayer) ─── @@ -1821,6 +1883,22 @@ class BasemapPickerControl {{ // it and showing nothing until the round trip completed). const pendingDraws = {{}}; // client_id -> {{ srcId, layerIds }} + // Keep the live sketch (Terra Draw's td-* layers) and the dashed + // pending previews above every asset layer. New asset layers append + // to the top of the style and reorder_assets lifts assets with + // moveLayer — either buried an in-progress drawing under overlays. + // Called after any asset layer add and after reorders. + function raiseDrawLayers() {{ + try {{ + const layers = (map.getStyle() || {{}}).layers || []; + for (const l of layers) {{ + if (l.id.startsWith('td-') || l.id.startsWith('pending-')) {{ + try {{ map.moveLayer(l.id); }} catch (e) {{ /* mid-rebuild */ }} + }} + }} + }} catch (e) {{ /* style not ready */ }} + }} + function addPendingDraw(clientId, geojson) {{ const srcId = 'pending-src-' + clientId; const fillId = 'pending-fill-' + clientId; @@ -2146,6 +2224,7 @@ class BasemapPickerControl {{ box: 'box', rectangle: 'box', polygon: 'polygon', circle: 'circle', line: 'line', linestring: 'line', + freedraw: 'freedraw', freehand: 'freedraw', }}[mode] || 'polygon'; setDrawMode(normalized); }}, @@ -2284,6 +2363,7 @@ class BasemapPickerControl {{ }} // Keep the shared mask beneath the lowest masked asset. if (map.getLayer(MASK_LAYER_ID)) rebuildMask(); + raiseDrawLayers(); console.log('Moved layer:', data.asset_id, position); }}, @@ -2306,6 +2386,7 @@ class BasemapPickerControl {{ }} // Keep the shared mask beneath the lowest masked asset. if (map.getLayer(MASK_LAYER_ID)) rebuildMask(); + raiseDrawLayers(); console.log('Reordered assets (top first):', data.asset_ids); }}, }}; @@ -2445,7 +2526,13 @@ class BasemapPickerControl {{ handlers.set_basemap({{ basemap: snapshot.basemap }}); }} // Restore theme (map-level; light/dark/auto). CSS-only, cheap. - if (snapshot.theme) {{ + // Same pinning rule as basemap: an explicit ?theme= from the + // embedding page wins over the stored map-level theme, otherwise + // a snapshot on WS (re)connect silently flips a light-booted page + // to a previously persisted dark (or vice versa). Live set_theme + // broadcasts still apply — this only mutes the stale replay. + const urlPinnedTheme = new URLSearchParams(window.location.search).has('theme'); + if (snapshot.theme && !urlPinnedTheme) {{ applyTheme(snapshot.theme); }} // Restore view mode BEFORE viewport — setProjection('globe') can @@ -2536,13 +2623,15 @@ class BasemapPickerControl {{ // setStyle and dead-locked into 60s retry timeouts). The toolbar is // entirely ours, themed with the page tokens (light/dark aware). let drawInstance = null; - let currentDrawMode = null; // 'polygon' | 'box' | 'circle' | 'line' | null + let currentDrawMode = null; // 'polygon' | 'box' | 'circle' | 'line' | 'freedraw' | null let deleteMode = false; const drawnAssetStack = []; // undo stack: tracks asset IDs of user-drawn features - // our draw-type names ↔ Terra Draw mode names - const DRAW_MODE_FOR = {{ polygon: 'polygon', box: 'rectangle', circle: 'circle', line: 'linestring' }}; - const DRAW_TYPE_FOR = {{ polygon: 'polygon', rectangle: 'box', circle: 'circle', linestring: 'line' }}; + // our draw-type names ↔ Terra Draw mode names. Freehand produces a + // polygon geometry, so its finished features re-enter the pipeline + // as draw_type 'polygon' — the server needs no new type. + const DRAW_MODE_FOR = {{ polygon: 'polygon', box: 'rectangle', circle: 'circle', line: 'linestring', freedraw: 'freehand' }}; + const DRAW_TYPE_FOR = {{ polygon: 'polygon', rectangle: 'box', circle: 'circle', linestring: 'line', freehand: 'polygon' }}; // In-progress geometry styled from the live theme tokens so drawing // matches the product in light AND dark (issue: map controls ignored @@ -2593,6 +2682,9 @@ class BasemapPickerControl {{ }} catch (e) {{ console.warn('setDrawMode failed:', e.message); }} + // Crosshair while armed (see .draw-mode-active) — the default + // grab cursor made it unclear the map was ready for sketching. + map.getCanvas().classList.toggle('draw-mode-active', currentDrawMode !== null); updateDrawToolbar(); }} @@ -2602,6 +2694,7 @@ class BasemapPickerControl {{ polygon: '', box: '', circle: '', + freedraw: '', line: '', pan: '', del: '', @@ -2630,6 +2723,9 @@ class DrawToolbarControl {{ add('Draw circle', DRAW_ICONS.circle, () => setDrawMode(currentDrawMode === 'circle' ? null : 'circle'), {{ 'data-draw-mode': 'circle' }}); + add('Draw freehand', DRAW_ICONS.freedraw, + () => setDrawMode(currentDrawMode === 'freedraw' ? null : 'freedraw'), + {{ 'data-draw-mode': 'freedraw' }}); add('Draw line', DRAW_ICONS.line, () => setDrawMode(currentDrawMode === 'line' ? null : 'line'), {{ 'data-draw-mode': 'line' }}); @@ -2656,6 +2752,7 @@ class DrawToolbarControl {{ new td.TerraDrawRectangleMode({{ styles: _areaStyles() }}), new td.TerraDrawCircleMode({{ styles: _areaStyles() }}), new td.TerraDrawLineStringMode({{ styles: _lineStyles() }}), + new td.TerraDrawFreehandMode({{ styles: _areaStyles() }}), ], }}); drawInstance.start(); @@ -2670,6 +2767,7 @@ class DrawToolbarControl {{ drawInstance.updateModeOptions('rectangle', {{ styles: _areaStyles() }}); drawInstance.updateModeOptions('circle', {{ styles: _areaStyles() }}); drawInstance.updateModeOptions('linestring', {{ styles: _lineStyles() }}); + drawInstance.updateModeOptions('freehand', {{ styles: _areaStyles() }}); }} catch (e) {{ /* styles refresh is cosmetic */ }} }}).observe(document.documentElement, {{ attributes: true, attributeFilter: ['data-theme'] }});