Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion server/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────
Expand Down
9 changes: 9 additions & 0 deletions server/mapcontrol_server/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down Expand Up @@ -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
Expand Down
928 changes: 559 additions & 369 deletions server/mapcontrol_server/main.py

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion server/mapcontrol_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions server/mapcontrol_server/routers/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
120 changes: 68 additions & 52 deletions server/mapcontrol_server/services/asset_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -54,6 +62,7 @@ async def create_asset(
int(animated),
z_index,
source_url,
json.dumps(bbox) if bbox else None,
now,
now,
),
Expand All @@ -66,6 +75,7 @@ async def create_asset(
name=name,
asset_type=asset_type,
geojson=geojson,
bbox=bbox,
style=style,
metadata=metadata,
visible=True,
Expand Down Expand Up @@ -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"])
Expand All @@ -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"]),
Expand All @@ -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.

Expand Down
Loading
Loading