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"]