diff --git a/backend/__init__.py b/backend/__init__.py index 6881226..ef31969 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -11,4 +11,18 @@ graphlink_app/tests/test_no_qt_anywhere.py. """ +import sys +from pathlib import Path + BACKEND_VERSION = "0.1.0" + +# The surviving Qt-free domain modules (GridViewSettings, NavigationPinStore, +# payload dataclasses, session layer, ...) live flat inside graphlink_app/ +# and import each other by bare name - the same path convention the Qt entry +# point used. The backend consumes them under that convention until the R7 +# cutover restructures the tree. Qt-free-ness of everything imported from +# there is enforced per-module by test_no_qt_anywhere.py's zero-tolerance +# rule the moment it lands in backend/ imports. +_GRAPHLINK_APP_DIR = str(Path(__file__).resolve().parents[1] / "graphlink_app") +if _GRAPHLINK_APP_DIR not in sys.path: + sys.path.insert(0, _GRAPHLINK_APP_DIR) diff --git a/backend/app.py b/backend/app.py index e17cf80..6f424a0 100644 --- a/backend/app.py +++ b/backend/app.py @@ -31,6 +31,7 @@ from fastapi.staticfiles import StaticFiles from backend import BACKEND_VERSION +from backend.canvas import register_canvas from backend.events import EventBus, SessionBus, UnknownIntentError, UnknownTopicError logger = logging.getLogger(__name__) @@ -56,6 +57,9 @@ def ping(*args): bus.register_intent("system", "ping", ping) + # R1 (doc/QT_REMOVAL_PLAN.md): scene document + grid topics. + register_canvas(bus) + def create_app(spa_dir: Path | None = None) -> FastAPI: app = FastAPI(title="Graphlink backend", version=BACKEND_VERSION) diff --git a/backend/canvas.py b/backend/canvas.py new file mode 100644 index 0000000..4b04c35 --- /dev/null +++ b/backend/canvas.py @@ -0,0 +1,269 @@ +"""Canvas domain state for the new architecture (Qt-removal plan R1). + +The backend half of the React Flow canvas: the scene document (nodes, edges, +navigation pins, canvas settings) plus the grid-appearance topic, all +session-scoped and WS-published as full snapshots. + +Model sources are the surviving Qt-free modules, per the plan's R1 line +"sourced from the existing Qt-free scene model files": +- `GridViewSettings` (graphlink_grid_view_settings) - the exact model + `ChatView.drawBackground()` reads today, republished here unchanged so the + R2 grid-control port keeps its intent names 1:1 with GridControlBridge. +- `NavigationPinStore`/`NavigationPinRecord` (graphlink_navigation_pins) - + the pin domain store, reused verbatim (validation, ordering, ids). + +R1 nodes are PLACEHOLDERS by design (plan acceptance: "create/move/connect +placeholder nodes"); real node types arrive one-per-increment in R3. Scene +persistence to the session DB is R6 - for R1 the backend's in-memory +document IS the source of truth the window can reload against. +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass, field +from typing import Any + +from graphlink_grid_view_settings import ( + GRID_SIZE_PRESETS, + GRID_STYLE_PRESETS, + GridViewSettings, +) +from graphlink_navigation_pins import NavigationPinRecord, NavigationPinStore + +from backend.events import SessionBus + +# Dark-theme grid swatches. The Qt bridge derived 3 of 5 from the live +# QPalette; the backend is Qt-free by law (test_no_qt_anywhere.py), so until +# the R2 theme service exists these are the dark theme's actual values, +# frozen here as data, not styling. +GRID_COLOR_PRESETS = ["#404040", "#555555", "#4a90d9", "#2f5b3c", "#5b2f4f"] + +DRAG_FACTOR_MIN = 0.05 +DRAG_FACTOR_MAX = 1.0 + + +class SceneError(ValueError): + """A scene intent referenced something that does not exist or is invalid. + Raised so the WS layer reports it to the caller instead of crashing.""" + + +@dataclass +class SceneNode: + id: str + x: float + y: float + title: str + kind: str = "placeholder" + + +@dataclass +class SceneEdge: + id: str + source: str + target: str + + +@dataclass +class SceneDocument: + """The canvas document for one session. Plain data + invariants; the + R6 serializer will read/write exactly this shape.""" + + nodes: dict[str, SceneNode] = field(default_factory=dict) + edges: dict[str, SceneEdge] = field(default_factory=dict) + pins: NavigationPinStore = field(default_factory=NavigationPinStore) + grid: GridViewSettings = field(default_factory=GridViewSettings) + snap_to_grid: bool = False + drag_factor: float = 1.0 + _counter: itertools.count = field(default_factory=itertools.count, repr=False) + + # -- nodes ------------------------------------------------------------- + + def add_node(self, x: float, y: float, title: str = "") -> SceneNode: + node_id = f"n{next(self._counter)}" + node = SceneNode(id=node_id, x=float(x), y=float(y), title=title or f"Node {node_id[1:]}") + self.nodes[node_id] = node + return node + + def move_node(self, node_id: str, x: float, y: float) -> SceneNode: + node = self.nodes.get(node_id) + if node is None: + raise SceneError(f"unknown node: {node_id}") + node.x, node.y = float(x), float(y) + return node + + def remove_nodes(self, node_ids: list[str]) -> None: + for node_id in node_ids: + if self.nodes.pop(node_id, None) is not None: + # Edges die with either endpoint - same invariant ChatScene + # enforced on node removal. + self.edges = { + eid: e + for eid, e in self.edges.items() + if e.source != node_id and e.target != node_id + } + + # -- edges ------------------------------------------------------------- + + def connect(self, source: str, target: str) -> SceneEdge: + if source not in self.nodes or target not in self.nodes: + raise SceneError(f"cannot connect unknown nodes: {source} -> {target}") + if source == target: + raise SceneError("cannot connect a node to itself") + for edge in self.edges.values(): + if edge.source == source and edge.target == target: + return edge # idempotent, matching ChatScene's duplicate guard + edge_id = f"e{next(self._counter)}" + edge = SceneEdge(id=edge_id, source=source, target=target) + self.edges[edge_id] = edge + return edge + + def remove_edges(self, edge_ids: list[str]) -> None: + for edge_id in edge_ids: + self.edges.pop(edge_id, None) + + # -- settings ---------------------------------------------------------- + + def set_drag_factor(self, factor: float) -> None: + self.drag_factor = max(DRAG_FACTOR_MIN, min(DRAG_FACTOR_MAX, float(factor))) + + # -- snapshots --------------------------------------------------------- + + def scene_payload(self) -> dict[str, Any]: + return { + "nodes": [ + {"id": n.id, "x": n.x, "y": n.y, "title": n.title, "kind": n.kind} + for n in self.nodes.values() + ], + "edges": [ + {"id": e.id, "source": e.source, "target": e.target} + for e in self.edges.values() + ], + "pins": [ + { + "id": p.pin_id, + "title": p.title, + "note": p.note, + "x": p.position[0], + "y": p.position[1], + } + for p in self.pins.records + ], + "snapToGrid": self.snap_to_grid, + "dragFactor": self.drag_factor, + } + + def grid_payload(self) -> dict[str, Any]: + # Field-for-field the GridControlStatePayload shape (whole-percent + # opacity, presets on the wire) so the generated validator the + # grid-control island already uses validates this topic untouched. + return { + "gridSize": self.grid.grid_size, + "gridOpacityPercent": round(self.grid.grid_opacity * 100), + "gridStyle": self.grid.grid_style, + "gridColor": self.grid.grid_color, + "sizePresets": list(GRID_SIZE_PRESETS), + "stylePresets": list(GRID_STYLE_PRESETS), + "colorPresets": list(GRID_COLOR_PRESETS), + } + + +def register_canvas(bus: SessionBus) -> SceneDocument: + """Give a session its canvas document + the scene/grid topics and every + R1 intent. Intent names for grid mirror GridControlBridge's @Slot names + 1:1 so the R2 island port is a transport swap, not a redesign.""" + + document = SceneDocument() + + bus.register_topic("scene", document.scene_payload) + bus.register_topic("grid-control", document.grid_payload) + + async def publish_scene(): + await bus.publish("scene") + + async def publish_grid(): + await bus.publish("grid-control") + + # -- scene intents (async: they publish after mutating) --------------- + + async def add_node(x, y, title=""): + node = document.add_node(x, y, title) + await publish_scene() + return node.id + + async def move_node(node_id, x, y): + document.move_node(node_id, x, y) + await publish_scene() + + async def remove_nodes(node_ids): + document.remove_nodes(list(node_ids)) + await publish_scene() + + async def connect_nodes(source, target): + edge = document.connect(source, target) + await publish_scene() + return edge.id + + async def remove_edges(edge_ids): + document.remove_edges(list(edge_ids)) + await publish_scene() + + async def add_pin(title, x, y, note=""): + record = NavigationPinRecord.create(title=title, x=x, y=y, note=note) + document.pins.add(record) + await publish_scene() + return record.pin_id + + async def move_pin(pin_id, x, y): + document.pins.move(pin_id, x, y) + await publish_scene() + + async def remove_pin(pin_id): + document.pins.remove(pin_id) + await publish_scene() + + async def set_snap_to_grid(enabled): + document.snap_to_grid = bool(enabled) + await publish_scene() + + async def set_drag_factor(factor): + document.set_drag_factor(factor) + await publish_scene() + + bus.register_intent("scene", "addNode", add_node) + bus.register_intent("scene", "moveNode", move_node) + bus.register_intent("scene", "removeNodes", remove_nodes) + bus.register_intent("scene", "connectNodes", connect_nodes) + bus.register_intent("scene", "removeEdges", remove_edges) + bus.register_intent("scene", "addPin", add_pin) + bus.register_intent("scene", "movePin", move_pin) + bus.register_intent("scene", "removePin", remove_pin) + bus.register_intent("scene", "setSnapToGrid", set_snap_to_grid) + bus.register_intent("scene", "setDragFactor", set_drag_factor) + + # -- grid intents (names == GridControlBridge @Slot names) ------------- + + async def set_grid_size(size): + document.grid.grid_size = int(size) + await publish_grid() + + async def set_grid_opacity_percent(percent): + document.grid.grid_opacity = max(0, min(100, int(percent))) / 100.0 + await publish_grid() + + async def set_grid_style(style): + if style not in GRID_STYLE_PRESETS: + raise SceneError(f"unknown grid style: {style}") + document.grid.grid_style = str(style) + await publish_grid() + + async def set_grid_color(color_hex): + document.grid.grid_color = str(color_hex) + await publish_grid() + + bus.register_intent("grid-control", "setGridSize", set_grid_size) + bus.register_intent("grid-control", "setGridOpacityPercent", set_grid_opacity_percent) + bus.register_intent("grid-control", "setGridStyle", set_grid_style) + bus.register_intent("grid-control", "setGridColor", set_grid_color) + + return document diff --git a/backend/tests/test_app_ws.py b/backend/tests/test_app_ws.py index f18607c..b1b2586 100644 --- a/backend/tests/test_app_ws.py +++ b/backend/tests/test_app_ws.py @@ -44,8 +44,9 @@ def test_subscribe_without_topics_sends_every_registered_topic(): client = make_client() with client.websocket_connect("/ws") as ws: ws.send_json({"kind": "subscribe"}) - message = ws.receive_json() - assert message["topic"] == "system", "R0 registers exactly the system topic" + # R1 surface: grid-control + scene (canvas) alongside system, sorted. + topics = [ws.receive_json()["topic"] for _ in range(3)] + assert topics == ["grid-control", "scene", "system"] def test_ping_round_trip_returns_echo_and_server_time(): diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py new file mode 100644 index 0000000..d685c67 --- /dev/null +++ b/backend/tests/test_canvas.py @@ -0,0 +1,168 @@ +"""Canvas domain tests (Qt-removal plan R1): scene document invariants, +intent surface, grid payload compatibility with the generated validator's +shape, and snapshot publishing.""" + +import asyncio + +import pytest + +from backend.canvas import ( + DRAG_FACTOR_MAX, + DRAG_FACTOR_MIN, + SceneDocument, + SceneError, + register_canvas, +) +from backend.events import SessionBus + + +# -- document invariants ---------------------------------------------------- + + +def test_add_move_and_remove_nodes(): + doc = SceneDocument() + a = doc.add_node(10, 20, "A") + assert doc.nodes[a.id].x == 10 + doc.move_node(a.id, -5.5, 7.25) + assert (doc.nodes[a.id].x, doc.nodes[a.id].y) == (-5.5, 7.25) + doc.remove_nodes([a.id]) + assert doc.nodes == {} + + +def test_move_unknown_node_raises_scene_error(): + with pytest.raises(SceneError): + SceneDocument().move_node("nope", 0, 0) + + +def test_connect_validates_and_is_idempotent(): + doc = SceneDocument() + a, b = doc.add_node(0, 0), doc.add_node(100, 0) + edge = doc.connect(a.id, b.id) + assert doc.connect(a.id, b.id).id == edge.id, "duplicate connect returns the same edge" + with pytest.raises(SceneError): + doc.connect(a.id, a.id) + with pytest.raises(SceneError): + doc.connect(a.id, "ghost") + + +def test_removing_a_node_removes_its_edges(): + doc = SceneDocument() + a, b, c = doc.add_node(0, 0), doc.add_node(1, 1), doc.add_node(2, 2) + doc.connect(a.id, b.id) + keep = doc.connect(b.id, c.id) + doc.remove_nodes([a.id]) + assert list(doc.edges) == [keep.id], "edges die with either endpoint" + + +def test_drag_factor_is_bounded(): + doc = SceneDocument() + doc.set_drag_factor(99) + assert doc.drag_factor == DRAG_FACTOR_MAX + doc.set_drag_factor(0.0001) + assert doc.drag_factor == DRAG_FACTOR_MIN + + +def test_grid_payload_matches_generated_validator_shape(): + payload = SceneDocument().grid_payload() + # Field-for-field the GridControlStatePayload contract (minus the + # envelope, which the bus stamps): the R2 island port depends on this. + assert set(payload) == { + "gridSize", + "gridOpacityPercent", + "gridStyle", + "gridColor", + "sizePresets", + "stylePresets", + "colorPresets", + } + assert isinstance(payload["gridOpacityPercent"], int) + assert len(payload["colorPresets"]) == 5 + + +# -- intent surface over the bus -------------------------------------------- + + +class Recorder: + def __init__(self): + self.messages = [] + + async def send_json(self, data): + self.messages.append(data) + + def topics_seen(self): + return [m["topic"] for m in self.messages if m["kind"] == "state"] + + +def make_bus(): + bus = SessionBus("canvas-test") + document = register_canvas(bus) + recorder = Recorder() + bus.attach(recorder) + return bus, document, recorder + + +def test_scene_intents_mutate_and_publish(): + async def run(): + bus, document, recorder = make_bus() + node_id = await bus.dispatch_intent("scene", "addNode", [40, 60, "hello"]) + assert document.nodes[node_id].title == "hello" + other = await bus.dispatch_intent("scene", "addNode", [0, 0]) + edge_id = await bus.dispatch_intent("scene", "connectNodes", [node_id, other]) + assert edge_id in document.edges + await bus.dispatch_intent("scene", "moveNode", [node_id, 1, 2]) + assert (document.nodes[node_id].x, document.nodes[node_id].y) == (1, 2) + assert recorder.topics_seen().count("scene") == 4, "every mutation publishes" + + asyncio.run(run()) + + +def test_pin_intents_round_trip_through_the_store(): + async def run(): + bus, document, _ = make_bus() + pin_id = await bus.dispatch_intent("scene", "addPin", ["Start here", 5, 9, "note!"]) + payload = document.scene_payload() + assert payload["pins"] == [ + {"id": pin_id, "title": "Start here", "note": "note!", "x": 5.0, "y": 9.0} + ] + await bus.dispatch_intent("scene", "movePin", [pin_id, 50, 90]) + assert document.scene_payload()["pins"][0]["x"] == 50.0 + await bus.dispatch_intent("scene", "removePin", [pin_id]) + assert document.scene_payload()["pins"] == [] + + asyncio.run(run()) + + +def test_grid_intents_use_the_bridge_slot_names_and_publish_grid_topic(): + async def run(): + bus, document, recorder = make_bus() + await bus.dispatch_intent("grid-control", "setGridSize", [50]) + await bus.dispatch_intent("grid-control", "setGridOpacityPercent", [140]) + await bus.dispatch_intent("grid-control", "setGridStyle", ["Lines"]) + await bus.dispatch_intent("grid-control", "setGridColor", ["#404040"]) + assert document.grid.grid_size == 50 + assert document.grid.grid_opacity == 1.0, "opacity clamps to 100%" + assert document.grid.grid_style == "Lines" + assert recorder.topics_seen().count("grid-control") == 4 + + asyncio.run(run()) + + +def test_unknown_grid_style_is_rejected(): + async def run(): + bus, _, _ = make_bus() + with pytest.raises(SceneError): + await bus.dispatch_intent("grid-control", "setGridStyle", ["Sparkles"]) + + asyncio.run(run()) + + +def test_snap_and_drag_factor_intents_publish_scene(): + async def run(): + bus, document, _ = make_bus() + await bus.dispatch_intent("scene", "setSnapToGrid", [True]) + await bus.dispatch_intent("scene", "setDragFactor", [0.5]) + payload = document.scene_payload() + assert payload["snapToGrid"] is True + assert payload["dragFactor"] == 0.5 + + asyncio.run(run()) diff --git a/graphlink_app/graphlink_island_codegen.py b/graphlink_app/graphlink_island_codegen.py index 60d699c..7435b25 100644 --- a/graphlink_app/graphlink_island_codegen.py +++ b/graphlink_app/graphlink_island_codegen.py @@ -471,6 +471,17 @@ def _validator_for(name: str, fields: dict[str, Any]) -> str: "schema_path": _REPO_ROOT / "web_ui" / "src" / "lib" / "bridge-core" / "generated" / "minimap-state.schema.json", "ts_path": _REPO_ROOT / "web_ui" / "src" / "lib" / "bridge-core" / "generated" / "minimap-state.ts", }, + { + # Qt-removal plan R1: the scene topic (backend/canvas.py's + # SceneDocument) - the first payload born for the WS bus rather than + # a QWebChannel island; same registry, same pipeline. + "dataclass": None, # resolved lazily in main() to avoid importing + "dataclass_import": ("graphlink_scene_payload", "SceneStatePayload"), + "title": "SceneState", + "source": "graphlink_app/graphlink_scene_payload.py::SceneStatePayload", + "schema_path": _REPO_ROOT / "web_ui" / "src" / "lib" / "bridge-core" / "generated" / "scene-state.schema.json", + "ts_path": _REPO_ROOT / "web_ui" / "src" / "lib" / "bridge-core" / "generated" / "scene-state.ts", + }, ] diff --git a/graphlink_app/graphlink_scene_payload.py b/graphlink_app/graphlink_scene_payload.py new file mode 100644 index 0000000..3b05daf --- /dev/null +++ b/graphlink_app/graphlink_scene_payload.py @@ -0,0 +1,56 @@ +"""The scene topic's wire contract (Qt-removal plan R1) - the canvas +document the React Flow canvas renders: nodes, edges, navigation pins, and +canvas settings, published as full snapshots over the WS event bus. + +THIS IS A WIRE FORMAT, NOT A DOMAIN MODEL (the graphlink_composer_payload.py +convention): the domain lives in backend/canvas.py's SceneDocument, which +sources GridViewSettings and NavigationPinStore - this module only fixes the +JSON shape and generates the TS type + runtime validator the SPA consumes. + +R1 nodes are placeholders (`kind: "placeholder"`); R3 extends `kind` per +migrated node type - additive only, per the schema-versioning contract. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class SceneNodeRow: + id: str + x: float + y: float + title: str + kind: str + + +@dataclass +class SceneEdgeRow: + id: str + source: str + target: str + + +@dataclass +class ScenePinRow: + id: str + title: str + note: str + x: float + y: float + + +@dataclass +class SceneStatePayload: + """The complete published snapshot, including the envelope fields the + event bus stamps onto every topic's payload.""" + + schemaVersion: int + revision: int + nodes: list[SceneNodeRow] + edges: list[SceneEdgeRow] + pins: list[ScenePinRow] + snapToGrid: bool + dragFactor: float + minCompatibleSchemaVersion: int | None = None diff --git a/web_ui/package-lock.json b/web_ui/package-lock.json index a26fa43..57a61a0 100644 --- a/web_ui/package-lock.json +++ b/web_ui/package-lock.json @@ -8,6 +8,7 @@ "name": "@graphlink/web-ui", "version": "0.1.0", "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -2038,6 +2039,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2139,7 +2189,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "peerDependencies": { @@ -2536,6 +2586,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2792,6 +2884,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2851,6 +2949,112 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -6033,6 +6237,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -6379,6 +6592,34 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web_ui/package.json b/web_ui/package.json index 9881ee1..05e9e90 100644 --- a/web_ui/package.json +++ b/web_ui/package.json @@ -17,6 +17,7 @@ "check": "npm run check:schema && npm run typecheck && npm run lint && npm run test && npm run build" }, "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx index 8fb95b2..f955c03 100644 --- a/web_ui/src/app/App.tsx +++ b/web_ui/src/app/App.tsx @@ -1,5 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { ConnectionStatus, WsTransport, defaultWsUrl } from "../lib/ws/transport"; +import { SceneCanvas } from "./canvas/SceneCanvas"; +import { SceneStore } from "./canvas/sceneStore"; /** * The single-app shell (Qt-removal plan R0). @@ -23,19 +25,22 @@ function App() { const [pingError, setPingError] = useState(null); const transport = useMemo(() => new WsTransport(defaultWsUrl()), []); + const sceneStore = useMemo(() => new SceneStore(transport), [transport]); useEffect(() => { const offStatus = transport.onStatus(setStatus); const offSystem = transport.subscribe("system", (payload) => { setSystem(payload as SystemState); }); + sceneStore.connect(); transport.connect(); return () => { offStatus(); offSystem(); + sceneStore.dispose(); transport.dispose(); }; - }, [transport]); + }, [transport, sceneStore]); async function ping() { setPingError(null); @@ -60,10 +65,7 @@ function App() {
-
-

Canvas

-

React Flow node graph lands in R1.

-
+

SYSTEM

diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx new file mode 100644 index 0000000..9fb4b8f --- /dev/null +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -0,0 +1,258 @@ +import { + Background, + BackgroundVariant, + Handle, + MiniMap, + Position, + ReactFlow, + ReactFlowProvider, + useReactFlow, + useStore, + type Connection, + type Edge, + type Node, + type NodeChange, + type NodeProps, + applyNodeChanges, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { SceneState } from "../../lib/bridge-core/generated/scene-state"; +import { SceneStore, scaleDragPosition } from "./sceneStore"; + +/** + * The React Flow canvas (Qt-removal plan R1) - the QGraphicsScene/ChatView + * successor. R1 scope: pan/zoom, model-driven grid (size/style/color/opacity + * + snap), node drag with the drag-speed factor, edges, selection + delete, + * minimap, an LOD threshold, navigation pins. Placeholder nodes only; real + * node types land per-increment in R3. + */ + +// Below this zoom the node body collapses to its title bar - the R1 seed of +// the Qt canvas's LOD thresholds (full LOD tiers return with real nodes). +const LOD_ZOOM_THRESHOLD = 0.5; + +const GRID_VARIANTS: Record = { + Dots: BackgroundVariant.Dots, + Lines: BackgroundVariant.Lines, + Cross: BackgroundVariant.Cross, +}; + +type PlaceholderNode = Node<{ title: string }, "placeholder">; + +function PlaceholderNodeView({ data, selected }: NodeProps) { + const zoom = useStore((s) => s.transform[2]); + const collapsed = zoom < LOD_ZOOM_THRESHOLD; + return ( +
+ {/* Connection endpoints mirror the Qt canvas's flow: children hang off + the bottom of a parent (vertical layout), so target on top, source + on bottom. */} + +
{data.title}
+ {!collapsed &&
placeholder — real nodes land in R3
} + +
+ ); +} + +const NODE_TYPES = { placeholder: PlaceholderNodeView }; + +function toFlowNodes(scene: SceneState): PlaceholderNode[] { + return scene.nodes.map((n) => ({ + id: n.id, + type: "placeholder" as const, + position: { x: n.x, y: n.y }, + data: { title: n.title }, + })); +} + +function toFlowEdges(scene: SceneState): Edge[] { + return scene.edges.map((e) => ({ id: e.id, source: e.source, target: e.target })); +} + +function CanvasInner({ store }: { store: SceneStore }) { + const scene = useSyncExternalStore(store.subscribe, store.getScene); + const grid = useSyncExternalStore(store.subscribe, store.getGrid); + const { setCenter } = useReactFlow(); + + // Local node state exists so dragging is fluid; backend snapshots are the + // truth and reconcile in whenever nothing is being dragged. dragStartRef + // powers the drag-speed scaling contract (see scaleDragPosition). + const [nodes, setNodes] = useState([]); + const dragStartRef = useRef>(new Map()); + const draggingRef = useRef(false); + + useEffect(() => { + if (!draggingRef.current) setNodes(toFlowNodes(scene)); + }, [scene]); + + const edges = useMemo(() => toFlowEdges(scene), [scene]); + + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + const scaled = changes.map((change) => { + if (change.type !== "position" || !change.position) return change; + if (change.dragging) { + draggingRef.current = true; + let start = dragStartRef.current.get(change.id); + if (!start) { + const node = nodes.find((n) => n.id === change.id); + start = node ? { ...node.position } : { ...change.position }; + dragStartRef.current.set(change.id, start); + } + return { + ...change, + position: scaleDragPosition(start, change.position, scene.dragFactor), + }; + } + // Drag end: commit the node's final (already-scaled) position. + draggingRef.current = false; + const settled = nodes.find((n) => n.id === change.id); + if (settled) store.moveNode(change.id, settled.position.x, settled.position.y); + dragStartRef.current.delete(change.id); + return change; + }); + setNodes((current) => applyNodeChanges(scaled, current)); + }, + [nodes, scene.dragFactor, store], + ); + + const onConnect = useCallback( + (connection: Connection) => { + if (connection.source && connection.target) { + store.connectNodes(connection.source, connection.target); + } + }, + [store], + ); + + const onDelete = useCallback( + ({ nodes: deletedNodes, edges: deletedEdges }: { nodes: Node[]; edges: Edge[] }) => { + store.removeNodes(deletedNodes.map((n) => n.id)); + // Skip edges an already-deleted node takes with it server-side. + const dying = new Set(deletedNodes.map((n) => n.id)); + store.removeEdges( + deletedEdges.filter((e) => !dying.has(e.source) && !dying.has(e.target)).map((e) => e.id), + ); + }, + [store], + ); + + const { screenToFlowPosition } = useReactFlow(); + const onDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Double-click on empty canvas creates a node there - the R1 stand-in + // for the plugin picker / context menu creation paths (R2/R8). + const target = event.target as HTMLElement; + if (!target.closest(".react-flow__node")) { + const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + store.addNode(position.x, position.y); + } + }, + [screenToFlowPosition, store], + ); + + const jumpToPin = useCallback( + (x: number, y: number) => setCenter(x, y, { zoom: 1, duration: 300 }), + [setCenter], + ); + + return ( +
+ + + + + +
+ ); +} + +function PinsPanel({ + store, + onJump, +}: { + store: SceneStore; + onJump: (x: number, y: number) => void; +}) { + const scene = useSyncExternalStore(store.subscribe, store.getScene); + const { getViewport } = useReactFlow(); + + const addPinHere = useCallback(() => { + // Pin the current view center - the R1 equivalent of the Qt canvas's + // "Pin this location" context-menu verb. + const viewport = getViewport(); + const centerX = (window.innerWidth / 2 - viewport.x) / viewport.zoom; + const centerY = (window.innerHeight / 2 - viewport.y) / viewport.zoom; + store.addPin(`Pin ${scene.pins.length + 1}`, centerX, centerY); + }, [getViewport, scene.pins.length, store]); + + return ( + + ); +} + +export function SceneCanvas({ store }: { store: SceneStore }) { + return ( + + + + ); +} diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts new file mode 100644 index 0000000..dc5b452 --- /dev/null +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; +import { SceneStore, initialSceneState, scaleDragPosition } from "./sceneStore"; +import type { WsTransport } from "../../lib/ws/transport"; + +type StateListener = (payload: Record) => void; + +function makeFakeTransport() { + const listeners = new Map(); + const intents: Array<{ topic: string; intent: string; args: unknown[] }> = []; + const transport = { + subscribe: vi.fn((topic: string, listener: StateListener) => { + listeners.set(topic, listener); + return () => listeners.delete(topic); + }), + intent: vi.fn((topic: string, intent: string, args: unknown[] = []) => { + intents.push({ topic, intent, args }); + }), + } as unknown as WsTransport; + return { transport, listeners, intents }; +} + +function validScenePayload(overrides: Record = {}) { + return { + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 3, + nodes: [{ id: "n0", x: 1, y: 2, title: "A", kind: "placeholder" }], + edges: [], + pins: [], + snapToGrid: true, + dragFactor: 0.5, + ...overrides, + }; +} + +describe("SceneStore", () => { + it("accepts a VALID scene snapshot and notifies subscribers", () => { + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + const seen = vi.fn(); + store.subscribe(seen); + + listeners.get("scene")!(validScenePayload()); + expect(seen).toHaveBeenCalledTimes(1); + expect(store.getScene().nodes[0].title).toBe("A"); + expect(store.getScene().dragFactor).toBe(0.5); + }); + + it("REJECTS a malformed snapshot and keeps the previous state", () => { + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + listeners.get("scene")!({ revision: "not-a-scene" }); + expect(store.getScene()).toEqual(initialSceneState); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("routes grid snapshots through the grid validator", () => { + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + listeners.get("grid-control")!({ + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 1, + gridSize: 50, + gridOpacityPercent: 80, + gridStyle: "Lines", + gridColor: "#404040", + sizePresets: [10, 20, 50, 100], + stylePresets: ["Dots", "Lines", "Cross"], + colorPresets: [], + }); + expect(store.getGrid().gridSize).toBe(50); + expect(store.getGrid().gridStyle).toBe("Lines"); + }); + + it("sends intents with the backend's registered names and shapes", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.addNode(10, 20, "hello"); + store.moveNode("n1", 3, 4); + store.connectNodes("n1", "n2"); + store.addPin("P", 5, 6, "note"); + store.setSnapToGrid(true); + store.setDragFactor(0.25); + expect(intents).toEqual([ + { topic: "scene", intent: "addNode", args: [10, 20, "hello"] }, + { topic: "scene", intent: "moveNode", args: ["n1", 3, 4] }, + { topic: "scene", intent: "connectNodes", args: ["n1", "n2"] }, + { topic: "scene", intent: "addPin", args: ["P", 5, 6, "note"] }, + { topic: "scene", intent: "setSnapToGrid", args: [true] }, + { topic: "scene", intent: "setDragFactor", args: [0.25] }, + ]); + }); + + it("suppresses empty removal intents", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.removeNodes([]); + store.removeEdges([]); + expect(intents).toEqual([]); + }); + + it("dispose() unsubscribes every topic", () => { + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + expect(listeners.size).toBe(2); + store.dispose(); + expect(listeners.size).toBe(0); + }); +}); + +describe("scaleDragPosition (the drag-speed contract)", () => { + it("factor 1 leaves motion unscaled", () => { + expect(scaleDragPosition({ x: 0, y: 0 }, { x: 100, y: 40 }, 1)).toEqual({ x: 100, y: 40 }); + }); + + it("factor 0.5 halves the delta from the drag start", () => { + expect(scaleDragPosition({ x: 10, y: 10 }, { x: 110, y: 50 }, 0.5)).toEqual({ x: 60, y: 30 }); + }); + + it("scales relative to the start, not the origin", () => { + expect(scaleDragPosition({ x: -20, y: 8 }, { x: -20, y: 8 }, 0.25)).toEqual({ x: -20, y: 8 }); + }); +}); diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts new file mode 100644 index 0000000..18846c7 --- /dev/null +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -0,0 +1,141 @@ +/** + * Scene-topic client store (Qt-removal plan R1). + * + * Binds the WS transport's "scene" topic to a validated, subscribable local + * snapshot, and exposes the intent surface backend/canvas.py registers. + * Deliberately framework-free (plain listeners, no React import) so the + * store logic is unit-testable without rendering; React consumes it through + * useSyncExternalStore in SceneCanvas. + */ + +import { TOPIC_VALIDATORS } from "../../lib/api-contract/topics"; +import type { SceneState } from "../../lib/bridge-core/generated/scene-state"; +import type { GridControlState } from "../../lib/bridge-core/generated/grid-control-state"; +import type { WsTransport } from "../../lib/ws/transport"; + +export const initialSceneState: SceneState = { + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 0, + nodes: [], + edges: [], + pins: [], + snapToGrid: false, + dragFactor: 1, +}; + +export const initialGridState: GridControlState = { + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 0, + gridSize: 10, + gridOpacityPercent: 30, + gridStyle: "Dots", + gridColor: "#555555", + sizePresets: [10, 20, 50, 100], + stylePresets: ["Dots", "Lines", "Cross"], + colorPresets: [], +}; + +type Listener = () => void; + +export class SceneStore { + private scene: SceneState = initialSceneState; + private grid: GridControlState = initialGridState; + private readonly listeners = new Set(); + private readonly unsubscribers: Array<() => void> = []; + + constructor(private readonly transport: WsTransport) {} + + connect(): void { + this.unsubscribers.push( + this.transport.subscribe("scene", (payload) => { + const validated = TOPIC_VALIDATORS["scene"](payload); + if (validated.ok) { + this.scene = validated.value; + this.emit(); + } else { + console.error("[scene] rejected snapshot:", validated.errors); + } + }), + this.transport.subscribe("grid-control", (payload) => { + const validated = TOPIC_VALIDATORS["grid-control"](payload); + if (validated.ok) { + this.grid = validated.value; + this.emit(); + } else { + console.error("[grid-control] rejected snapshot:", validated.errors); + } + }), + ); + } + + dispose(): void { + for (const unsubscribe of this.unsubscribers) unsubscribe(); + this.unsubscribers.length = 0; + } + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getScene = (): SceneState => this.scene; + getGrid = (): GridControlState => this.grid; + + private emit(): void { + for (const listener of [...this.listeners]) listener(); + } + + // -- intents (backend/canvas.py's registered surface, 1:1) --------------- + + addNode(x: number, y: number, title = ""): void { + this.transport.intent("scene", "addNode", [x, y, title]); + } + + moveNode(id: string, x: number, y: number): void { + this.transport.intent("scene", "moveNode", [id, x, y]); + } + + removeNodes(ids: string[]): void { + if (ids.length > 0) this.transport.intent("scene", "removeNodes", [ids]); + } + + connectNodes(source: string, target: string): void { + this.transport.intent("scene", "connectNodes", [source, target]); + } + + removeEdges(ids: string[]): void { + if (ids.length > 0) this.transport.intent("scene", "removeEdges", [ids]); + } + + addPin(title: string, x: number, y: number, note = ""): void { + this.transport.intent("scene", "addPin", [title, x, y, note]); + } + + removePin(id: string): void { + this.transport.intent("scene", "removePin", [id]); + } + + setSnapToGrid(enabled: boolean): void { + this.transport.intent("scene", "setSnapToGrid", [enabled]); + } + + setDragFactor(factor: number): void { + this.transport.intent("scene", "setDragFactor", [factor]); + } +} + +/** start + (proposed - start) * factor: the drag-speed contract carried over + * from the Qt canvas (ChatView's drag factor scaled item motion the same + * way). Exported standalone for direct unit testing. */ +export function scaleDragPosition( + start: { x: number; y: number }, + proposed: { x: number; y: number }, + factor: number, +): { x: number; y: number } { + return { + x: start.x + (proposed.x - start.x) * factor, + y: start.y + (proposed.y - start.y) * factor, + }; +} diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 1601d5e..df9e521 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -156,3 +156,174 @@ body, color: var(--gl-surface-text-muted); text-align: center; } + +/* -- R1 canvas (React Flow) ---------------------------------------------- */ + +.scene-canvas { + position: absolute; + inset: 0; +} + +.scene-canvas .react-flow { + background-color: var(--gl-surface-window); +} + +.scene-node { + min-width: 160px; + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 8px; + overflow: hidden; + font-size: 12px; +} + +.scene-node.selected { + border-color: var(--gl-surface-border-strong, var(--gl-surface-text-muted)); +} + +.scene-node-title { + padding: 6px 10px; + font-weight: 600; + color: var(--gl-surface-text); + background-color: var(--gl-surface-inset, var(--gl-surface-node-body)); + border-bottom: 1px solid var(--gl-surface-border); +} + +.scene-node.collapsed .scene-node-title { + border-bottom: none; +} + +.scene-node-body { + padding: 8px 10px; + color: var(--gl-surface-text-muted); + font-size: 11px; +} + +.scene-canvas .react-flow__edge-path { + stroke: var(--gl-surface-border-strong, var(--gl-surface-text-muted)); + stroke-width: 1.5; +} + +.scene-canvas .react-flow__edge.selected .react-flow__edge-path { + stroke: var(--gl-surface-text); +} + +.scene-minimap { + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 8px; +} + +.scene-minimap .react-flow__minimap-mask { + fill: var(--gl-surface-window); + fill-opacity: 0.55; +} + +.scene-pins { + position: absolute; + left: 16px; + top: 16px; + width: 190px; + padding: 10px 12px; + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 10px; + z-index: 5; +} + +.scene-pins-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.scene-pins-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + color: var(--gl-surface-text-muted); +} + +.scene-pins-add { + font-size: 10px; + font-weight: 600; + font-family: inherit; + padding: 3px 8px; + color: var(--gl-surface-text); + background-color: var(--gl-neutral-button-background); + border: 1px solid var(--gl-neutral-button-border); + border-radius: 6px; + cursor: pointer; +} + +.scene-pins-add:hover { + background-color: var(--gl-neutral-button-hover); +} + +.scene-pins-empty { + margin: 4px 0 0; + font-size: 11px; + color: var(--gl-surface-text-muted); +} + +.scene-pins-list { + margin: 0; + padding: 0; + list-style: none; +} + +.scene-pins-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; +} + +.scene-pins-jump { + flex: 1; + text-align: left; + font-size: 11px; + font-family: inherit; + padding: 4px 6px; + color: var(--gl-surface-text); + background: transparent; + border: none; + border-radius: 5px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scene-pins-jump:hover { + background-color: var(--gl-neutral-button-hover); +} + +.scene-pins-remove { + font-size: 12px; + font-family: inherit; + line-height: 1; + padding: 2px 6px; + color: var(--gl-surface-text-muted); + background: transparent; + border: none; + border-radius: 5px; + cursor: pointer; +} + +.scene-pins-remove:hover { + color: var(--gl-surface-text); + background-color: var(--gl-neutral-button-hover); +} + +.scene-node-handle { + width: 8px; + height: 8px; + background-color: var(--gl-neutral-button-background); + border: 1px solid var(--gl-surface-text-muted); +} + +.scene-node-handle:hover { + background-color: var(--gl-neutral-button-hover); +} diff --git a/web_ui/src/lib/api-contract/topics.ts b/web_ui/src/lib/api-contract/topics.ts index ba66649..cacfbaf 100644 --- a/web_ui/src/lib/api-contract/topics.ts +++ b/web_ui/src/lib/api-contract/topics.ts @@ -18,6 +18,7 @@ import { type MinimapState, validateMinimapState } from "../bridge-core/generate import { type NotificationState, validateNotificationState } from "../bridge-core/generated/notification-state"; import { type PinOverlayState, validatePinOverlayState } from "../bridge-core/generated/pin-overlay-state"; import { type PluginPickerState, validatePluginPickerState } from "../bridge-core/generated/plugin-picker-state"; +import { type SceneState, validateSceneState } from "../bridge-core/generated/scene-state"; import { type SearchOverlayState, validateSearchOverlayState } from "../bridge-core/generated/search-overlay-state"; import { type SettingsState, validateSettingsState } from "../bridge-core/generated/settings-state"; import { type TokenCounterState, validateTokenCounterState } from "../bridge-core/generated/token-counter-state"; @@ -39,6 +40,7 @@ export const TOPIC_VALIDATORS = { "notification": validateNotificationState, "pin-overlay": validatePinOverlayState, "plugin-picker": validatePluginPickerState, + "scene": validateSceneState, "search-overlay": validateSearchOverlayState, "settings": validateSettingsState, "token-counter": validateTokenCounterState, @@ -63,6 +65,7 @@ export interface TopicStates { "notification": NotificationState; "pin-overlay": PinOverlayState; "plugin-picker": PluginPickerState; + "scene": SceneState; "search-overlay": SearchOverlayState; "settings": SettingsState; "token-counter": TokenCounterState; diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json new file mode 100644 index 0000000..49f2f3c --- /dev/null +++ b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "dragFactor": { + "type": "number" + }, + "edges": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "id", + "source", + "target" + ], + "type": "object" + }, + "type": "array" + }, + "minCompatibleSchemaVersion": { + "type": "integer" + }, + "nodes": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "title": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "id", + "x", + "y", + "title", + "kind" + ], + "type": "object" + }, + "type": "array" + }, + "pins": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "note": { + "type": "string" + }, + "title": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "note", + "x", + "y" + ], + "type": "object" + }, + "type": "array" + }, + "revision": { + "type": "integer" + }, + "schemaVersion": { + "type": "integer" + }, + "snapToGrid": { + "type": "boolean" + } + }, + "required": [ + "schemaVersion", + "revision", + "nodes", + "edges", + "pins", + "snapToGrid", + "dragFactor" + ], + "title": "SceneState", + "type": "object" +} diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.ts b/web_ui/src/lib/bridge-core/generated/scene-state.ts new file mode 100644 index 0000000..d44cc6e --- /dev/null +++ b/web_ui/src/lib/bridge-core/generated/scene-state.ts @@ -0,0 +1,182 @@ +/* GENERATED - do not hand-edit. Source of truth: graphlink_app/graphlink_scene_payload.py::SceneStatePayload. + * Regenerate with graphlink_island_codegen.py; a pytest fails if this file + * drifts from what regenerating it now would produce. */ + +export interface SceneNodeRow { + id: string; + x: number; + y: number; + title: string; + kind: string; +} + +export interface SceneEdgeRow { + id: string; + source: string; + target: string; +} + +export interface ScenePinRow { + id: string; + title: string; + note: string; + x: number; + y: number; +} + +export interface SceneState { + schemaVersion: number; + revision: number; + nodes: SceneNodeRow[]; + edges: SceneEdgeRow[]; + pins: ScenePinRow[]; + snapToGrid: boolean; + dragFactor: number; + minCompatibleSchemaVersion?: number | null; +} + +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; errors: string[] }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Unknown keys are tolerated on purpose. The JSON Schema marks the contract +// additionalProperties:false because Python and the schema must not drift, but +// an incoming payload carrying a field this build has never heard of is the +// normal, expected shape of a NEWER compatible sender - rejecting it here would +// defeat the additive-forward-compatibility the version negotiation exists to +// provide. Missing or wrongly-typed KNOWN fields are still hard errors. + +function checkSceneNodeRow(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["id"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } + } + { + const fieldValue = value["x"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.x: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.x` + ": expected number"); } + } + { + const fieldValue = value["y"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.y: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.y` + ": expected number"); } + } + { + const fieldValue = value["title"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } + } + { + const fieldValue = value["kind"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.kind: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.kind` + ": expected string"); } + } +} + +function checkSceneEdgeRow(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["id"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } + } + { + const fieldValue = value["source"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.source: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.source` + ": expected string"); } + } + { + const fieldValue = value["target"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.target: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.target` + ": expected string"); } + } +} + +function checkScenePinRow(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["id"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } + } + { + const fieldValue = value["title"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } + } + { + const fieldValue = value["note"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.note: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.note` + ": expected string"); } + } + { + const fieldValue = value["x"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.x: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.x` + ": expected number"); } + } + { + const fieldValue = value["y"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.y: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.y` + ": expected number"); } + } +} + +function checkSceneState(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["schemaVersion"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } + } + { + const fieldValue = value["revision"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } + } + { + const fieldValue = value["nodes"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.nodes: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.nodes` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { checkSceneNodeRow(item, `${path}.nodes` + `[${i}]`, errors); }); } + } + { + const fieldValue = value["edges"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.edges: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.edges` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { checkSceneEdgeRow(item, `${path}.edges` + `[${i}]`, errors); }); } + } + { + const fieldValue = value["pins"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.pins: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.pins` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { checkScenePinRow(item, `${path}.pins` + `[${i}]`, errors); }); } + } + { + const fieldValue = value["snapToGrid"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.snapToGrid: missing required field`); + else { if (typeof fieldValue !== "boolean") errors.push(`${path}.snapToGrid` + ": expected boolean"); } + } + { + const fieldValue = value["dragFactor"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.dragFactor: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.dragFactor` + ": expected number"); } + } + { + const fieldValue = value["minCompatibleSchemaVersion"]; + if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } + } +} + +export function validateSceneState(value: unknown): ValidationResult { + const errors: string[] = []; + checkSceneState(value, "$", errors); + return errors.length === 0 + ? { ok: true, value: value as SceneState } + : { ok: false, errors }; +}