diff --git a/server/config.toml b/server/config.toml
index e97ca0a..a26e9ea 100644
--- a/server/config.toml
+++ b/server/config.toml
@@ -21,7 +21,10 @@ default_basemap = "satellite"
# basemap was explicitly chosen (create_map arg, set_basemap, or ?basemap=).
# Keyless Carto Dark Matter so it works without provider API keys.
default_dark_basemap = "carto_dark"
-# Default terrain mode: "2d" or "3d" (3d = globe with terrain tiles + sky)
+# Default view mode: "2d" (flat mercator) or "3d" (globe projection + sky).
+# 3d no longer enables MapLibre raster-DEM terrain — globe+terrain is the
+# half-supported combination behind a renderer-crash class; real 3D terrain
+# belongs to the (separate) Babylon/3D-Tiles view.
default_terrain = "3d"
# ─── Basemap providers ────────────────────────────────────────────────────
diff --git a/server/mapcontrol_server/database.py b/server/mapcontrol_server/database.py
index 4be5bc5..467557e 100644
--- a/server/mapcontrol_server/database.py
+++ b/server/mapcontrol_server/database.py
@@ -42,6 +42,7 @@
animated INTEGER DEFAULT 0,
z_index INTEGER DEFAULT 0,
source_url TEXT,
+ bbox TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -104,6 +105,14 @@ async def init_db(config: AppConfig) -> aiosqlite.Connection:
)
except aiosqlite.OperationalError:
pass # column already present
+ # assets.bbox: precomputed [minLon, minLat, maxLon, maxLat] JSON, filled at
+ # create_asset. Lets the asset list serve a geojson-free summary (layer
+ # managers poll it every few seconds) while zoom-to-fit still works.
+ # Pre-existing rows keep NULL — readers fall back to the full geojson.
+ try:
+ await _db.execute("ALTER TABLE assets ADD COLUMN bbox TEXT")
+ except aiosqlite.OperationalError:
+ pass # column already present
await _db.commit()
return _db
diff --git a/server/mapcontrol_server/main.py b/server/mapcontrol_server/main.py
index 0ed2f36..8d96a77 100644
--- a/server/mapcontrol_server/main.py
+++ b/server/mapcontrol_server/main.py
@@ -216,13 +216,20 @@ async def websocket_endpoint(websocket: WebSocket, map_id: str, user_session_id:
draw_data = msg.get("data", {})
geojson_str = draw_data.get("geojson", "")
draw_type = draw_data.get("draw_type", "polygon")
+ # Client-generated correlation id: the drawing browser renders
+ # the shape immediately in a "pending" style and swaps it for
+ # the confirmed asset when this id round-trips.
+ client_id = draw_data.get("client_id", "")
asset_type = f"drawn_{draw_type}"
name = draw_data.get("name", f"User drawn {draw_type}")
- # Default style for drawn features (distinct blue)
+ # Default style for drawn features: the EOGPT earth-green
+ # accent (--eo-accent #7cc242, darker #5fa830 stroke) so
+ # user-drawn shapes read as part of the product, distinct
+ # from agent-added analysis layers.
style = AssetStyle(
- fill_color="#4264fb",
- stroke_color="#4264fb",
+ fill_color="#7cc242",
+ stroke_color="#5fa830",
stroke_width=2.0,
)
@@ -239,13 +246,16 @@ async def websocket_endpoint(websocket: WebSocket, map_id: str, user_session_id:
f"type={asset_type} map={map_id} session={user_session_id}"
)
- # Broadcast the new asset to all sessions as an add_polygon event
+ # Broadcast the new asset to all sessions as an add_polygon
+ # event. client_id lets the drawing session replace its
+ # pending-styled shape; other sessions ignore it.
broadcast_msg = {
"type": "add_polygon",
"data": {
"asset_id": asset.asset_id,
"geojson": geojson_str,
"style": style.model_dump(),
+ "client_id": client_id,
},
}
await manager.broadcast_to_map(map_id, broadcast_msg)
@@ -257,6 +267,7 @@ async def websocket_endpoint(websocket: WebSocket, map_id: str, user_session_id:
"asset_id": asset.asset_id,
"asset_type": asset_type,
"draw_type": draw_type,
+ "client_id": client_id,
},
}))
except Exception as e:
@@ -384,7 +395,7 @@ def _thumbnail_url(entry) -> str:
# none — naked canvas: no basemap picker, no draw tools, no embed
# outfit. Only renders assets + emits interaction events
# (the canonical ESIP design; what slide embeds want).
- # controls — native map controls (basemap picker + Geoman draw tools +
+ # controls — native map controls (basemap picker + draw toolbar +
# attribution) but NO embed outfit. What embedders with
# their own management UI (e.g. EOGPT) want.
# default — controls PLUS the built-in "default outfit" (layer panel
@@ -469,7 +480,7 @@ def _thumbnail_url(entry) -> str:
Map Control
-
+
{ui_head}
@@ -512,9 +523,9 @@ def _thumbnail_url(entry) -> str:
The canonical ESIP design: a bare map that only renders assets and
emits interaction events. No draw toolbar, no basemap picker, no
custom buttons, no on-map attribution pill. JS below also skips
- initializing Geoman and the picker control entirely; these rules
- are the belt-and-suspenders layer for anything that still lands
- in the DOM.
+ building the draw toolbar and the picker control entirely; these
+ rules are the belt-and-suspenders layer for anything that still
+ lands in the DOM.
ATTRIBUTION CONTRACT: hiding the pill shifts the provider-credit
obligation to the EMBEDDING PAGE — anyone iframing a ui=none map
must render basemap attribution (e.g. "© Esri, © OpenStreetMap
@@ -523,38 +534,43 @@ def _thumbnail_url(entry) -> str:
html[data-esip-ui="none"] .maplibregl-ctrl-top-left,
html[data-esip-ui="none"] .maplibregl-ctrl-bottom-right,
html[data-esip-ui="none"] .basemap-picker-wrap,
- html[data-esip-ui="none"] .custom-toolbar,
- html[data-esip-ui="none"] [class*="gm-control"],
- html[data-esip-ui="none"] [class*="geoman-control"] {{
+ html[data-esip-ui="none"] .draw-toolbar {{
display: none !important;
}}
/* Custom toolbar buttons (undo, pan, delete) */
- .custom-toolbar {{
- position: absolute;
- top: 10px;
- right: 10px;
- z-index: 1000;
+ /* ─── Draw toolbar (Terra Draw is headless — this is our own UI) ──
+ Themed via the tokens above so it follows light/dark live, exactly
+ like the basemap picker. One vertical group, top-left, mirroring
+ the MapLibre control aesthetic without depending on it. */
+ .draw-toolbar {{
display: flex;
flex-direction: column;
- gap: 4px;
+ background: var(--eo-surface);
+ border: 1px solid var(--eo-border);
+ border-radius: 8px;
+ box-shadow: var(--eo-shadow);
+ overflow: hidden;
}}
- .custom-toolbar button {{
- width: 36px;
- height: 36px;
- border: 1px solid #ccc;
- border-radius: 4px;
- background: #fff;
+ .draw-toolbar button {{
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-bottom: 1px solid var(--eo-border);
+ background: transparent;
+ color: var(--eo-text-muted);
cursor: pointer;
- font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
- box-shadow: 0 1px 4px rgba(0,0,0,0.2);
- transition: background 0.15s;
+ transition: background 0.15s, color 0.15s;
}}
- .custom-toolbar button:hover {{ background: #f0f0f0; }}
- .custom-toolbar button.active {{ background: #e94560; color: #fff; border-color: #e94560; }}
- .custom-toolbar button:disabled {{ opacity: 0.4; cursor: default; }}
+ .draw-toolbar button:last-child {{ border-bottom: none; }}
+ .draw-toolbar button:hover {{ background: var(--eo-surface-hover); color: var(--eo-text); }}
+ .draw-toolbar button[aria-pressed="true"] {{
+ background: var(--eo-accent-soft);
+ color: var(--eo-accent);
+ }}
+ .draw-toolbar button svg {{ width: 18px; height: 18px; display: block; }}
.delete-mode-active {{ cursor: crosshair !important; }}
/* ─── Basemap picker (collapsed dropdown) ─────────────────────────
A trigger button that expands into a grouped thumbnail grid.
@@ -681,7 +697,10 @@ def _thumbnail_url(entry) -> str:
-
+
+
+
diff --git a/server/mapcontrol_server/models.py b/server/mapcontrol_server/models.py
index 046d679..96c6a28 100644
--- a/server/mapcontrol_server/models.py
+++ b/server/mapcontrol_server/models.py
@@ -185,7 +185,12 @@ class AssetResponse(BaseModel):
map_id: str
name: str | None = None
asset_type: str
- geojson: dict[str, Any] | str
+ # None in summary responses (list_assets include_geojson=False) — pollers
+ # like layer managers don't need multi-MB geometry every few seconds.
+ geojson: dict[str, Any] | str | None
+ # [minLon, minLat, maxLon, maxLat], precomputed at creation. None for rows
+ # created before the bbox column existed and for empty geometries.
+ bbox: list[float] | None = None
style: AssetStyle | None = None
metadata: AssetMetadata | None = None
visible: bool = True
diff --git a/server/mapcontrol_server/routers/assets.py b/server/mapcontrol_server/routers/assets.py
index b3bb893..b2cfcd3 100644
--- a/server/mapcontrol_server/routers/assets.py
+++ b/server/mapcontrol_server/routers/assets.py
@@ -11,13 +11,19 @@
@router.get("", response_model=list[AssetResponse])
-async def list_assets(map_id: str):
- """List all assets for a map."""
+async def list_assets(map_id: str, include_geojson: bool = True):
+ """List all assets for a map.
+
+ ``include_geojson=false`` omits the geometry (geojson=null, bbox kept) —
+ use it for recurring polls (layer managers) so a map holding multi-MB
+ vector assets doesn't ship them all on every refresh. Fetch a single
+ asset by id when the full geometry is actually needed.
+ """
info = await session_service.get_map(map_id)
if info is None:
raise HTTPException(status_code=404, detail="Map not found")
- return await asset_service.list_assets(map_id)
+ return await asset_service.list_assets(map_id, include_geojson=include_geojson)
@router.get("/{asset_id}", response_model=AssetResponse)
diff --git a/server/mapcontrol_server/services/asset_service.py b/server/mapcontrol_server/services/asset_service.py
index 8f4dd24..c4d39c6 100644
--- a/server/mapcontrol_server/services/asset_service.py
+++ b/server/mapcontrol_server/services/asset_service.py
@@ -12,6 +12,13 @@
from ..models import AssetResponse, AssetStyle, AssetMetadata, AssetUpdate
+def _compute_bbox(geojson: str) -> list[float] | None:
+ """[minLon, minLat, maxLon, maxLat] of a geojson string, None if empty."""
+ from .session_service import _compute_bbox_from_geojsons
+
+ return _compute_bbox_from_geojsons([geojson])
+
+
async def create_asset(
map_id: str,
asset_type: str,
@@ -31,6 +38,7 @@ async def create_asset(
style_json = style.model_dump_json() if style else None
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(
@@ -41,8 +49,8 @@ async def create_asset(
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, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)""",
+ """INSERT INTO assets (id, map_id, name, asset_type, geojson, style, metadata, visible, animated, z_index, source_url, bbox, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)""",
(
asset_id,
map_id,
@@ -54,6 +62,7 @@ async def create_asset(
int(animated),
z_index,
source_url,
+ json.dumps(bbox) if bbox else None,
now,
now,
),
@@ -66,6 +75,7 @@ async def create_asset(
name=name,
asset_type=asset_type,
geojson=geojson,
+ bbox=bbox,
style=style,
metadata=metadata,
visible=True,
@@ -104,57 +114,17 @@ async def create_asset_from_url(
)
-async def list_assets(map_id: str) -> list[AssetResponse]:
- """List all assets for a map, bottom-most first (ascending z_index).
-
- Iterating the result and adding layers in order reproduces the stacking:
- later rows render on top (MapLibre adds new layers above existing ones).
- """
- db = await get_db()
- cursor = await db.execute(
- "SELECT * FROM assets WHERE map_id = ? ORDER BY z_index ASC, created_at ASC",
- (map_id,),
- )
- rows = await cursor.fetchall()
-
- assets = []
- for row in rows:
- style = AssetStyle.model_validate_json(row["style"]) if row["style"] else None
- metadata = (
- AssetMetadata.model_validate_json(row["metadata"])
- if row["metadata"]
- else None
- )
- assets.append(
- AssetResponse(
- asset_id=row["id"],
- map_id=row["map_id"],
- name=row["name"],
- asset_type=row["asset_type"],
- geojson=row["geojson"],
- style=style,
- metadata=metadata,
- visible=bool(row["visible"]),
- animated=bool(row["animated"]),
- z_index=row["z_index"] or 0,
- source_url=row["source_url"],
- created_at=row["created_at"],
- updated_at=row["updated_at"],
- )
- )
- return assets
-
-
-async def get_asset(map_id: str, asset_id: str) -> AssetResponse | None:
- """Get a single asset."""
- db = await get_db()
- cursor = await db.execute(
- "SELECT * FROM assets WHERE id = ? AND map_id = ?", (asset_id, map_id)
- )
- row = await cursor.fetchone()
- if row is None:
+def _parse_bbox(raw: str | None) -> list[float] | None:
+ if not raw:
+ return None
+ try:
+ bbox = json.loads(raw)
+ except (ValueError, TypeError):
return None
+ return bbox if isinstance(bbox, list) and len(bbox) == 4 else None
+
+def _row_to_asset(row, geojson: str | None) -> AssetResponse:
style = AssetStyle.model_validate_json(row["style"]) if row["style"] else None
metadata = (
AssetMetadata.model_validate_json(row["metadata"])
@@ -166,7 +136,8 @@ async def get_asset(map_id: str, asset_id: str) -> AssetResponse | None:
map_id=row["map_id"],
name=row["name"],
asset_type=row["asset_type"],
- geojson=row["geojson"],
+ geojson=geojson,
+ bbox=_parse_bbox(row["bbox"]),
style=style,
metadata=metadata,
visible=bool(row["visible"]),
@@ -178,6 +149,51 @@ async def get_asset(map_id: str, asset_id: str) -> AssetResponse | None:
)
+# Every column except geojson — the summary query must not pull multi-MB
+# geometry strings out of SQLite just to throw them away.
+_SUMMARY_COLUMNS = (
+ "id, map_id, name, asset_type, style, metadata, visible, animated, "
+ "z_index, source_url, bbox, created_at, updated_at"
+)
+
+
+async def list_assets(
+ map_id: str, include_geojson: bool = True
+) -> list[AssetResponse]:
+ """List all assets for a map, bottom-most first (ascending z_index).
+
+ Iterating the result and adding layers in order reproduces the stacking:
+ later rows render on top (MapLibre adds new layers above existing ones).
+
+ ``include_geojson=False`` returns a summary (geojson=None, bbox kept) —
+ the shape layer-manager pollers should request every few seconds, since
+ full geometries can be many MB per asset.
+ """
+ db = await get_db()
+ columns = "*" if include_geojson else _SUMMARY_COLUMNS
+ cursor = await db.execute(
+ f"SELECT {columns} FROM assets WHERE map_id = ? ORDER BY z_index ASC, created_at ASC",
+ (map_id,),
+ )
+ rows = await cursor.fetchall()
+ return [
+ _row_to_asset(row, row["geojson"] if include_geojson else None)
+ for row in rows
+ ]
+
+
+async def get_asset(map_id: str, asset_id: str) -> AssetResponse | None:
+ """Get a single asset."""
+ db = await get_db()
+ cursor = await db.execute(
+ "SELECT * FROM assets WHERE id = ? AND map_id = ?", (asset_id, map_id)
+ )
+ row = await cursor.fetchone()
+ if row is None:
+ return None
+ return _row_to_asset(row, row["geojson"])
+
+
async def reorder_assets(map_id: str, asset_ids: list[str]) -> None:
"""Persist a new stacking order.
diff --git a/server/mapcontrol_server/services/geotiff_service.py b/server/mapcontrol_server/services/geotiff_service.py
index 4285628..8ac3371 100644
--- a/server/mapcontrol_server/services/geotiff_service.py
+++ b/server/mapcontrol_server/services/geotiff_service.py
@@ -13,6 +13,7 @@
from __future__ import annotations
+import asyncio
import io
import logging
import os
@@ -146,13 +147,39 @@ def _resolve_source(source: str) -> str:
return source
+def _bounds_are_geographic(bounds) -> bool:
+ """Whether [minLon, minLat, maxLon, maxLat] fall within valid lon/lat."""
+ lon_min, lat_min, lon_max, lat_max = bounds
+ return (
+ -180.0 <= lon_min <= 180.0
+ and -180.0 <= lon_max <= 180.0
+ and -90.0 <= lat_min <= 90.0
+ and -90.0 <= lat_max <= 90.0
+ )
+
+
def _compute_bounds_4326(dataset) -> list[float]:
- """Compute EPSG:4326 bounds from a rasterio dataset."""
+ """Compute EPSG:4326 bounds from a rasterio dataset.
+
+ Raises ValueError when the raster has NO CRS and its coordinates aren't
+ valid lon/lat. Such a file is a projected raster that lost its CRS on
+ write (e.g. a change raster differenced from EPSG:3832 inputs whose
+ profile dropped the CRS): assuming 4326 would silently place the overlay
+ at projected-meter coordinates like (-819450, -487230) — off the map, an
+ invisible "phantom" layer. Failing loud lets the caller surface a real
+ error instead. A CRS-less raster that IS already geographic still works.
+ """
src_crs = dataset.crs
if src_crs is None:
- # No CRS — assume already in 4326
b = dataset.bounds
- return [b.left, b.bottom, b.right, b.top]
+ bounds = [b.left, b.bottom, b.right, b.top]
+ if not _bounds_are_geographic(bounds):
+ raise ValueError(
+ "GeoTIFF has no CRS and its coordinates are not longitude/"
+ "latitude — it cannot be georeferenced. Re-export the raster "
+ "with a CRS (e.g. copy the source profile's crs/transform)."
+ )
+ return bounds
bounds = transform_bounds(src_crs, "EPSG:4326", *dataset.bounds)
return list(bounds) # [minLon, minLat, maxLon, maxLat]
@@ -384,28 +411,33 @@ async def process_geotiff_rgb(
tif_path, size_mb = await _download_to_tempfile(source, max_bytes)
is_temp = True
- # Process
- rgba, ds = _process_rgb(tif_path, bands, alpha, nodata)
- bounds = _compute_bounds_4326(ds)
- band_count = ds.count
- crs_str = str(ds.crs) if ds.crs else "unknown"
- ds.close()
-
- # Save PNG
- img = Image.fromarray(rgba, "RGBA")
- png_filename = f"{asset_id}.png"
- png_path = file_dir / png_filename
- img.save(str(png_path), "PNG")
-
- return GeoTIFFResult(
- png_path=str(png_path),
- bounds=bounds,
- width=rgba.shape[1],
- height=rgba.shape[0],
- crs=crs_str,
- band_count=band_count,
- image_url=f"/api/files/{png_filename}",
- )
+ # Process + PNG encode are CPU-bound (rasterio reads, percentile
+ # stretches, PIL compression) — run off the event loop so WebSocket
+ # traffic (drawn-shape echoes, viewport updates) keeps flowing while
+ # a raster renders.
+ def _render() -> GeoTIFFResult:
+ rgba, ds = _process_rgb(tif_path, bands, alpha, nodata)
+ bounds = _compute_bounds_4326(ds)
+ band_count = ds.count
+ crs_str = str(ds.crs) if ds.crs else "unknown"
+ ds.close()
+
+ img = Image.fromarray(rgba, "RGBA")
+ png_filename = f"{asset_id}.png"
+ png_path = file_dir / png_filename
+ img.save(str(png_path), "PNG")
+
+ return GeoTIFFResult(
+ png_path=str(png_path),
+ bounds=bounds,
+ width=rgba.shape[1],
+ height=rgba.shape[0],
+ crs=crs_str,
+ band_count=band_count,
+ image_url=f"/api/files/{png_filename}",
+ )
+
+ return await asyncio.to_thread(_render)
except ValueError as e:
msg = str(e)
@@ -483,31 +515,33 @@ async def process_geotiff_singleband(
tif_path, size_mb = await _download_to_tempfile(source, max_bytes)
is_temp = True
- # Process
- rgba, ds = _process_singleband(
- tif_path, band, colormap, alpha,
- vmin, vmax, percentile_min, percentile_max, nodata,
- )
- bounds = _compute_bounds_4326(ds)
- band_count = ds.count
- crs_str = str(ds.crs) if ds.crs else "unknown"
- ds.close()
-
- # Save PNG
- img = Image.fromarray(rgba, "RGBA")
- png_filename = f"{asset_id}.png"
- png_path = file_dir / png_filename
- img.save(str(png_path), "PNG")
-
- return GeoTIFFResult(
- png_path=str(png_path),
- bounds=bounds,
- width=rgba.shape[1],
- height=rgba.shape[0],
- crs=crs_str,
- band_count=band_count,
- image_url=f"/api/files/{png_filename}",
- )
+ # CPU-bound render off the event loop — same reasoning as the RGB path.
+ def _render() -> GeoTIFFResult:
+ rgba, ds = _process_singleband(
+ tif_path, band, colormap, alpha,
+ vmin, vmax, percentile_min, percentile_max, nodata,
+ )
+ bounds = _compute_bounds_4326(ds)
+ band_count = ds.count
+ crs_str = str(ds.crs) if ds.crs else "unknown"
+ ds.close()
+
+ img = Image.fromarray(rgba, "RGBA")
+ png_filename = f"{asset_id}.png"
+ png_path = file_dir / png_filename
+ img.save(str(png_path), "PNG")
+
+ return GeoTIFFResult(
+ png_path=str(png_path),
+ bounds=bounds,
+ width=rgba.shape[1],
+ height=rgba.shape[0],
+ crs=crs_str,
+ band_count=band_count,
+ image_url=f"/api/files/{png_filename}",
+ )
+
+ return await asyncio.to_thread(_render)
except ValueError as e:
msg = str(e)
diff --git a/server/mapcontrol_server/static/esip-contract.js b/server/mapcontrol_server/static/esip-contract.js
index 79e61b4..d999a3b 100644
--- a/server/mapcontrol_server/static/esip-contract.js
+++ b/server/mapcontrol_server/static/esip-contract.js
@@ -70,16 +70,33 @@
}
// ─── Hover (peek) ─────────────────────────────────────────────────────
+ // The hit-test runs only after the cursor DWELLS for a beat, never on
+ // raw mousemove. queryRenderedFeatures over run-sized vector results
+ // costs ~14ms per call (measured); at 60-120 mousemoves/sec that alone
+ // saturated the main thread the moment the mouse moved, crawling the
+ // draw tools and everything else in the iframe. Hover cards are a
+ // dwell interaction — peek when the pointer settles, stay silent while
+ // it travels. Also fully suppressed while a draw/delete mode is active
+ // (a hover card mid-draw is noise, and draws are mousemove-heavy).
var hoverId = null;
- map.on("mousemove", function (e) {
+ var hoverTimer = null;
+ var HOVER_DWELL_MS = 90;
+ function clearHover() {
+ if (hoverId) {
+ emit("asset_hover_end", { asset_id: hoverId });
+ hoverId = null;
+ map.getCanvas().style.cursor = "";
+ }
+ }
+ function hoverProbe(point, lngLat) {
+ if (typeof internals.isDrawing === "function" && internals.isDrawing()) {
+ clearHover();
+ return;
+ }
var ids = assetLayerIds();
- var feats = ids.length ? map.queryRenderedFeatures(e.point, { layers: ids }) : [];
+ var feats = ids.length ? map.queryRenderedFeatures(point, { layers: ids }) : [];
if (feats.length === 0) {
- if (hoverId) {
- emit("asset_hover_end", { asset_id: hoverId });
- hoverId = null;
- map.getCanvas().style.cursor = "";
- }
+ clearHover();
return;
}
var aid = assetForLayer(feats[0].layer.id);
@@ -93,10 +110,20 @@
asset_id: aid,
name: reg.name || null,
asset_type: reg.asset_type || null,
- point: { x: e.point.x, y: e.point.y },
- lngLat: [e.lngLat.lng, e.lngLat.lat],
+ point: { x: point.x, y: point.y },
+ lngLat: [lngLat.lng, lngLat.lat],
});
}
+ }
+ map.on("mousemove", function (e) {
+ if (hoverTimer) clearTimeout(hoverTimer);
+ var point = e.point;
+ var lngLat = e.lngLat;
+ hoverTimer = setTimeout(function () { hoverProbe(point, lngLat); }, HOVER_DWELL_MS);
+ });
+ map.on("mouseout", function () {
+ if (hoverTimer) clearTimeout(hoverTimer);
+ clearHover();
});
// ─── Click (commit) ───────────────────────────────────────────────────
diff --git a/server/tests/test_assets.py b/server/tests/test_assets.py
index 0f113eb..9ece604 100644
--- a/server/tests/test_assets.py
+++ b/server/tests/test_assets.py
@@ -56,6 +56,35 @@ async def test_get_asset(client, map_with_asset):
assert resp.json()["name"] == "Test Polygon"
+async def test_list_assets_summary_mode(client, map_with_asset):
+ """include_geojson=false omits geometry but keeps the precomputed bbox,
+ so layer-manager pollers can refresh + zoom-to-fit without re-downloading
+ potentially multi-MB geometries every few seconds."""
+ map_id, asset_id = map_with_asset
+ resp = await client.get(
+ f"/api/maps/{map_id}/assets", params={"include_geojson": "false"}
+ )
+ assert resp.status_code == 200
+ assets = resp.json()
+ assert len(assets) == 1
+ assert assets[0]["asset_id"] == asset_id
+ assert assets[0]["geojson"] is None
+ # SAMPLE_GEOJSON spans lon [-97.7, -97.6], lat [30.2, 30.3]
+ assert assets[0]["bbox"] == [-97.7, 30.2, -97.6, 30.3]
+ # Non-geometry fields are intact
+ assert assets[0]["name"] == "Test Polygon"
+ assert assets[0]["style"]["fill_color"] == "#ff0000"
+
+
+async def test_list_assets_default_includes_geojson_and_bbox(client, map_with_asset):
+ map_id, _ = map_with_asset
+ resp = await client.get(f"/api/maps/{map_id}/assets")
+ assert resp.status_code == 200
+ assets = resp.json()
+ assert assets[0]["geojson"] == SAMPLE_GEOJSON
+ assert assets[0]["bbox"] == [-97.7, 30.2, -97.6, 30.3]
+
+
async def test_update_asset_visibility(client, map_with_asset):
map_id, asset_id = map_with_asset
resp = await client.patch(f"/api/maps/{map_id}/assets/{asset_id}", json={
diff --git a/server/tests/test_drawing.py b/server/tests/test_drawing.py
index f3c2726..cae4bdc 100644
--- a/server/tests/test_drawing.py
+++ b/server/tests/test_drawing.py
@@ -212,3 +212,41 @@ async def test_drawn_asset_style_can_be_updated(client, map_id):
assert resp.status_code == 200
data = resp.json()
assert data["style"]["fill_color"] == "#ff0000"
+
+
+def test_user_drawn_feature_echoes_client_id():
+ """The WS draw round trip carries the client's correlation id back in BOTH
+ the add_polygon broadcast and the draw_complete confirmation, so the
+ drawing browser can swap its dashed pending-styled shape for the
+ confirmed asset."""
+ import json
+ from starlette.testclient import TestClient
+ from mapcontrol_server.main import app as _app
+
+ tmp = tempfile.mkdtemp()
+ os.environ["MAPCONTROL_DB_PATH"] = f"{tmp}/test.db"
+ os.environ["MAPCONTROL_FILE_DIR"] = f"{tmp}/files"
+
+ with TestClient(_app) as c:
+ map_id = c.post("/api/maps").json()["map_id"]
+ session_id = c.post(f"/api/maps/{map_id}/sessions").json()["user_session_id"]
+
+ with c.websocket_connect(f"/ws/{map_id}/{session_id}") as ws:
+ ws.send_text(json.dumps({
+ "type": "user_drawn_feature",
+ "data": {
+ "geojson": DRAWN_POLYGON_GEOJSON,
+ "draw_type": "polygon",
+ "client_id": "draw-test-abc123",
+ },
+ }))
+ broadcast = json.loads(ws.receive_text())
+ confirm = json.loads(ws.receive_text())
+
+ assert broadcast["type"] == "add_polygon"
+ assert broadcast["data"]["client_id"] == "draw-test-abc123"
+ assert broadcast["data"]["asset_id"]
+
+ assert confirm["type"] == "draw_complete"
+ assert confirm["data"]["client_id"] == "draw-test-abc123"
+ assert confirm["data"]["asset_id"] == broadcast["data"]["asset_id"]