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'] }});