feat(topology): M2 - API-driven demo page with layout tables and dynamic rendering
- Add diagram_layouts and diagram_nodes tables - Seed layout coordinates extracted from SVG - Extend snapshot API to return layout data - Rewrite demo page to fetch from API dynamically - Support ?mission= and ?at= URL parameters - Modal details populated from database component data - Show/hide nodes based on component existence at snapshot time Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
|||||||
# topology_api/app.py
|
# topology_api/app.py
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
from topology_api.config import API_HOST, API_PORT, API_DEBUG
|
from topology_api.config import API_HOST, API_PORT, API_DEBUG
|
||||||
from topology_api.routes.snapshot import snapshot_bp
|
from topology_api.routes.snapshot import snapshot_bp
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
app.register_blueprint(snapshot_bp)
|
app.register_blueprint(snapshot_bp)
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- topology_api/migrations/004_layout.sql
|
||||||
|
-- Diagram layout tables for topology visualization
|
||||||
|
|
||||||
|
CREATE TABLE topology.diagram_layouts (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
base_id uuid NOT NULL REFERENCES topology.bases(id),
|
||||||
|
layout_key text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
canvas_width numeric NOT NULL DEFAULT 1500,
|
||||||
|
canvas_height numeric NOT NULL DEFAULT 1900,
|
||||||
|
is_default boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_layout_base_key UNIQUE (base_id, layout_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE topology.diagram_nodes (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
layout_id uuid NOT NULL REFERENCES topology.diagram_layouts(id),
|
||||||
|
component_id uuid REFERENCES topology.components(id),
|
||||||
|
node_key text NOT NULL,
|
||||||
|
x numeric NOT NULL,
|
||||||
|
y numeric NOT NULL,
|
||||||
|
width numeric NOT NULL DEFAULT 200,
|
||||||
|
height numeric NOT NULL DEFAULT 120,
|
||||||
|
rotation_deg numeric NOT NULL DEFAULT 0,
|
||||||
|
style_key text NOT NULL DEFAULT 'node-card',
|
||||||
|
label_override text,
|
||||||
|
z_index integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_diagram_node UNIQUE (layout_id, node_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_diagram_nodes_layout
|
||||||
|
ON topology.diagram_nodes(layout_id);
|
||||||
@@ -201,3 +201,48 @@ def fetch_snapshot(snapshot_at):
|
|||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_layout():
|
||||||
|
"""Fetch the default diagram layout with node positions."""
|
||||||
|
with read_cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
dl.layout_key,
|
||||||
|
dl.name AS layout_name,
|
||||||
|
dl.canvas_width,
|
||||||
|
dl.canvas_height,
|
||||||
|
dn.node_key,
|
||||||
|
dn.component_id,
|
||||||
|
c.component_key,
|
||||||
|
dn.x, dn.y, dn.width, dn.height,
|
||||||
|
dn.style_key, dn.label_override, dn.z_index
|
||||||
|
FROM diagram_layouts dl
|
||||||
|
JOIN diagram_nodes dn ON dn.layout_id = dl.id
|
||||||
|
LEFT JOIN components c ON c.id = dn.component_id
|
||||||
|
WHERE dl.base_id = (SELECT id FROM bases WHERE code = 'guanghan')
|
||||||
|
AND dl.is_default = true
|
||||||
|
ORDER BY dn.z_index, dn.node_key
|
||||||
|
""")
|
||||||
|
nodes = []
|
||||||
|
layout_info = None
|
||||||
|
for row in cur.fetchall():
|
||||||
|
if layout_info is None:
|
||||||
|
layout_info = {
|
||||||
|
"key": row["layout_key"],
|
||||||
|
"name": row["layout_name"],
|
||||||
|
"canvasWidth": float(row["canvas_width"]),
|
||||||
|
"canvasHeight": float(row["canvas_height"]),
|
||||||
|
}
|
||||||
|
nodes.append({
|
||||||
|
"nodeKey": row["node_key"],
|
||||||
|
"componentKey": row["component_key"],
|
||||||
|
"x": float(row["x"]),
|
||||||
|
"y": float(row["y"]),
|
||||||
|
"width": float(row["width"]),
|
||||||
|
"height": float(row["height"]),
|
||||||
|
"styleKey": row["style_key"],
|
||||||
|
"labelOverride": row["label_override"],
|
||||||
|
"zIndex": row["z_index"],
|
||||||
|
})
|
||||||
|
return {"layout": layout_info, "nodes": nodes} if layout_info else None
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# topology_api/routes/snapshot.py
|
# topology_api/routes/snapshot.py
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from topology_api.queries.snapshot_queries import resolve_snapshot_time, fetch_snapshot
|
from topology_api.queries.snapshot_queries import (
|
||||||
|
resolve_snapshot_time, fetch_snapshot, fetch_layout,
|
||||||
|
)
|
||||||
|
|
||||||
snapshot_bp = Blueprint("snapshot", __name__)
|
snapshot_bp = Blueprint("snapshot", __name__)
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ def get_snapshot(base_code):
|
|||||||
})
|
})
|
||||||
|
|
||||||
data = fetch_snapshot(snapshot_at)
|
data = fetch_snapshot(snapshot_at)
|
||||||
|
layout = fetch_layout()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"base": data["base"],
|
"base": data["base"],
|
||||||
@@ -58,4 +61,5 @@ def get_snapshot(base_code):
|
|||||||
},
|
},
|
||||||
"components": data["components"],
|
"components": data["components"],
|
||||||
"connections": data["connections"],
|
"connections": data["connections"],
|
||||||
|
"layout": layout,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# topology_api/seed/seed_layout.py
|
||||||
|
"""Seed diagram layout and node positions for the engineering overview SVG."""
|
||||||
|
import json
|
||||||
|
from topology_api.db import write_cursor
|
||||||
|
|
||||||
|
|
||||||
|
def seed():
|
||||||
|
with write_cursor() as cur:
|
||||||
|
# Get base
|
||||||
|
cur.execute("SELECT id FROM bases WHERE code = 'guanghan'")
|
||||||
|
base_id = cur.fetchone()["id"]
|
||||||
|
|
||||||
|
# Upsert layout
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO diagram_layouts (id, base_id, layout_key, name,
|
||||||
|
canvas_width, canvas_height, is_default)
|
||||||
|
VALUES (gen_random_uuid(), %(base)s, 'engineering_overview_v1',
|
||||||
|
'工程总图 v1', 1500, 1900, true)
|
||||||
|
ON CONFLICT (base_id, layout_key) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name, is_default = true
|
||||||
|
RETURNING id
|
||||||
|
""", {"base": base_id})
|
||||||
|
layout_id = cur.fetchone()["id"]
|
||||||
|
print(f"Layout: {layout_id}")
|
||||||
|
|
||||||
|
# Component key → component_id mapping
|
||||||
|
cur.execute("SELECT component_key, id FROM components WHERE base_id = %(base)s",
|
||||||
|
{"base": base_id})
|
||||||
|
comp_map = {r["component_key"]: r["id"] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# SVG node positions extracted from guanghan_topology_demo.html
|
||||||
|
# node_key → (x, y, width, height, style_key, label_override, z_index)
|
||||||
|
nodes = [
|
||||||
|
# Core cross
|
||||||
|
("core_cabin", comp_map.get("core_cabin"), 630, 470, 240, 200,
|
||||||
|
"core-card", None, 10),
|
||||||
|
("habitat_2", comp_map.get("habitat_2"), 630, 220, 240, 140,
|
||||||
|
"node-card", None, 5),
|
||||||
|
("habitat_1", comp_map.get("habitat_1"), 630, 760, 240, 120,
|
||||||
|
"node-card", None, 5),
|
||||||
|
("greenhouse", comp_map.get("greenhouse"), 330, 490, 220, 160,
|
||||||
|
"node-card", None, 5),
|
||||||
|
("lab_module", comp_map.get("lab_module"), 950, 490, 220, 160,
|
||||||
|
"node-card", None, 5),
|
||||||
|
("wugang_2", comp_map.get("wugang_2"), 40, 495, 210, 150,
|
||||||
|
"vehicle-card", None, 3),
|
||||||
|
|
||||||
|
# Power cross
|
||||||
|
("power_module_hub", comp_map.get("power_module_hub"), 650, 1260, 200, 180,
|
||||||
|
"power-node-card", None, 8),
|
||||||
|
("power_storage_rack", comp_map.get("power_storage_rack"), 600, 990, 300, 160,
|
||||||
|
"power-card", "北部模块", 5),
|
||||||
|
("nuclear_reactor", comp_map.get("nuclear_reactor"), 620, 1540, 260, 150,
|
||||||
|
"power-card", "南部模块", 5),
|
||||||
|
|
||||||
|
# Power west/east groups (multiple DB components per SVG node)
|
||||||
|
# power-west groups: isru_module, mining_module, ore_fuel_storage
|
||||||
|
("power_west_group", None, 280, 1250, 260, 200,
|
||||||
|
"power-card", "西部模块", 5),
|
||||||
|
# power-east groups: fuel_storage_mod, centrifuge_mod
|
||||||
|
("power_east_group", None, 960, 1250, 260, 200,
|
||||||
|
"power-card", "东部模块", 5),
|
||||||
|
|
||||||
|
# Bingluns
|
||||||
|
("binglun_1", comp_map.get("binglun_1"), 30, 1280, 210, 140,
|
||||||
|
"vehicle-card", None, 2),
|
||||||
|
("binglun_2", comp_map.get("binglun_2"), 645, 1740, 210, 120,
|
||||||
|
"vehicle-card", None, 2),
|
||||||
|
("binglun_3", comp_map.get("binglun_3"), 1260, 1280, 210, 140,
|
||||||
|
"vehicle-card", None, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Delete existing nodes and re-insert
|
||||||
|
cur.execute("DELETE FROM diagram_nodes WHERE layout_id = %(lid)s",
|
||||||
|
{"lid": layout_id})
|
||||||
|
|
||||||
|
for node_key, comp_id, x, y, w, h, style, label, z in nodes:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO diagram_nodes
|
||||||
|
(id, layout_id, component_id, node_key, x, y,
|
||||||
|
width, height, style_key, label_override, z_index)
|
||||||
|
VALUES (gen_random_uuid(), %(lid)s, %(cid)s, %(nkey)s,
|
||||||
|
%(x)s, %(y)s, %(w)s, %(h)s,
|
||||||
|
%(style)s, %(label)s, %(z)s)
|
||||||
|
""", {
|
||||||
|
"lid": layout_id, "cid": comp_id, "nkey": node_key,
|
||||||
|
"x": x, "y": y, "w": w, "h": h,
|
||||||
|
"style": style, "label": label, "z": z,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"Nodes inserted: {len(nodes)}")
|
||||||
|
|
||||||
|
# Node group mappings (for modal details aggregation)
|
||||||
|
groups = {
|
||||||
|
"power_west_group": ["isru_module", "mining_module", "ore_fuel_storage"],
|
||||||
|
"power_east_group": ["fuel_storage_mod", "centrifuge_mod"],
|
||||||
|
}
|
||||||
|
print(f"Node groups: {json.dumps(groups, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed()
|
||||||
Reference in New Issue
Block a user