From 7151d89a5329ca17230b549e6305ace6b571e7f4 Mon Sep 17 00:00:00 2001 From: Otto Wagner <48657113+isConic@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:03:06 -0500 Subject: [PATCH 1/2] feat(style): generic animate engine + opacity + status attention-lifecycle presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three Style-schema fields (no new verbs, per the asset-style-ir constraint) so agents can visually mark assets as being-processed / completed / abandoned — e.g. survey stations in a pixel-drill demo: - animate: generic property animation, a list of effects driven by ONE shared client rAF loop: [{property: opacity|circle_radius|stroke_width, from, to, period}]. glow stays as back-compat sugar that compiles to a single opacity effect; [] stops a running animation. - opacity: flat 0..1 static opacity on fills/lines/circles, composing with the hover-highlight expression. - status: one-word lifecycle sugar for LLM callers, expanded server-side into concrete fields (explicit fields always win): active -> attention pulse (opacity + marker-size) done -> stop animation, full opacity, success stroke muted -> stop animation, grayed out Server: AssetStyle model validator expands status; update_style broadcasts the EXPANDED style so clients only ever see concrete fields. Frontend: glow engine generalized to the effects list; static opacity paint; update_style cleanly starts/stops/re-tunes animations and resets circle radius. SDK Style + update_style MCP tool docs mirrored. Tests: tests/test_style_animate.py — status expansion, explicit-wins, glow back-compat, animate round-trip, event round-trips, opacity-only partial update. Demo: demo_status.py walks stations through active -> done -> muted. Note: the async-client fixtures share the pre-existing limitation that StreamableHTTPSessionManager.run() is once-per-process (same pattern and behavior as tests/test_assets.py on main). --- demo_status.py | 73 ++++++ sdk/mapcontrol/models.py | 26 +++ server/mapcontrol_server/main.py | 217 ++++++++++++------ server/mapcontrol_server/mcp_tools.py | 9 + server/mapcontrol_server/models.py | 58 ++++- .../services/event_service.py | 11 +- server/tests/test_style_animate.py | 142 ++++++++++++ 7 files changed, 463 insertions(+), 73 deletions(-) create mode 100644 demo_status.py create mode 100644 server/tests/test_style_animate.py diff --git a/demo_status.py b/demo_status.py new file mode 100644 index 0000000..a76c046 --- /dev/null +++ b/demo_status.py @@ -0,0 +1,73 @@ +"""Demo: attention-lifecycle styling (style.status) on survey stations. + +Simulates the EOGPT Cawndilla-2 pixel-drill flow: drop six stations, then +walk them one by one — the station being processed PULSES (status=active), +finished stations flip to a solid success look (status=done), and when the +analysis pivots away, abandoned stations GRAY OUT (status=muted). + +Run: python demo_status.py (server on http://localhost:8000) +Open the printed URL in a browser to watch. +""" + +import time + +import httpx + +BASE = "http://localhost:8000" + +STATIONS = [ + ("P1", -143.20, 32.10), ("P2", -143.05, 32.18), ("P3", -143.12, 31.98), + ("C1", -143.45, 32.30), ("C2", -142.80, 32.35), ("C3", -142.90, 31.85), +] + + +def point_geojson(name, lon, lat): + return ( + '{"type":"Feature","properties":{"name":"%s"},' + '"geometry":{"type":"Point","coordinates":[%f,%f]}}' % (name, lon, lat) + ) + + +def main(): + c = httpx.Client(base_url=BASE, timeout=30) + map_id = c.post("/api/maps").json()["map_id"] + print(f"Map: {BASE}/map/{map_id}") + + def event(type_, data): + r = c.post(f"/api/maps/{map_id}/events", json={"type": type_, "data": data}) + r.raise_for_status() + return r.json() + + # Drop the stations (labeled points) + ids = {} + for name, lon, lat in STATIONS: + resp = event("add_point", { + "geojson": point_geojson(name, lon, lat), "name": name, + "style": {"fill_color": "#38bdf8", "stroke_color": "#e0f2fe", + "stroke_width": 2, "label": True}, + }) + ids[name] = resp["asset_id"] + event("zoom_to_bbox", {"bbox": [-143.7, 31.7, -142.6, 32.5]}) + print("Stations placed. Starting the drill loop...") + time.sleep(3) + + # Drill P1-P3: pulse while "processing", then mark done + for name in ("P1", "P2", "P3"): + print(f" {name}: processing (pulse)...") + event("update_style", {"asset_id": ids[name], "style": {"status": "active"}}) + time.sleep(5) # pretend to compute + print(f" {name}: done.") + event("update_style", {"asset_id": ids[name], "style": {"status": "done"}}) + time.sleep(1) + + # Pivot: the control stations are no longer under consideration + print("Pivoting — muting control stations C1-C3...") + time.sleep(2) + for name in ("C1", "C2", "C3"): + event("update_style", {"asset_id": ids[name], "style": {"status": "muted"}}) + + print("Demo complete: P1-P3 solid green, C1-C3 grayed out.") + + +if __name__ == "__main__": + main() diff --git a/sdk/mapcontrol/models.py b/sdk/mapcontrol/models.py index 077b326..8ebc582 100644 --- a/sdk/mapcontrol/models.py +++ b/sdk/mapcontrol/models.py @@ -26,6 +26,26 @@ class Style: glow: True → defaults, or {"period": 2.0 (seconds per cycle), "min_opacity": 0.15, "max_opacity": 0.85, "stroke": True} — the asset slowly fades between translucent and opaque. + (Back-compat sugar over `animate`.) + + Static opacity: + opacity: flat 0..1 opacity on fills, lines, and circles. + None = renderer defaults. An active animation overrides it + while running. + + Animate (generic property animation): + animate: list of effects driven by one shared client rAF loop: + [{"property": "opacity"|"circle_radius"|"stroke_width", + "from": 0.35, "to": 1.0, "period": 1.2}] + Each effect oscillates the paint property between `from` and + `to` over `period` seconds. [] stops any running animation. + + Status (attention-lifecycle sugar): + status: "active" (attention pulse: opacity + marker-size), + "done" (stop animation, full opacity, success stroke), or + "muted" (stop animation, grayed out). Expanded server-side + into concrete animate/opacity/color fields; explicit fields + you set alongside it always win. """ fill_color: str | None = None stroke_color: str | None = None @@ -37,6 +57,9 @@ class Style: label_placement: str | None = None color_by: dict[str, Any] | None = None glow: bool | dict[str, Any] | None = None + opacity: float | None = None + animate: list[dict[str, Any]] | None = None + status: str | None = None def to_dict(self) -> dict: return {k: v for k, v in { @@ -50,6 +73,9 @@ def to_dict(self) -> dict: "label_placement": self.label_placement, "color_by": self.color_by, "glow": self.glow, + "opacity": self.opacity, + "animate": self.animate, + "status": self.status, }.items() if v is not None} diff --git a/server/mapcontrol_server/main.py b/server/mapcontrol_server/main.py index af02042..a6cb49c 100644 --- a/server/mapcontrol_server/main.py +++ b/server/mapcontrol_server/main.py @@ -1120,85 +1120,147 @@ class BasemapPickerControl {{ return labelId; }} - // ─── Glow engine (pulsing opacity animation, style.glow) ───────── - // One shared requestAnimationFrame loop drives every glowing asset: - // opacity = min + (max-min) * (0.5 + 0.5*sin(2π·t/period)). The loop - // self-starts when the first glowing asset registers and self-stops - // when the last one is removed (no idle CPU burn). Fill opacity - // composes with the feature-state hover expression so hover still - // brightens a glowing polygon. - const glowAssets = {{}}; // asset_id -> {{period, min, max, stroke, layerIds: {{fill, line, circle}}}} - let glowRafId = null; - - function parseGlow(g) {{ - if (!g) return null; - const o = (typeof g === 'object') ? g : {{}}; - return {{ - period: Math.max(0.2, Number(o.period) || 2.0), - min: Math.min(1, Math.max(0, o.min_opacity !== undefined ? Number(o.min_opacity) : 0.15)), - max: Math.min(1, Math.max(0, o.max_opacity !== undefined ? Number(o.max_opacity) : 0.85)), - stroke: o.stroke !== false, - }}; + // ─── Animation engine (style.animate / style.glow) ─────────────── + // One shared requestAnimationFrame loop drives every animated asset. + // style.animate is a list of effects, each oscillating one numeric + // paint property between `from` and `to` over `period` seconds: + // [{{"property": "opacity", "from": 0.35, "to": 1.0, "period": 1.2}}, + // {{"property": "circle_radius", "from": 6, "to": 10, "period": 1.2}}] + // Supported properties: opacity (fill/line/circle, composes with the + // feature-state hover expression), circle_radius, stroke_width. + // style.glow (bool | {{period, min_opacity, max_opacity, stroke}}) is + // kept as back-compat sugar and compiles to a single opacity effect. + // The loop self-starts when the first animated asset registers and + // self-stops when the last one is removed (no idle CPU burn). + const animAssets = {{}}; // asset_id -> {{effects: [...], layerIds: {{fill, line, circle}}, staticOpacity}} + let animRafId = null; + + function normalizeEffects(s) {{ + // Style → effects list. `animate` wins; `glow` compiles to an + // opacity effect. Returns [] when there is nothing to animate. + const out = []; + if (Array.isArray(s.animate)) {{ + for (const e of s.animate) {{ + if (!e || !e.property) continue; + out.push({{ + property: String(e.property), + from: Number(e.from !== undefined ? e.from : 0.2), + to: Number(e.to !== undefined ? e.to : 1.0), + period: Math.max(0.2, Number(e.period) || 2.0), + stroke: e.stroke !== false, + }}); + }} + }} else if (s.glow) {{ + const o = (typeof s.glow === 'object') ? s.glow : {{}}; + out.push({{ + property: 'opacity', + from: Math.min(1, Math.max(0, o.min_opacity !== undefined ? Number(o.min_opacity) : 0.15)), + to: Math.min(1, Math.max(0, o.max_opacity !== undefined ? Number(o.max_opacity) : 0.85)), + period: Math.max(0.2, Number(o.period) || 2.0), + stroke: o.stroke !== false, + }}); + }} + return out; + }} + + function applyStaticOpacity(assetId, opacity) {{ + // Flat opacity on all of an asset's paint layers (style.opacity). + // null/undefined restores renderer defaults. Fill keeps the + // hover-brighten expression, scaled by the requested opacity. + const op = (opacity === null || opacity === undefined) ? null + : Math.min(1, Math.max(0, Number(opacity))); + const fillId = 'fill-' + assetId, lineId = 'line-' + assetId, circleId = 'circle-' + assetId; + try {{ + if (map.getLayer(fillId)) {{ + map.setPaintProperty(fillId, 'fill-opacity', op === null + ? ['case', ['boolean', ['feature-state', 'hover'], false], 0.72, 0.5] + : ['case', ['boolean', ['feature-state', 'hover'], false], + Math.min(1, 0.72 * op + 0.2 * op), 0.5 * op]); + }} + if (map.getLayer(lineId)) map.setPaintProperty(lineId, 'line-opacity', op === null ? 1 : op); + if (map.getLayer(circleId)) {{ + map.setPaintProperty(circleId, 'circle-opacity', op === null ? 1 : op); + map.setPaintProperty(circleId, 'circle-stroke-opacity', op === null ? 1 : op); + }} + }} catch (e) {{}} }} - function glowTick(nowMs) {{ - const ids = Object.keys(glowAssets); - if (ids.length === 0) {{ glowRafId = null; return; }} + function animTick(nowMs) {{ + const ids = Object.keys(animAssets); + if (ids.length === 0) {{ animRafId = null; return; }} const t = nowMs / 1000; for (const aid of ids) {{ - const g = glowAssets[aid]; - const phase = 0.5 + 0.5 * Math.sin((2 * Math.PI * t) / g.period); - const op = g.min + (g.max - g.min) * phase; - try {{ - if (g.layerIds.fill && map.getLayer(g.layerIds.fill)) {{ - // Compose with hover: hovered feature stays brighter - map.setPaintProperty(g.layerIds.fill, 'fill-opacity', ['case', - ['boolean', ['feature-state', 'hover'], false], - Math.min(1, op + 0.2), op]); - }} - if (g.stroke && g.layerIds.line && map.getLayer(g.layerIds.line)) {{ - map.setPaintProperty(g.layerIds.line, 'line-opacity', op); - }} - if (g.layerIds.circle && map.getLayer(g.layerIds.circle)) {{ - map.setPaintProperty(g.layerIds.circle, 'circle-opacity', op); - map.setPaintProperty(g.layerIds.circle, 'circle-stroke-opacity', op); - }} - }} catch (e) {{ /* layer mid-removal; next tick recovers */ }} + const a = animAssets[aid]; + for (const fx of a.effects) {{ + const phase = 0.5 + 0.5 * Math.sin((2 * Math.PI * t) / fx.period); + const v = fx.from + (fx.to - fx.from) * phase; + try {{ + if (fx.property === 'opacity') {{ + if (a.layerIds.fill && map.getLayer(a.layerIds.fill)) {{ + // Compose with hover: hovered feature stays brighter + map.setPaintProperty(a.layerIds.fill, 'fill-opacity', ['case', + ['boolean', ['feature-state', 'hover'], false], + Math.min(1, v + 0.2), v]); + }} + if (fx.stroke && a.layerIds.line && map.getLayer(a.layerIds.line)) {{ + map.setPaintProperty(a.layerIds.line, 'line-opacity', v); + }} + if (a.layerIds.circle && map.getLayer(a.layerIds.circle)) {{ + map.setPaintProperty(a.layerIds.circle, 'circle-opacity', v); + map.setPaintProperty(a.layerIds.circle, 'circle-stroke-opacity', v); + }} + }} else if (fx.property === 'circle_radius') {{ + if (a.layerIds.circle && map.getLayer(a.layerIds.circle)) {{ + map.setPaintProperty(a.layerIds.circle, 'circle-radius', v); + }} + }} else if (fx.property === 'stroke_width') {{ + if (a.layerIds.line && map.getLayer(a.layerIds.line)) {{ + map.setPaintProperty(a.layerIds.line, 'line-width', v); + }} + if (a.layerIds.circle && map.getLayer(a.layerIds.circle)) {{ + map.setPaintProperty(a.layerIds.circle, 'circle-stroke-width', v); + }} + }} + }} catch (e) {{ /* layer mid-removal; next tick recovers */ }} + }} }} map.triggerRepaint(); - glowRafId = requestAnimationFrame(glowTick); - }} - - function registerGlow(assetId, glowCfg) {{ - const g = parseGlow(glowCfg); - if (!g) return; - g.layerIds = {{ - fill: 'fill-' + assetId, - line: 'line-' + assetId, - circle: 'circle-' + assetId, + animRafId = requestAnimationFrame(animTick); + }} + + function registerAnim(assetId, style) {{ + const effects = normalizeEffects(style || {{}}); + if (effects.length === 0) return; + animAssets[assetId] = {{ + effects: effects, + layerIds: {{ + fill: 'fill-' + assetId, + line: 'line-' + assetId, + circle: 'circle-' + assetId, + }}, + staticOpacity: (style && style.opacity !== undefined) ? style.opacity : null, }}; - glowAssets[assetId] = g; - if (glowRafId === null) glowRafId = requestAnimationFrame(glowTick); + if (animRafId === null) animRafId = requestAnimationFrame(animTick); }} - function unregisterGlow(assetId) {{ - if (!glowAssets[assetId]) return; - const g = glowAssets[assetId]; - delete glowAssets[assetId]; - // Restore static paint values + function unregisterAnim(assetId, staticOpacity) {{ + if (!animAssets[assetId]) return; + const a = animAssets[assetId]; + delete animAssets[assetId]; + // Restore static paint values (honoring style.opacity if given) + const op = (staticOpacity !== undefined) ? staticOpacity : a.staticOpacity; + applyStaticOpacity(assetId, op); try {{ - if (map.getLayer(g.layerIds.fill)) {{ - map.setPaintProperty(g.layerIds.fill, 'fill-opacity', ['case', - ['boolean', ['feature-state', 'hover'], false], 0.72, 0.5]); - }} - if (map.getLayer(g.layerIds.line)) map.setPaintProperty(g.layerIds.line, 'line-opacity', 1); - if (map.getLayer(g.layerIds.circle)) {{ - map.setPaintProperty(g.layerIds.circle, 'circle-opacity', 1); - map.setPaintProperty(g.layerIds.circle, 'circle-stroke-opacity', 1); + if (map.getLayer(a.layerIds.circle)) {{ + map.setPaintProperty(a.layerIds.circle, 'circle-radius', 6); }} }} catch (e) {{}} }} + // Back-compat aliases (call sites + older embeds) + function registerGlow(assetId, glowCfg) {{ registerAnim(assetId, {{ glow: glowCfg }}); }} + function unregisterGlow(assetId) {{ unregisterAnim(assetId); }} + // ─── Hover highlight (feature-state) ─── // Polygon fills brighten under the cursor. Uses generateId'd feature // ids on the GeoJSON source; the fill layer's fill-opacity is a @@ -1295,8 +1357,11 @@ class BasemapPickerControl {{ assetRegistry[assetId] = {{ layerIds, bounds: geojsonBounds(geojson), srcId, name: name || null, asset_type: assetType || 'vector', geomTypes: Array.from(geomTypes) }}; - // Optional glow (style.glow): pulsing opacity animation - if (s.glow) registerGlow(assetId, s.glow); + // Static opacity (style.opacity), then optional animation + // (style.animate / style.glow) — the animation overrides the + // static value while running. + if (s.opacity !== undefined && s.opacity !== null) applyStaticOpacity(assetId, s.opacity); + if ((Array.isArray(s.animate) && s.animate.length > 0) || s.glow) registerAnim(assetId, s); }} // ─── Add Image Overlay (GeoTIFF) ─── @@ -1571,10 +1636,20 @@ class BasemapPickerControl {{ if (newId) reg.layerIds.push(newId); }} }} - // Glow start/stop/re-tune via update_style - if (s.glow !== undefined) {{ - unregisterGlow(data.asset_id); - if (s.glow) registerGlow(data.asset_id, s.glow); + // Static opacity via update_style (applied first; a + // running animation re-drives opacity on its next tick) + if (s.opacity !== undefined) {{ + applyStaticOpacity(data.asset_id, s.opacity); + if (animAssets[data.asset_id]) {{ + animAssets[data.asset_id].staticOpacity = s.opacity; + }} + }} + // Animation start/stop/re-tune via update_style. + // animate: [] → stop; animate: [...] → replace effects; + // glow keeps working as the back-compat alias. + if (s.animate !== undefined || s.glow !== undefined) {{ + unregisterAnim(data.asset_id, s.opacity); + registerAnim(data.asset_id, s); }} }} }}, diff --git a/server/mapcontrol_server/mcp_tools.py b/server/mapcontrol_server/mcp_tools.py index 9a98fdc..ee5e714 100644 --- a/server/mapcontrol_server/mcp_tools.py +++ b/server/mapcontrol_server/mcp_tools.py @@ -802,6 +802,15 @@ async def set_visibility(map_id: str, asset_id: str, visible: bool = True) -> di async def update_style(map_id: str, asset_id: str, style: AssetStyle) -> dict[str, Any]: """Change an asset's style (fill_color, stroke_color, stroke_width, line_dash). + Attention lifecycle: style.status is one-word sugar for demo/progress + states — "active" makes the asset pulse (opacity + marker-size + animation) to draw attention while it's being processed; "done" stops + the pulse and marks it complete (full opacity, green stroke); "muted" + grays it out (no longer under consideration). For custom effects use + style.animate = [{"property": "opacity"|"circle_radius"|"stroke_width", + "from": .., "to": .., "period": seconds}] ([] stops animation), and + style.opacity for a flat 0..1 opacity. + Also controls labels: style.label=True renders the asset name as map text (a string gives custom text; False removes the label); label_placement is 'point' | 'center' | 'perimeter' (text along the polygon outline / line); diff --git a/server/mapcontrol_server/models.py b/server/mapcontrol_server/models.py index d272d6f..19bfda5 100644 --- a/server/mapcontrol_server/models.py +++ b/server/mapcontrol_server/models.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator # ─── Style ─────────────────────────────────────────────────────────────────── @@ -38,7 +38,63 @@ class AssetStyle(BaseModel): # "min_opacity": 0.15, "max_opacity": 0.85, "stroke": True}. # The asset slowly fades between translucent and opaque — a client-side # rAF loop; False/None = static. Works on fills, lines, and circles. + # NOTE: glow is now sugar over the generic `animate` list (an opacity + # effect); both are kept so existing callers keep working. glow: bool | dict[str, Any] | None = None + # ─── Static opacity (0..1) ─── + # Flat opacity applied to fills, lines, and circles. None = renderer + # defaults. Composes with hover highlight; ignored while an opacity + # animation is running (the animation wins). + opacity: float | None = None + # ─── Animate (generic property animation) ─── + # A list of effects driven by one shared client-side rAF loop: + # [{"property": "opacity"|"circle_radius"|"stroke_width", + # "from": 0.3, "to": 1.0, "period": 1.2, "easing": "sine"}] + # Each effect oscillates the paint property between `from` and `to` + # over `period` seconds. Empty list / None = no animation. `glow: true` + # compiles to a single opacity effect for back-compat. + animate: list[dict[str, Any]] | None = None + # ─── Status (attention-lifecycle sugar) ─── + # "active" → attention pulse (opacity + marker-size animation) + # "done" → animation stops, full opacity, success stroke + # "muted" → animation stops, grayed out (no longer under consideration) + # Expands server-side into concrete animate/opacity/color fields (only + # filling fields the caller left unset), so clients and session restore + # only ever see concrete style values. + status: str | None = None + + @model_validator(mode="after") + def _expand_status(self) -> "AssetStyle": + """Expand the ``status`` preset into concrete style fields. + + Explicitly-set fields always win; the preset only fills gaps. The + status value itself is preserved so callers can read it back. + """ + if self.status == "active": + if self.animate is None: + self.animate = [ + {"property": "opacity", "from": 0.35, "to": 1.0, "period": 1.2}, + {"property": "circle_radius", "from": 6, "to": 10, "period": 1.2}, + ] + elif self.status == "done": + if self.animate is None: + self.animate = [] # stop any running animation + if self.opacity is None: + self.opacity = 1.0 + if self.stroke_color is None: + self.stroke_color = "#22c55e" + if self.stroke_width is None: + self.stroke_width = 3 + elif self.status == "muted": + if self.animate is None: + self.animate = [] # stop any running animation + if self.opacity is None: + self.opacity = 0.35 + if self.fill_color is None: + self.fill_color = "#9ca3af" + if self.stroke_color is None: + self.stroke_color = "#6b7280" + return self diff --git a/server/mapcontrol_server/services/event_service.py b/server/mapcontrol_server/services/event_service.py index d94ae29..ccd7251 100644 --- a/server/mapcontrol_server/services/event_service.py +++ b/server/mapcontrol_server/services/event_service.py @@ -255,9 +255,18 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: style_data = event.data.get("style", {}) if target_id: from ..models import AssetUpdate + style = AssetStyle(**style_data) await asset_service.update_asset( - map_id, target_id, AssetUpdate(style=AssetStyle(**style_data)) + map_id, target_id, AssetUpdate(style=style) ) + # Broadcast the EXPANDED style so clients receive concrete + # animate/opacity/color fields — style.status is server-side + # sugar (see AssetStyle._expand_status); the frontend only + # understands the concrete fields. exclude_unset keeps the + # partial-update semantics (only caller-touched + preset-filled + # fields travel), so e.g. an opacity-only update can't clobber + # colors. + event.data["style"] = style.model_dump(exclude_none=True) elif event.type == "set_theme": # Map-level UI theme (light | dark | auto). Persist so new viewers / diff --git a/server/tests/test_style_animate.py b/server/tests/test_style_animate.py new file mode 100644 index 0000000..5e7af28 --- /dev/null +++ b/server/tests/test_style_animate.py @@ -0,0 +1,142 @@ +"""Tests for the animate/opacity/status Style-schema extension. + +Covers the AssetStyle model semantics (status preset expansion, explicit +fields winning over presets, glow back-compat) and the event round-trip +(add_point with status → stored expanded style; update_style broadcasts the +expanded style). +""" + +import os +import tempfile + +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from mapcontrol_server.main import app, lifespan +from mapcontrol_server.models import AssetStyle + +POINT_GEOJSON = '{"type":"Feature","geometry":{"type":"Point","coordinates":[-97.7,30.27]},"properties":{"name":"P1"}}' + + +@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 + + +@pytest_asyncio.fixture +async def map_with_point(client): + map_resp = await client.post("/api/maps") + map_id = map_resp.json()["map_id"] + event_resp = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "add_point", + "data": {"geojson": POINT_GEOJSON, "name": "P1"}, + }) + return map_id, event_resp.json()["asset_id"] + + +# ─── Model semantics ───────────────────────────────────────── + +def test_status_active_expands_to_pulse(): + s = AssetStyle(status="active") + assert s.animate is not None and len(s.animate) == 2 + props = {e["property"] for e in s.animate} + assert props == {"opacity", "circle_radius"} + + +def test_status_done_stops_animation_and_marks_complete(): + s = AssetStyle(status="done") + assert s.animate == [] # explicit stop + assert s.opacity == 1.0 + assert s.stroke_color # success stroke set + + +def test_status_muted_grays_out(): + s = AssetStyle(status="muted") + assert s.animate == [] + assert s.opacity is not None and s.opacity < 0.5 + assert s.fill_color and s.stroke_color + + +def test_explicit_fields_win_over_status_preset(): + s = AssetStyle(status="muted", opacity=0.6, fill_color="#123456") + assert s.opacity == 0.6 + assert s.fill_color == "#123456" + + +def test_status_preserved_for_readback(): + s = AssetStyle(status="active") + assert s.status == "active" + assert s.model_dump(exclude_none=True)["status"] == "active" + + +def test_glow_untouched_by_animate_machinery(): + s = AssetStyle(glow=True) + assert s.glow is True + assert s.animate is None # glow compiles client-side; model leaves it + + +def test_custom_animate_round_trips(): + fx = [{"property": "stroke_width", "from": 1, "to": 5, "period": 0.8}] + s = AssetStyle(animate=fx) + assert s.model_dump(exclude_none=True)["animate"] == fx + + +# ─── Event round-trip ──────────────────────────────────────── + +async def test_add_point_with_status_stores_expanded_style(client): + map_resp = await client.post("/api/maps") + map_id = map_resp.json()["map_id"] + event_resp = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "add_point", + "data": {"geojson": POINT_GEOJSON, "name": "P1", + "style": {"status": "active"}}, + }) + asset_id = event_resp.json()["asset_id"] + asset = (await client.get(f"/api/maps/{map_id}/assets/{asset_id}")).json() + style = asset["style"] + assert style["status"] == "active" + assert style["animate"] and len(style["animate"]) == 2 + + +async def test_update_style_status_lifecycle(client, map_with_point): + map_id, asset_id = map_with_point + # active → pulse + r = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "update_style", + "data": {"asset_id": asset_id, "style": {"status": "active"}}, + }) + assert r.status_code == 200 and not r.json().get("error") + # done → stop + complete + r = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "update_style", + "data": {"asset_id": asset_id, "style": {"status": "done"}}, + }) + assert r.status_code == 200 and not r.json().get("error") + asset = (await client.get(f"/api/maps/{map_id}/assets/{asset_id}")).json() + assert asset["style"]["status"] == "done" + assert asset["style"]["animate"] == [] + assert asset["style"]["opacity"] == 1.0 + # muted → gray + r = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "update_style", + "data": {"asset_id": asset_id, "style": {"status": "muted"}}, + }) + assert r.status_code == 200 + asset = (await client.get(f"/api/maps/{map_id}/assets/{asset_id}")).json() + assert asset["style"]["opacity"] < 0.5 + + +async def test_update_style_opacity_only(client, map_with_point): + map_id, asset_id = map_with_point + r = await client.post(f"/api/maps/{map_id}/events", json={ + "type": "update_style", + "data": {"asset_id": asset_id, "style": {"opacity": 0.42}}, + }) + assert r.status_code == 200 and not r.json().get("error") + asset = (await client.get(f"/api/maps/{map_id}/assets/{asset_id}")).json() + assert asset["style"]["opacity"] == 0.42 From 684a05aca79b67297d88d50b89a5161a730dd9d8 Mon Sep 17 00:00:00 2001 From: Otto Wagner <48657113+isConic@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:22:31 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(style):=20ripple=20halo=20effect=20?= =?UTF-8?q?=E2=80=94=20sonar-ping=20ring=20that=20expands=20and=20dissipat?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "ripple" animate property: an auxiliary halo circle layer beneath point markers whose ring expands (radius from→to px) while its opacity ramps to transparent, then restarts — a sawtooth "dissipating wave" rather than the existing sine oscillations. Optional "color" tints the halo (defaults to the marker's fill color). - models.py: document ripple in AssetStyle.animate; status="active" preset now expands to opacity + circle_radius + ripple (3 effects) — explicit animate still wins over the preset - main.py (inline frontend): ensureHaloLayer/removeHaloLayer manage a 'halo-{asset}' circle layer (reuses the asset source, inserted below the marker); animTick drives the sawtooth radius/opacity; registerAnim wires the halo in, unregisterAnim removes it; normalizeEffects passes the optional color through - sdk/mapcontrol/models.py + mcp_tools.py: docs mirrored - tests: +3 (active preset includes ripple w/ outward growth; custom ripple round-trips) — 10/10 model tests pass; the 3 async-fixture errors are the pre-existing StreamableHTTPSessionManager once-per- process limitation (same as tests/test_assets.py on main) - demo_status.py: finale now shows a raw ripple spec (8→40px, amber) --- demo_status.py | 10 +++++ sdk/mapcontrol/models.py | 7 ++- server/mapcontrol_server/main.py | 63 ++++++++++++++++++++++++++- server/mapcontrol_server/mcp_tools.py | 14 +++--- server/mapcontrol_server/models.py | 13 +++++- server/tests/test_style_animate.py | 20 +++++++-- 6 files changed, 115 insertions(+), 12 deletions(-) diff --git a/demo_status.py b/demo_status.py index a76c046..ba80134 100644 --- a/demo_status.py +++ b/demo_status.py @@ -66,6 +66,16 @@ def event(type_, data): for name in ("C1", "C2", "C3"): event("update_style", {"asset_id": ids[name], "style": {"status": "muted"}}) + # Finale: raw ripple spec — a big slow sonar ping with a custom color + time.sleep(3) + print("Finale: raw ripple on P2 (halo 8->40px, 2.2s period, amber)...") + event("update_style", {"asset_id": ids["P2"], "style": { + "animate": [{"property": "ripple", "from": 8, "to": 40, + "period": 2.2, "color": "#f59e0b"}], + }}) + time.sleep(10) + event("update_style", {"asset_id": ids["P2"], "style": {"status": "done"}}) + print("Demo complete: P1-P3 solid green, C1-C3 grayed out.") diff --git a/sdk/mapcontrol/models.py b/sdk/mapcontrol/models.py index 8ebc582..2996942 100644 --- a/sdk/mapcontrol/models.py +++ b/sdk/mapcontrol/models.py @@ -39,9 +39,14 @@ class Style: "from": 0.35, "to": 1.0, "period": 1.2}] Each effect oscillates the paint property between `from` and `to` over `period` seconds. [] stops any running animation. + Special effect "ripple": a sonar-ping halo ring that expands + outward from point markers (radius `from`→`to` px) while + fading to transparent, then restarts (sawtooth). Optional + "color" (hex) tints the halo: + [{"property": "ripple", "from": 8, "to": 26, "period": 1.6}] Status (attention-lifecycle sugar): - status: "active" (attention pulse: opacity + marker-size), + status: "active" (attention pulse: opacity + marker-size + ripple halo), "done" (stop animation, full opacity, success stroke), or "muted" (stop animation, grayed out). Expanded server-side into concrete animate/opacity/color fields; explicit fields diff --git a/server/mapcontrol_server/main.py b/server/mapcontrol_server/main.py index a6cb49c..8653e58 100644 --- a/server/mapcontrol_server/main.py +++ b/server/mapcontrol_server/main.py @@ -1127,7 +1127,10 @@ class BasemapPickerControl {{ // [{{"property": "opacity", "from": 0.35, "to": 1.0, "period": 1.2}}, // {{"property": "circle_radius", "from": 6, "to": 10, "period": 1.2}}] // Supported properties: opacity (fill/line/circle, composes with the - // feature-state hover expression), circle_radius, stroke_width. + // feature-state hover expression), circle_radius, stroke_width, and + // ripple (a sonar-ping halo on point markers: an auxiliary ring + // beneath the marker expands `from`→`to` px while fading to + // transparent — sawtooth, not sine — then restarts). // style.glow (bool | {{period, min_opacity, max_opacity, stroke}}) is // kept as back-compat sugar and compiles to a single opacity effect. // The loop self-starts when the first animated asset registers and @@ -1148,6 +1151,7 @@ class BasemapPickerControl {{ to: Number(e.to !== undefined ? e.to : 1.0), period: Math.max(0.2, Number(e.period) || 2.0), stroke: e.stroke !== false, + color: (typeof e.color === 'string') ? e.color : null, }}); }} }} else if (s.glow) {{ @@ -1220,6 +1224,17 @@ class BasemapPickerControl {{ if (a.layerIds.circle && map.getLayer(a.layerIds.circle)) {{ map.setPaintProperty(a.layerIds.circle, 'circle-stroke-width', v); }} + }} else if (fx.property === 'ripple') {{ + // Sonar ping: sawtooth phase — the halo ring + // grows outward while fading to transparent, + // then snaps back and repeats (dissipating wave). + if (a.layerIds.halo && map.getLayer(a.layerIds.halo)) {{ + const saw = ((t / fx.period) % 1 + 1) % 1; + const r = fx.from + (fx.to - fx.from) * saw; + const o = 0.55 * (1 - saw); + map.setPaintProperty(a.layerIds.halo, 'circle-radius', r); + map.setPaintProperty(a.layerIds.halo, 'circle-stroke-opacity', o); + }} }} }} catch (e) {{ /* layer mid-removal; next tick recovers */ }} }} @@ -1228,15 +1243,60 @@ class BasemapPickerControl {{ animRafId = requestAnimationFrame(animTick); }} + function ensureHaloLayer(assetId, rippleFx) {{ + // Auxiliary expanding-ring layer for the ripple effect. Reuses + // the asset's GeoJSON source; inserted beneath the marker circle + // so the ping radiates from behind it. Returns the halo layer id + // or null (no source on this asset). + const haloId = 'halo-' + assetId; + if (map.getLayer(haloId)) return haloId; + const srcId = 'src-' + assetId; + if (!map.getSource(srcId)) return null; + const circleId = 'circle-' + assetId; + let color = rippleFx.color; + if (!color) {{ + try {{ + const p = map.getPaintProperty(circleId, 'circle-color'); + if (typeof p === 'string') color = p; + }} catch (e) {{}} + }} + const spec = {{ + id: haloId, type: 'circle', source: srcId, + filter: ['==', '$type', 'Point'], + paint: {{ + 'circle-color': 'rgba(0,0,0,0)', + 'circle-radius': rippleFx.from, + 'circle-opacity': 0, + 'circle-stroke-color': color || '#38bdf8', + 'circle-stroke-width': 2, + 'circle-stroke-opacity': 0, + }}, + }}; + try {{ + if (map.getLayer(circleId)) map.addLayer(spec, circleId); + else map.addLayer(spec); + }} catch (e) {{ return null; }} + return haloId; + }} + + function removeHaloLayer(assetId) {{ + const haloId = 'halo-' + assetId; + try {{ if (map.getLayer(haloId)) map.removeLayer(haloId); }} catch (e) {{}} + }} + function registerAnim(assetId, style) {{ const effects = normalizeEffects(style || {{}}); if (effects.length === 0) return; + const rippleFx = effects.find(fx => fx.property === 'ripple'); + const haloId = rippleFx ? ensureHaloLayer(assetId, rippleFx) : null; + if (!rippleFx) removeHaloLayer(assetId); animAssets[assetId] = {{ effects: effects, layerIds: {{ fill: 'fill-' + assetId, line: 'line-' + assetId, circle: 'circle-' + assetId, + halo: haloId, }}, staticOpacity: (style && style.opacity !== undefined) ? style.opacity : null, }}; @@ -1255,6 +1315,7 @@ class BasemapPickerControl {{ map.setPaintProperty(a.layerIds.circle, 'circle-radius', 6); }} }} catch (e) {{}} + removeHaloLayer(assetId); }} // Back-compat aliases (call sites + older embeds) diff --git a/server/mapcontrol_server/mcp_tools.py b/server/mapcontrol_server/mcp_tools.py index ee5e714..3d06dc0 100644 --- a/server/mapcontrol_server/mcp_tools.py +++ b/server/mapcontrol_server/mcp_tools.py @@ -804,12 +804,14 @@ async def update_style(map_id: str, asset_id: str, style: AssetStyle) -> dict[st Attention lifecycle: style.status is one-word sugar for demo/progress states — "active" makes the asset pulse (opacity + marker-size - animation) to draw attention while it's being processed; "done" stops - the pulse and marks it complete (full opacity, green stroke); "muted" - grays it out (no longer under consideration). For custom effects use - style.animate = [{"property": "opacity"|"circle_radius"|"stroke_width", - "from": .., "to": .., "period": seconds}] ([] stops animation), and - style.opacity for a flat 0..1 opacity. + animation plus a ripple halo — a sonar-ping ring expanding outward + while dissipating) to draw attention while it's being processed; + "done" stops the pulse and marks it complete (full opacity, green + stroke); "muted" grays it out (no longer under consideration). For + custom effects use style.animate = [{"property": "opacity"| + "circle_radius"|"stroke_width"|"ripple", "from": .., "to": .., + "period": seconds, "color": optional hex for ripple}] ([] stops + animation), and style.opacity for a flat 0..1 opacity. Also controls labels: style.label=True renders the asset name as map text (a string gives custom text; False removes the label); label_placement is diff --git a/server/mapcontrol_server/models.py b/server/mapcontrol_server/models.py index 19bfda5..740682f 100644 --- a/server/mapcontrol_server/models.py +++ b/server/mapcontrol_server/models.py @@ -53,9 +53,19 @@ class AssetStyle(BaseModel): # Each effect oscillates the paint property between `from` and `to` # over `period` seconds. Empty list / None = no animation. `glow: true` # compiles to a single opacity effect for back-compat. + # + # Special effect: "ripple" — a sonar-ping halo ring that expands + # outward from point markers while fading to transparent (sawtooth, + # not sine): radius ramps `from` → `to` px per cycle as opacity ramps + # to 0, then restarts. Optional "color" (hex) tints the halo + # (defaults to the marker's fill color): + # [{"property": "ripple", "from": 8, "to": 26, "period": 1.6, + # "color": "#38bdf8"}] + # Rendered as an auxiliary circle layer beneath the marker; removed + # automatically when the animation stops. Points only. animate: list[dict[str, Any]] | None = None # ─── Status (attention-lifecycle sugar) ─── - # "active" → attention pulse (opacity + marker-size animation) + # "active" → attention pulse (opacity + marker-size + ripple halo) # "done" → animation stops, full opacity, success stroke # "muted" → animation stops, grayed out (no longer under consideration) # Expands server-side into concrete animate/opacity/color fields (only @@ -75,6 +85,7 @@ def _expand_status(self) -> "AssetStyle": self.animate = [ {"property": "opacity", "from": 0.35, "to": 1.0, "period": 1.2}, {"property": "circle_radius", "from": 6, "to": 10, "period": 1.2}, + {"property": "ripple", "from": 8, "to": 26, "period": 1.6}, ] elif self.status == "done": if self.animate is None: diff --git a/server/tests/test_style_animate.py b/server/tests/test_style_animate.py index 5e7af28..7b13ea2 100644 --- a/server/tests/test_style_animate.py +++ b/server/tests/test_style_animate.py @@ -43,9 +43,23 @@ async def map_with_point(client): def test_status_active_expands_to_pulse(): s = AssetStyle(status="active") - assert s.animate is not None and len(s.animate) == 2 + assert s.animate is not None and len(s.animate) == 3 props = {e["property"] for e in s.animate} - assert props == {"opacity", "circle_radius"} + assert props == {"opacity", "circle_radius", "ripple"} + + +def test_status_active_ripple_expands_outward(): + s = AssetStyle(status="active") + ripple = next(e for e in s.animate if e["property"] == "ripple") + assert ripple["to"] > ripple["from"] # halo grows outward + assert ripple["period"] > 0 + + +def test_custom_ripple_with_color_round_trips(): + fx = [{"property": "ripple", "from": 8, "to": 30, "period": 2.0, + "color": "#38bdf8"}] + s = AssetStyle(animate=fx) + assert s.model_dump(exclude_none=True)["animate"] == fx def test_status_done_stops_animation_and_marks_complete(): @@ -100,7 +114,7 @@ async def test_add_point_with_status_stores_expanded_style(client): asset = (await client.get(f"/api/maps/{map_id}/assets/{asset_id}")).json() style = asset["style"] assert style["status"] == "active" - assert style["animate"] and len(style["animate"]) == 2 + assert style["animate"] and len(style["animate"]) == 3 async def test_update_style_status_lifecycle(client, map_with_point):