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
248 changes: 212 additions & 36 deletions server/mapcontrol_server/main.py

Large diffs are not rendered by default.

73 changes: 66 additions & 7 deletions server/mapcontrol_server/services/asset_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 16 additions & 1 deletion server/mapcontrol_server/services/event_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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":
Expand All @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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 {}),
Expand Down Expand Up @@ -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
Expand Down
63 changes: 0 additions & 63 deletions server/mapcontrol_server/static/esip-embed.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
69 changes: 6 additions & 63 deletions server/mapcontrol_server/static/esip-embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading