diff --git a/server/mapcontrol_server/main.py b/server/mapcontrol_server/main.py index d4f44d8..5b279a2 100644 --- a/server/mapcontrol_server/main.py +++ b/server/mapcontrol_server/main.py @@ -998,6 +998,17 @@ class BasemapPickerControl {{ center: DEFAULTS.center, zoom: DEFAULTS.zoom, preserveDrawingBuffer: true, + // Default cache is sized to the current viewport, so panning + // back over a just-visited area re-fetches and re-fades tiles. + // 512 tiles/source (~tens of MB) keeps a session's working set + // hot — revisits render instantly instead of flashing blanks. + maxTileCacheSize: 512, + // Default (true) abandons in-flight tile requests mid-zoom, so + // a zoom gesture ends on blank chunks that then restart from + // zero. Keeping the requests alive costs some now-stale-zoom + // bandwidth — accepted tradeoff, bandwidth is not our + // bottleneck — and fills the end-of-zoom frame much sooner. + cancelPendingTileRequestsWhileZooming: false, }}; // Server-resolved initial camera: when the map has a persisted or // inherited bbox viewport, start FITTED to it so the very first tiles @@ -1630,8 +1641,65 @@ class BasemapPickerControl {{ clearHoverHL(); }}); + // ─── Click selection (issue #111: hover popup → click-to-select) ─── + // Clicking an asset emphasizes its borders (thicker, fully opaque + // stroke in the asset's own color); clicking empty map clears it. + // The embedding app pairs this with its own selection card via the + // contract's asset_click / map_click events — this block is only the + // in-map visual. Original paint values are saved per layer and + // restored verbatim on deselect, so custom styles survive. + const selection = {{ assetId: null, saved: null }}; + function deselectAsset() {{ + if (!selection.assetId) return; + if (selection.saved) {{ + for (const [lid, props] of Object.entries(selection.saved)) {{ + if (!map.getLayer(lid)) continue; + for (const [k, v] of Object.entries(props)) {{ + try {{ map.setPaintProperty(lid, k, v); }} catch (e) {{}} + }} + }} + }} + selection.assetId = null; + selection.saved = null; + }} + function selectAsset(assetId) {{ + if (selection.assetId === assetId) return; + deselectAsset(); + const reg = assetRegistry[assetId]; + if (!reg) return; + const saved = {{}}; + for (const lid of reg.layerIds) {{ + const layer = map.getLayer(lid); + if (!layer || layer.type !== 'line') continue; + const w = map.getPaintProperty(lid, 'line-width'); + saved[lid] = {{ + 'line-width': w, + 'line-opacity': map.getPaintProperty(lid, 'line-opacity'), + }}; + try {{ + map.setPaintProperty(lid, 'line-width', (typeof w === 'number' ? w : 2) + 2); + map.setPaintProperty(lid, 'line-opacity', 1); + }} catch (e) {{}} + }} + if (Object.keys(saved).length === 0) return; // no stroke to emphasize + selection.assetId = assetId; + selection.saved = saved; + }} + map.on('click', (e) => {{ + if (currentDrawMode !== null || deleteMode) return; + const layerIds = []; + for (const reg of Object.values(assetRegistry)) {{ + for (const lid of reg.layerIds) if (map.getLayer(lid)) layerIds.push(lid); + }} + const feats = layerIds.length ? map.queryRenderedFeatures(e.point, {{ layers: layerIds }}) : []; + if (!feats.length) {{ deselectAsset(); return; }} + for (const [aid, reg] of Object.entries(assetRegistry)) {{ + if (reg.layerIds.includes(feats[0].layer.id)) {{ selectAsset(aid); return; }} + }} + }}); + // ─── Add GeoJSON to map ─── - function addGeoJSON(assetId, geojsonStr, style, visible, name, assetType) {{ + function addGeoJSON(assetId, geojsonStr, style, visible, name, assetType, zIndex) {{ const geojson = typeof geojsonStr === 'string' ? JSON.parse(geojsonStr) : geojsonStr; const srcId = 'src-' + assetId; const vis = visible !== false ? 'visible' : 'none'; @@ -1702,7 +1770,8 @@ class BasemapPickerControl {{ if (labelId) layerIds.push(labelId); // geojson kept so update_style can rebuild the mask layer later. - assetRegistry[assetId] = {{ layerIds, bounds: geojsonBounds(geojson), srcId, name: name || null, asset_type: assetType || 'vector', geomTypes: Array.from(geomTypes), geojson }}; + assetRegistry[assetId] = {{ layerIds, bounds: geojsonBounds(geojson), srcId, name: name || null, asset_type: assetType || 'vector', geomTypes: Array.from(geomTypes), geojson, zIndex }}; + applyZPosition(assetId); // Optional mask (style.mask): darken everything outside the polygon if (s.mask && geomTypes.has('fill')) setAssetMask(assetId, s.mask); @@ -1715,20 +1784,81 @@ class BasemapPickerControl {{ }} // ─── Add Image Overlay (GeoTIFF) ─── - // Find the first vector layer ID (fill/line/circle) so rasters insert below it + // Bottom-most vector layer in ACTUAL style order (fill/line/circle of + // a user asset), so legacy rasters (no z_index in the payload) insert + // below it. Registry-iteration order lied after any reorder. function getFirstVectorLayerId() {{ - for (const [id, reg] of Object.entries(assetRegistry)) {{ - for (const lid of reg.layerIds) {{ - const layer = map.getLayer(lid); - if (layer && (layer.type === 'fill' || layer.type === 'line' || layer.type === 'circle')) {{ - return lid; - }} + const owned = new Set(); + for (const reg of Object.values(assetRegistry)) {{ + for (const lid of reg.layerIds) owned.add(lid); + }} + const layers = (map.getStyle() || {{}}).layers || []; + for (const l of layers) {{ + if (owned.has(l.id) && (l.type === 'fill' || l.type === 'line' || l.type === 'circle')) {{ + return l.id; }} }} return undefined; // no vector layers yet }} - async function addImageOverlay(assetId, imageUrl, bounds, opacity, visible, name, assetType) {{ + // ─── z_index-driven stacking ─── + // The server's z_index is the canonical order (it's what layer + // managers list and what session restore replays). A new asset slots + // in below the closest asset with a HIGHER z: return that asset's + // bottom-most live layer as the moveLayer/addLayer beforeId, or + // undefined to stack on top. + function beforeIdForZ(z) {{ + if (z === undefined || z === null) return undefined; + let above = null; + for (const reg of Object.values(assetRegistry)) {{ + if (reg.zIndex === undefined || reg.zIndex === null) continue; + if (reg.zIndex > z && (above === null || reg.zIndex < above.zIndex)) above = reg; + }} + if (!above) return undefined; + for (const lid of above.layerIds) {{ + if (map.getLayer(lid)) return lid; // layerIds[0] is bottom-most + }} + return undefined; + }} + + // Slot an asset's freshly-added layers into their z position (layers + // are appended on top by addLayer; move preserves their internal + // fill→line→circle→label order because each insert lands before + // beforeId and after the previously moved one). + function applyZPosition(assetId) {{ + const reg = assetRegistry[assetId]; + if (!reg) return; + const beforeId = beforeIdForZ(reg.zIndex); + if (!beforeId) return; + for (const lid of reg.layerIds) {{ + if (map.getLayer(lid)) {{ + try {{ map.moveLayer(lid, beforeId); }} catch (e) {{}} + }} + }} + }} + + // After a client-side re-stack (move_layer / reorder_assets), refresh + // every registry zIndex from the ACTUAL style order so later inserts + // compute their slot against reality. (Absolute values don't need to + // match the server's — only the relative order does.) + function syncRegistryZOrder() {{ + const layerToAsset = {{}}; + for (const [aid, reg] of Object.entries(assetRegistry)) {{ + for (const lid of reg.layerIds) layerToAsset[lid] = aid; + }} + const layers = (map.getStyle() || {{}}).layers || []; + let z = 0; + const seen = new Set(); + for (const l of layers) {{ + const aid = layerToAsset[l.id]; + if (aid && !seen.has(aid)) {{ + seen.add(aid); + assetRegistry[aid].zIndex = z++; + }} + }} + }} + + async function addImageOverlay(assetId, imageUrl, bounds, opacity, visible, name, assetType, zIndex) {{ // bounds = [minLon, minLat, maxLon, maxLat] const fullUrl = imageUrl.startsWith('http') ? imageUrl : BASE_URL + imageUrl; const srcId = 'src-' + assetId; @@ -1751,15 +1881,18 @@ class BasemapPickerControl {{ console.warn('[GeoTIFF] Blob fetch failed, using direct URL:', e.message); }} map.addSource(srcId, {{ type: 'image', url: imgSrc, coordinates: coords }}); - // Insert raster below any existing vector layers (vectors always on top) - const beforeId = getFirstVectorLayerId(); + // Slot per the server's canonical z_index; a legacy broadcast + // without one falls back to "below all vectors" (the same rule + // the server now encodes at creation). + const beforeId = (zIndex !== undefined && zIndex !== null) + ? beforeIdForZ(zIndex) : getFirstVectorLayerId(); map.addLayer({{ id: layerId, type: 'raster', source: srcId, paint: {{ 'raster-opacity': opacity || 1.0 }}, layout: {{ visibility: visible !== false ? 'visible' : 'none' }}, }}, 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' }}; + assetRegistry[assetId] = {{ layerIds: [layerId], bounds: b, srcId, name: name || null, asset_type: assetType || 'geotiff', zIndex }}; raiseDrawLayers(); }} @@ -1818,7 +1951,7 @@ class BasemapPickerControl {{ // circle/label layers for the endpoint dots. `from`/`to` come from // the live add_arc broadcast; on session restore they're recovered // from the stored FeatureCollection's Point features. - function addArc(assetId, geojsonStr, style, visible, name, from, to, curvature) {{ + function addArc(assetId, geojsonStr, style, visible, name, from, to, curvature, zIndex) {{ const gj = typeof geojsonStr === 'string' ? JSON.parse(geojsonStr) : geojsonStr; if (!from || !to) {{ const pts = (gj.features || []).filter(f => f.geometry && f.geometry.type === 'Point'); @@ -1835,7 +1968,7 @@ class BasemapPickerControl {{ // mercator). The deck 3D ribbon is layered on top only in // mercator mode; syncArcMode() then hides the flat line so the // two never double-draw. - addGeoJSON(assetId, gj, style, visible, name, 'arc'); + addGeoJSON(assetId, gj, style, visible, name, 'arc', zIndex); if (!deckOverlay || !from || !to) return; if (assetRegistry[assetId]) {{ assetRegistry[assetId].isDeckArc = true; @@ -1971,18 +2104,19 @@ class BasemapPickerControl {{ // Drawn-shape echo: drop our pending-styled copy first (no-op // for other sessions / agent-created polygons). removePendingDraw(data.client_id); - addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'polygon'); + addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'polygon', data.z_index); }}, - add_polygon_url(data) {{ addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'polygon'); }}, - add_path(data) {{ addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'path'); }}, - add_path_url(data) {{ addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'path'); }}, - add_point(data) {{ addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'point'); }}, + add_polygon_url(data) {{ addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'polygon', data.z_index); }}, + add_path(data) {{ addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'path', data.z_index); }}, + add_path_url(data) {{ addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'path', data.z_index); }}, + add_point(data) {{ addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'point', data.z_index); }}, // Arc: 3D deck.gl ArcLayer (+ MapLibre endpoint dots/labels); // falls back to the stored flat LineString if deck is unavailable. - add_arc(data) {{ addArc(data.asset_id, data.geojson, data.style, true, data.name, data.from, data.to, data.curvature); }}, + add_arc(data) {{ addArc(data.asset_id, data.geojson, data.style, true, data.name, data.from, data.to, data.curvature, data.z_index); }}, // ─── Asset management ─── delete_asset(data) {{ + if (selection.assetId === data.asset_id) deselectAsset(); const reg = assetRegistry[data.asset_id]; if (reg) {{ for (const lid of reg.layerIds) {{ @@ -2015,6 +2149,10 @@ class BasemapPickerControl {{ }} }}, update_style(data) {{ + // Drop any selection emphasis first: the saved paint values + // are about to go stale, and restoring them later would undo + // this style change. + if (selection.assetId === data.asset_id) deselectAsset(); const reg = assetRegistry[data.asset_id]; if (reg && data.style) {{ const s = data.style; @@ -2209,10 +2347,10 @@ class BasemapPickerControl {{ // ─── GeoTIFF Handlers ─── add_geotiff_rgb(data) {{ - addImageOverlay(data.asset_id, data.image_url, data.bounds, data.alpha || 1.0, true, data.name, 'geotiff_rgb'); + addImageOverlay(data.asset_id, data.image_url, data.bounds, data.alpha || 1.0, true, data.name, 'geotiff_rgb', data.z_index); }}, add_geotiff_singleband(data) {{ - addImageOverlay(data.asset_id, data.image_url, data.bounds, data.alpha || 1.0, true, data.name, 'geotiff_singleband'); + addImageOverlay(data.asset_id, data.image_url, data.bounds, data.alpha || 1.0, true, data.name, 'geotiff_singleband', data.z_index); }}, // ─── Drawing Control (from SDK/API) ─── @@ -2235,7 +2373,7 @@ class BasemapPickerControl {{ // ─── Drawn asset from server (add_drawn_polygon) ─── add_drawn_polygon(data) {{ removePendingDraw(data.client_id); - addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'drawn_polygon'); + addGeoJSON(data.asset_id, data.geojson, data.style, true, data.name, 'drawn_polygon', data.z_index); }}, // ─── Draw confirmation (push to undo stack) ─── @@ -2269,8 +2407,10 @@ class BasemapPickerControl {{ attribution: data.attribution || '', }}); - // Insert tile layers below vector layers (same as GeoTIFFs) - const beforeId = getFirstVectorLayerId(); + // Slot per canonical z_index (legacy fallback: below vectors, + // same as GeoTIFFs) + const beforeId = (data.z_index !== undefined && data.z_index !== null) + ? beforeIdForZ(data.z_index) : getFirstVectorLayerId(); map.addLayer({{ id: layerId, type: 'raster', @@ -2279,7 +2419,7 @@ class BasemapPickerControl {{ layout: {{ visibility: vis }}, }}, beforeId); - assetRegistry[assetId] = {{ layerIds: [layerId], bounds: null, srcId, tileUrl: url, name: data.name || null, asset_type: 'tile' }}; + assetRegistry[assetId] = {{ layerIds: [layerId], bounds: null, srcId, tileUrl: url, name: data.name || null, asset_type: 'tile', zIndex: data.z_index }}; console.log('Added tile layer:', assetId, url, 'opacity:', opacity); }}, @@ -2364,6 +2504,7 @@ class BasemapPickerControl {{ // Keep the shared mask beneath the lowest masked asset. if (map.getLayer(MASK_LAYER_ID)) rebuildMask(); raiseDrawLayers(); + syncRegistryZOrder(); console.log('Moved layer:', data.asset_id, position); }}, @@ -2387,6 +2528,7 @@ class BasemapPickerControl {{ // Keep the shared mask beneath the lowest masked asset. if (map.getLayer(MASK_LAYER_ID)) rebuildMask(); raiseDrawLayers(); + syncRegistryZOrder(); console.log('Reordered assets (top first):', data.asset_ids); }}, }}; @@ -2473,6 +2615,9 @@ class BasemapPickerControl {{ // ─── Session Restore Handler ─── function handleSnapshot(snapshot) {{ + // Selection references layers about to be torn down. + selection.assetId = null; + selection.saved = null; // Clear all existing assets for (const [id, reg] of Object.entries(assetRegistry)) {{ for (const lid of reg.layerIds) {{ @@ -2494,15 +2639,20 @@ class BasemapPickerControl {{ const lons = coords.map(c => c[0]); const lats = coords.map(c => c[1]); const bounds = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)]; - addImageOverlay(asset.asset_id, imageUrl, bounds, 1.0, asset.visible, asset.name, asset.asset_type); + // Snapshot assets arrive bottom-most first (ascending + // z_index) — replaying with each asset's z reproduces the + // persisted stack exactly, INCLUDING user reorders that + // put a raster above vectors (the old below-all-vectors + // heuristic silently undid those on every reconnect). + addImageOverlay(asset.asset_id, imageUrl, bounds, 1.0, asset.visible, asset.name, asset.asset_type, asset.z_index); }} else if (asset.asset_type === 'arc') {{ // 3D deck arc: endpoints recovered from the stored // FeatureCollection's Point features inside addArc. - addArc(asset.asset_id, asset.geojson, asset.style, asset.visible, asset.name); + addArc(asset.asset_id, asset.geojson, asset.style, asset.visible, asset.name, undefined, undefined, undefined, asset.z_index); }} else {{ // Pass name + type through — losing them on restore left // hover cards and layer managers with unnamed "vector" rows. - addGeoJSON(asset.asset_id, asset.geojson, asset.style, asset.visible, asset.name, asset.asset_type); + addGeoJSON(asset.asset_id, asset.geojson, asset.style, asset.visible, asset.name, asset.asset_type, asset.z_index); }} }} // Restore basemap — ONLY when it actually differs, and always @@ -2684,7 +2834,15 @@ class BasemapPickerControl {{ }} // Crosshair while armed (see .draw-mode-active) — the default // grab cursor made it unclear the map was ready for sketching. + // The class flips synchronously here; any INLINE cursor left by + // the hover systems ('pointer') or Terra Draw's adapter must be + // cleared too, or it re-surfaces the moment the class comes off + // (exit draw mode over empty map showed a stale pointer until + // the next hover probe). Note: Chromium only repaints the cursor + // on pointer movement — a delayed switch on an idle mouse is a + // browser limitation (Firefox repaints immediately). map.getCanvas().classList.toggle('draw-mode-active', currentDrawMode !== null); + clearHoverHL(); updateDrawToolbar(); }} @@ -2746,13 +2904,25 @@ class DrawToolbarControl {{ const td = window.terraDraw; const tda = window.terraDrawMaplibreGlAdapter; drawInstance = new td.TerraDraw({{ - adapter: new tda.TerraDrawMapLibreGLAdapter({{ map }}), + // minPixelDragDistance: before a shape is started the + // adapter's default is 1px — ANY pointer jitter between + // press and release reclassified the click as a "drag", + // which click-driven modes ignore. That was the "first + // click on the map does nothing" bug (later clicks used + // the separate 8px while-drawing default and landed). + // 8px matches the while-drawing threshold. + adapter: new tda.TerraDrawMapLibreGLAdapter({{ map, minPixelDragDistance: 8 }}), + // click-move-or-drag: two-point shapes (and freehand) + // accept BOTH gestures — click, move, click again; or + // press, drag, release. Polygon/linestring stay + // click-per-vertex (Terra Draw has no drag variant for + // them; freehand IS the drag way to sketch a polygon). modes: [ new td.TerraDrawPolygonMode({{ styles: _areaStyles() }}), - new td.TerraDrawRectangleMode({{ styles: _areaStyles() }}), - new td.TerraDrawCircleMode({{ styles: _areaStyles() }}), + new td.TerraDrawRectangleMode({{ styles: _areaStyles(), drawInteraction: 'click-move-or-drag' }}), + new td.TerraDrawCircleMode({{ styles: _areaStyles(), drawInteraction: 'click-move-or-drag' }}), new td.TerraDrawLineStringMode({{ styles: _lineStyles() }}), - new td.TerraDrawFreehandMode({{ styles: _areaStyles() }}), + new td.TerraDrawFreehandMode({{ styles: _areaStyles(), drawInteraction: 'click-move-or-drag' }}), ], }}); drawInstance.start(); @@ -2774,8 +2944,10 @@ class DrawToolbarControl {{ // A completed shape: render the dashed pending copy RIGHT NOW, // hand the geometry to the server (echo returns the confirmed // green asset), and clear it from Terra Draw's store — the - // asset pipeline owns it from here. The draw mode STAYS active - // so consecutive shapes need no re-click. + // asset pipeline owns it from here. Finishing a shape then + // reverts to pan mode (issue #111): draw-another means + // re-clicking the tool, which beats the stuck-in-draw-mode + // surprise the old always-armed behavior caused. drawInstance.on('finish', function(id, context) {{ if (context && context.action && context.action !== 'draw') return; const feature = drawInstance.getSnapshot().find(f => f.id === id); @@ -2811,6 +2983,7 @@ class DrawToolbarControl {{ }} try {{ drawInstance.removeFeatures([id]); }} catch (e) {{}} + activatePanMode(); }}); if (!UI_NAKED) map.addControl(new DrawToolbarControl(), 'top-left'); @@ -2875,7 +3048,9 @@ class DrawToolbarControl {{ }} currentDrawMode = null; deleteMode = true; + map.getCanvas().classList.remove('draw-mode-active'); map.getCanvas().classList.add('delete-mode-active'); + clearHoverHL(); map.on('click', onDeleteClick); updateDrawToolbar(); }} @@ -2883,6 +3058,7 @@ class DrawToolbarControl {{ function exitDeleteMode() {{ deleteMode = false; map.getCanvas().classList.remove('delete-mode-active'); + clearHoverHL(); map.off('click', onDeleteClick); updateDrawToolbar(); }} diff --git a/server/mapcontrol_server/services/asset_service.py b/server/mapcontrol_server/services/asset_service.py index c4d39c6..11c88e6 100644 --- a/server/mapcontrol_server/services/asset_service.py +++ b/server/mapcontrol_server/services/asset_service.py @@ -40,13 +40,36 @@ async def create_asset( metadata_json = metadata.model_dump_json() if metadata else None bbox = _compute_bbox(geojson) - # New assets stack on top: z_index = current max + 1 for this map. - cursor = await db.execute( - "SELECT COALESCE(MAX(z_index), -1) + 1 FROM assets WHERE map_id = ?", - (map_id,), - ) - row = await cursor.fetchone() - z_index = row[0] if row else 0 + # Stacking policy, encoded HERE so the DB z_index is canonical and every + # consumer (live map, layer managers, session restore) sees the same + # order. Vectors stack on top (max + 1). Rasters (geotiffs/tiles) slot in + # just BELOW the lowest vector — a full-extent raster added on top would + # bury every polygon — by shifting everything at or above that slot up + # one. Explicit user reorders are respected afterwards: this only picks + # the INITIAL slot. + is_raster = asset_type.startswith("geotiff") or asset_type == "tile_layer" + min_vector_z = None + if is_raster: + cursor = await db.execute( + "SELECT MIN(z_index) FROM assets WHERE map_id = ? " + "AND asset_type NOT LIKE 'geotiff%' AND asset_type != 'tile_layer'", + (map_id,), + ) + row = await cursor.fetchone() + min_vector_z = row[0] if row else None + if min_vector_z is not None: + await db.execute( + "UPDATE assets SET z_index = z_index + 1 WHERE map_id = ? AND z_index >= ?", + (map_id, min_vector_z), + ) + z_index = min_vector_z + else: + cursor = await db.execute( + "SELECT COALESCE(MAX(z_index), -1) + 1 FROM assets WHERE map_id = ?", + (map_id,), + ) + row = await cursor.fetchone() + z_index = row[0] if row else 0 await db.execute( """INSERT INTO assets (id, map_id, name, asset_type, geojson, style, metadata, visible, animated, z_index, source_url, bbox, created_at, updated_at) @@ -212,6 +235,42 @@ async def reorder_assets(map_id: str, asset_ids: list[str]) -> None: await db.commit() +async def move_asset(map_id: str, asset_id: str, position: str) -> None: + """Persist a single-asset move ('top' | 'bottom' | 'up' | 'down'). + + Mirrors the live map's move_layer handling so the stored order survives + reloads — move_layer used to be broadcast-only, and the next snapshot or + layer-manager poll silently snapped the stack back. + """ + db = await get_db() + cursor = await db.execute( + "SELECT id FROM assets WHERE map_id = ? ORDER BY z_index ASC, created_at ASC", + (map_id,), + ) + order = [row["id"] for row in await cursor.fetchall()] + if asset_id not in order: + return + idx = order.index(asset_id) + order.pop(idx) + if position == "top": + order.append(asset_id) + elif position == "bottom": + order.insert(0, asset_id) + elif position == "up": + order.insert(min(idx + 1, len(order)), asset_id) + elif position == "down": + order.insert(max(idx - 1, 0), asset_id) + else: + return + now = datetime.now(timezone.utc).isoformat() + for z, aid in enumerate(order): + await db.execute( + "UPDATE assets SET z_index = ?, updated_at = ? WHERE id = ? AND map_id = ?", + (z, now, aid, map_id), + ) + await db.commit() + + async def update_asset( map_id: str, asset_id: str, update: AssetUpdate ) -> AssetResponse | None: diff --git a/server/mapcontrol_server/services/event_service.py b/server/mapcontrol_server/services/event_service.py index 0712d97..6142224 100644 --- a/server/mapcontrol_server/services/event_service.py +++ b/server/mapcontrol_server/services/event_service.py @@ -139,8 +139,10 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: animated=event.data.get("animated", False), ) asset_id = asset.asset_id - # Enrich the broadcast data with the assigned asset_id + # Enrich the broadcast with the assigned asset_id and canonical + # stacking slot (clients insert by z_index, not arrival order). event.data["asset_id"] = asset_id + event.data["z_index"] = asset.z_index elif event.type == "add_arc": # Arc glyph: server tessellates a great-circle LineString between two @@ -192,6 +194,7 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: asset_id = asset.asset_id # Enrich broadcast so the frontend can render without re-tessellating event.data["asset_id"] = asset_id + event.data["z_index"] = asset.z_index event.data["geojson"] = arc_geojson event.data["name"] = arc_name if style is not None: @@ -217,6 +220,7 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: asset_id = asset.asset_id # Send the resolved geojson to the frontend event.data["asset_id"] = asset_id + event.data["z_index"] = asset.z_index event.data["geojson"] = asset.geojson elif event.type == "delete_asset": @@ -235,6 +239,7 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: ) asset_id = asset.asset_id event.data["asset_id"] = asset_id + event.data["z_index"] = asset.z_index elif event.type == "remove_tile_layer": target_id = event.data.get("asset_id") @@ -257,6 +262,14 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: ) await asset_service.reorder_assets(map_id, ids) + elif event.type == "move_layer": + # Persist the move so DB z_index stays canonical (the live map + # applies the same move via its broadcast handler). + target_id = event.data.get("asset_id") + position = event.data.get("position") + if target_id and position: + await asset_service.move_asset(map_id, target_id, position) + elif event.type == "set_visibility": target_id = event.data.get("asset_id") visible = event.data.get("visible", True) @@ -375,6 +388,7 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: "event_id": event_id, "data": { "asset_id": asset.asset_id, + "z_index": asset.z_index, "geojson": item.get("geojson", ""), **({"style": item["style"]} if "style" in item else {}), **({"name": item["name"]} if "name" in item else {}), @@ -457,6 +471,7 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: # Enrich event data for broadcast event.data["asset_id"] = asset_id + event.data["z_index"] = asset.z_index event.data["image_url"] = result.image_url event.data["bounds"] = result.bounds event.data["width"] = result.width diff --git a/server/mapcontrol_server/static/esip-embed.css b/server/mapcontrol_server/static/esip-embed.css index 4b936cf..cc0b4cc 100644 --- a/server/mapcontrol_server/static/esip-embed.css +++ b/server/mapcontrol_server/static/esip-embed.css @@ -288,66 +288,3 @@ } .esip-switch input:checked + .esip-switch-slider { background: var(--esip-accent); } .esip-switch input:checked + .esip-switch-slider::before { transform: translateX(16px); } - -/* ─── Hover card (peek) ──────────────────────────────────────────────────── */ -.esip-hovercard { - position: absolute; - z-index: 1400; - max-width: 280px; - min-width: 180px; - display: flex; - gap: 10px; - padding: 10px; - background: var(--esip-bg); - border: 1px solid var(--esip-border); - border-radius: 10px; - box-shadow: var(--esip-shadow); - pointer-events: none; - opacity: 0; - transition: opacity 90ms ease; -} -.esip-hovercard.esip-show { opacity: 1; } -.esip-hovercard-visual { - width: 56px; - height: 56px; - flex-shrink: 0; - border-radius: 8px; - border: 1px solid var(--esip-border); - background-size: cover; - background-position: center; - display: flex; - align-items: center; - justify-content: center; - color: var(--esip-muted); -} -.esip-hovercard-visual svg { width: 24px; height: 24px; } -.esip-hovercard-body { min-width: 0; } -.esip-hovercard-title { - font-size: 13px; - font-weight: 600; - line-height: 1.25; - margin-bottom: 2px; -} -.esip-hovercard-type { - display: inline-block; - font-size: 9.5px; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--esip-accent); - background: var(--esip-accent-soft); - padding: 1px 6px; - border-radius: 999px; - margin-bottom: 4px; -} -.esip-hovercard-stat { font-size: 11px; color: var(--esip-muted); } -.esip-hovercard-desc { - font-size: 11.5px; - color: var(--esip-text); - line-height: 1.4; - margin-top: 4px; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} diff --git a/server/mapcontrol_server/static/esip-embed.js b/server/mapcontrol_server/static/esip-embed.js index 6d72b58..ab66524 100644 --- a/server/mapcontrol_server/static/esip-embed.js +++ b/server/mapcontrol_server/static/esip-embed.js @@ -295,14 +295,8 @@ els.layersPanel = layersPanel; els.settingsPanel = settingsPanel; - // Hover card - var card = el("div", "esip-hovercard"); - card.hidden = true; - els.card = card; - document.body.appendChild(gear); document.body.appendChild(panel); - document.body.appendChild(card); renderSettings(); } @@ -473,7 +467,7 @@ // *navigating away from* the chrome, not by hiding it in place. p.appendChild(mkSetting( "Default UI", - "Show the built-in layer panel & hover cards. Turn off for a bare map.", + "Show the built-in layer panel. Turn off for a bare map.", true, function (on) { var u = new URL(window.location.href); @@ -508,65 +502,14 @@ return wrap; } - // ─── Hover card (peek) ────────────────────────────────────────────────── - var hideTimer = null; - function showCard(detail) { - var asset = assetById[detail.asset_id]; - // If the asset isn't in our cache yet (race), synthesize a minimal model - // from the hover payload so the card is still useful. - var model = asset ? cardModel(asset) : { - name: detail.name || "Untitled", - type: prettyType(detail.asset_type), - thumb: null, swatch: null, icon: iconForType(detail.asset_type), md: "", stat: "", - }; - var card = els.card; - clear(card); - - var visual = el("div", "esip-hovercard-visual"); - if (model.thumb) visual.style.backgroundImage = 'url("' + model.thumb + '")'; - else if (model.swatch) visual.style.background = model.swatch; - else visual.innerHTML = model.icon; - card.appendChild(visual); - - var body = el("div", "esip-hovercard-body"); - body.appendChild(el("div", "esip-hovercard-type", esc(model.type))); - body.appendChild(el("div", "esip-hovercard-title", esc(model.name))); - if (model.stat) body.appendChild(el("div", "esip-hovercard-stat", esc(model.stat))); - if (model.md) body.appendChild(el("div", "esip-hovercard-desc", miniMarkdown(model.md))); - card.appendChild(body); - - positionCard(detail.point); - card.hidden = false; - // next frame → fade in - requestAnimationFrame(function () { card.classList.add("esip-show"); }); - if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } - } - - function positionCard(point) { - var card = els.card; - var x = (point && point.x != null) ? point.x : 20; - var y = (point && point.y != null) ? point.y : 20; - // Default: below-right of cursor; flip if near the viewport edge. - card.style.left = "0px"; card.style.top = "0px"; - var w = 280, h = 100; - var left = x + 16, top = y + 16; - if (left + w > window.innerWidth) left = x - w - 16; - if (top + h > window.innerHeight) top = y - h - 16; - card.style.left = Math.max(8, left) + "px"; - card.style.top = Math.max(8, top) + "px"; - } - - function hideCard() { - var card = els.card; - card.classList.remove("esip-show"); - hideTimer = setTimeout(function () { card.hidden = true; }, 120); - } - // ─── Wire the public contract events ──────────────────────────────────── + // Hover deliberately shows NOTHING (issue #111): the old hover card pinned + // itself at the position where the hover began, and when zoomed inside a + // large polygon it could never be dismissed. Selection is a CLICK now — + // the map emphasizes the clicked asset's border, and the click below + // focuses its row (with opacity/style controls) in the panel. function wire() { window.addEventListener("esip:assetschanged", function () { refreshAssets(); }); - window.addEventListener("esip:asset_hover", function (e) { showCard(e.detail || {}); }); - window.addEventListener("esip:asset_hover_end", function () { hideCard(); }); window.addEventListener("esip:asset_click", function (e) { var d = e.detail || {}; if (!d.asset_id) return; diff --git a/server/tests/test_layer_order.py b/server/tests/test_layer_order.py new file mode 100644 index 0000000..263a7cc --- /dev/null +++ b/server/tests/test_layer_order.py @@ -0,0 +1,149 @@ +"""Stacking-order tests (issue #111): the DB z_index is the canonical order. + +The live map, layer managers, and session restore all derive their stacking +from ascending z_index — these tests pin the server-side policy: + - vectors stack on top (max + 1); + - rasters (tile layers / geotiffs) slot in just below the lowest vector; + - reorder_assets and move_layer both persist. +""" + +import os +import tempfile + +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from mapcontrol_server.main import app, lifespan + +SAMPLE_GEOJSON = '{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-97.7,30.2],[-97.7,30.3],[-97.6,30.3],[-97.6,30.2],[-97.7,30.2]]]},"properties":{}}' + + +@pytest_asyncio.fixture +async def client(): + tmp = tempfile.mkdtemp() + os.environ["MAPCONTROL_DB_PATH"] = f"{tmp}/test.db" + os.environ["MAPCONTROL_FILE_DIR"] = f"{tmp}/files" + async with lifespan(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def _add_polygon(client, map_id, name): + resp = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "add_polygon", + "data": {"geojson": SAMPLE_GEOJSON, "name": name}, + }) + return resp.json()["asset_id"] + + +async def _add_tile_layer(client, map_id, name): + resp = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "add_tile_layer", + "data": {"url": "https://tile.example/{z}/{x}/{y}.png", "name": name}, + }) + return resp.json()["asset_id"] + + +async def _order(client, map_id): + """Asset ids bottom-most first (the documented list order).""" + resp = await client.get(f"/api/maps/{map_id}/assets?include_geojson=false") + assert resp.status_code == 200 + return [a["asset_id"] for a in resp.json()] + + +@pytest_asyncio.fixture +async def map_id(client): + resp = await client.post("/api/maps") + return resp.json()["map_id"] + + +async def test_vectors_stack_on_top(client, map_id): + a = await _add_polygon(client, map_id, "A") + b = await _add_polygon(client, map_id, "B") + assert await _order(client, map_id) == [a, b] + + +async def test_raster_slots_below_vectors(client, map_id): + a = await _add_polygon(client, map_id, "A") + b = await _add_polygon(client, map_id, "B") + t = await _add_tile_layer(client, map_id, "T") + # The tile layer lands BELOW both polygons even though it was added last. + assert await _order(client, map_id) == [t, a, b] + + +async def test_raster_on_empty_map_is_bottom_of_later_vectors(client, map_id): + t = await _add_tile_layer(client, map_id, "T") + a = await _add_polygon(client, map_id, "A") + assert await _order(client, map_id) == [t, a] + + +async def test_second_raster_lands_above_first_but_below_vectors(client, map_id): + t1 = await _add_tile_layer(client, map_id, "T1") + a = await _add_polygon(client, map_id, "A") + t2 = await _add_tile_layer(client, map_id, "T2") + assert await _order(client, map_id) == [t1, t2, a] + + +async def test_reorder_assets_persists(client, map_id): + a = await _add_polygon(client, map_id, "A") + b = await _add_polygon(client, map_id, "B") + t = await _add_tile_layer(client, map_id, "T") + # Layer-manager order is TOP-most first; put the raster on top. + resp = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "reorder_assets", + "data": {"asset_ids": [t, b, a]}, + }) + assert resp.status_code == 200 + assert await _order(client, map_id) == [a, b, t] + + +async def test_reorder_survives_new_raster(client, map_id): + """An explicit raster-on-top order stays put; a NEW raster still slots + below the vectors (initial-placement rule only applies to new assets).""" + a = await _add_polygon(client, map_id, "A") + t = await _add_tile_layer(client, map_id, "T") + await client.post(f"/api/maps/{map_id}/events", json={ + "type": "reorder_assets", + "data": {"asset_ids": [t, a]}, # user puts the raster on top + }) + t2 = await _add_tile_layer(client, map_id, "T2") + assert await _order(client, map_id) == [t2, a, t] + + +async def test_move_layer_persists(client, map_id): + a = await _add_polygon(client, map_id, "A") + b = await _add_polygon(client, map_id, "B") + c = await _add_polygon(client, map_id, "C") + + resp = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "move_layer", + "data": {"asset_id": a, "position": "top"}, + }) + assert resp.status_code == 200 + assert await _order(client, map_id) == [b, c, a] + + await client.post(f"/api/maps/{map_id}/events", json={ + "type": "move_layer", + "data": {"asset_id": a, "position": "down"}, + }) + assert await _order(client, map_id) == [b, a, c] + + await client.post(f"/api/maps/{map_id}/events", json={ + "type": "move_layer", + "data": {"asset_id": c, "position": "bottom"}, + }) + assert await _order(client, map_id) == [c, b, a] + + await client.post(f"/api/maps/{map_id}/events", json={ + "type": "move_layer", + "data": {"asset_id": c, "position": "up"}, + }) + assert await _order(client, map_id) == [b, c, a] + + +async def test_add_polygon_asset_carries_z_index(client, map_id): + a = await _add_polygon(client, map_id, "A") + b = await _add_polygon(client, map_id, "B") + ra = await client.get(f"/api/maps/{map_id}/assets/{a}") + rb = await client.get(f"/api/maps/{map_id}/assets/{b}") + assert rb.json()["z_index"] > ra.json()["z_index"]