feat(topology): persist complete diagram semantics

This commit is contained in:
2026-07-09 21:53:00 +08:00
parent e7becba0b0
commit 0dbcd3086e
4 changed files with 139 additions and 44 deletions
+40
View File
@@ -124,6 +124,46 @@ class TopologyApiQueryTests(unittest.TestCase):
}, },
) )
def test_layout_contains_members_labels_and_radiators(self) -> None:
response = self.client.get(
"/api/v1/bases/guanghan/snapshot?mission=GHC-05"
)
nodes = response.get_json()["layout"]["nodes"]
by_key = {node["nodeKey"]: node for node in nodes}
self.assertEqual(
by_key["power_west_group"]["memberKeys"],
["isru_module", "mining_module", "ore_fuel_storage"],
)
self.assertEqual(
by_key["power_east_group"]["memberKeys"],
["fuel_storage_mod", "centrifuge_mod"],
)
self.assertEqual(
by_key["core_cabin"]["memberKeys"],
["core_cabin"],
)
for node in nodes:
self.assertIn("displayMeta", node)
self.assertIn("displayHint", node)
self.assertIn("decorations", node)
radiator_nodes = {
node["nodeKey"]
for node in nodes
if node["decorations"].get("radiator")
}
self.assertEqual(
radiator_nodes,
{
"power_storage_rack",
"power_west_group",
"power_east_group",
"nuclear_reactor",
},
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,18 @@
-- Persist all presentation semantics required by the database-driven SVG.
ALTER TABLE topology.diagram_nodes
ADD COLUMN IF NOT EXISTS display_meta text,
ADD COLUMN IF NOT EXISTS display_hint text,
ADD COLUMN IF NOT EXISTS decorations jsonb NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE IF NOT EXISTS topology.diagram_node_members (
diagram_node_id uuid NOT NULL
REFERENCES topology.diagram_nodes(id) ON DELETE CASCADE,
component_id uuid NOT NULL
REFERENCES topology.components(id),
display_order integer NOT NULL DEFAULT 0,
PRIMARY KEY (diagram_node_id, component_id)
);
CREATE INDEX IF NOT EXISTS idx_diagram_node_members_component
ON topology.diagram_node_members(component_id);
+17 -1
View File
@@ -247,10 +247,22 @@ def fetch_layout():
dn.component_id, dn.component_id,
c.component_key, c.component_key,
dn.x, dn.y, dn.width, dn.height, dn.x, dn.y, dn.width, dn.height,
dn.style_key, dn.label_override, dn.z_index dn.style_key, dn.label_override, dn.z_index,
dn.display_meta, dn.display_hint, dn.decorations,
members.member_keys
FROM diagram_layouts dl FROM diagram_layouts dl
JOIN diagram_nodes dn ON dn.layout_id = dl.id JOIN diagram_nodes dn ON dn.layout_id = dl.id
LEFT JOIN components c ON c.id = dn.component_id LEFT JOIN components c ON c.id = dn.component_id
LEFT JOIN LATERAL (
SELECT ARRAY_AGG(
member_component.component_key
ORDER BY member.display_order
) AS member_keys
FROM diagram_node_members member
JOIN components member_component
ON member_component.id = member.component_id
WHERE member.diagram_node_id = dn.id
) members ON true
WHERE dl.base_id = (SELECT id FROM bases WHERE code = 'guanghan') WHERE dl.base_id = (SELECT id FROM bases WHERE code = 'guanghan')
AND dl.is_default = true AND dl.is_default = true
ORDER BY dn.z_index, dn.node_key ORDER BY dn.z_index, dn.node_key
@@ -275,5 +287,9 @@ def fetch_layout():
"styleKey": row["style_key"], "styleKey": row["style_key"],
"labelOverride": row["label_override"], "labelOverride": row["label_override"],
"zIndex": row["z_index"], "zIndex": row["z_index"],
"displayMeta": row["display_meta"],
"displayHint": row["display_hint"],
"decorations": row["decorations"] or {},
"memberKeys": row["member_keys"] or [],
}) })
return {"layout": layout_info, "nodes": nodes} if layout_info else None return {"layout": layout_info, "nodes": nodes} if layout_info else None
+64 -43
View File
@@ -28,75 +28,96 @@ def seed():
{"base": base_id}) {"base": base_id})
comp_map = {r["component_key"]: r["id"] for r in cur.fetchall()} comp_map = {r["component_key"]: r["id"] for r in cur.fetchall()}
# SVG node positions extracted from guanghan_topology_demo.html # Layout and concise engineering labels. All visual semantics live here,
# node_key → (x, y, width, height, style_key, label_override, z_index) # not in the Wiki page.
nodes = [ nodes = [
# Core cross # Core cross
("core_cabin", comp_map.get("core_cabin"), 630, 470, 240, 200, ("core_cabin", 630, 470, 240, 200, "core-card", None, 10,
"core-card", None, 10), "四向安装节点", "北 / 东 / 南 / 西", {}, ["core_cabin"]),
("habitat_2", comp_map.get("habitat_2"), 630, 220, 240, 140, ("habitat_2", 630, 220, 240, 140, "node-card", "居住舱#2", 5,
"node-card", None, 5), "核心舱北部安装口", "4铺位 + 2睡袋", {}, ["habitat_2"]),
("habitat_1", comp_map.get("habitat_1"), 630, 760, 240, 120, ("habitat_1", 630, 760, 240, 120, "node-card", "居住舱#1", 5,
"node-card", None, 5), "核心舱南部安装口", None, {}, ["habitat_1"]),
("greenhouse", comp_map.get("greenhouse"), 330, 490, 220, 160, ("greenhouse", 330, 490, 220, 160, "node-card", "温室模块", 5,
"node-card", None, 5), "核心舱西部安装口", "远端连接吴刚二号", {}, ["greenhouse"]),
("lab_module", comp_map.get("lab_module"), 950, 490, 220, 160, ("lab_module", 950, 490, 220, 160, "node-card", "实验舱", 5,
"node-card", None, 5), "核心舱东部安装口", "三温区实验舱", {}, ["lab_module"]),
("wugang_2", comp_map.get("wugang_2"), 40, 495, 210, 150, ("wugang_2", 40, 495, 210, 150, "vehicle-card", None, 3,
"vehicle-card", None, 3), "Block 2 载人着陆器", "温室远端接口", {}, ["wugang_2"]),
# Power cross # Power cross
("power_module_hub", comp_map.get("power_module_hub"), 650, 1260, 200, 180, ("power_module_hub", 650, 1260, 200, 180, "power-node-card",
"power-node-card", None, 8), "电力模块", 8, "四向安装节点", "核电与资源中枢", {},
("power_storage_rack", comp_map.get("power_storage_rack"), 600, 990, 300, 160, ["power_module_hub"]),
"power-card", "北部模块", 5), ("power_storage_rack", 600, 990, 300, 160, "power-card",
("nuclear_reactor", comp_map.get("nuclear_reactor"), 620, 1540, 260, 150, "北部模块", 5, "电力模块存储支架", "加压走道端",
"power-card", "南部模块", 5), {"radiator": True}, ["power_storage_rack"]),
("nuclear_reactor", 620, 1540, 260, 150, "power-card",
"南部模块", 5, "核反应堆", "30%功率",
{"radiator": True}, ["nuclear_reactor"]),
# Power west/east groups (multiple DB components per SVG node) # Power west/east groups (multiple DB components per SVG node)
# power-west groups: isru_module, mining_module, ore_fuel_storage ("power_west_group", 280, 1250, 260, 200, "power-card",
("power_west_group", None, 280, 1250, 260, 200, "西部模块", 5, "ISRU · 采矿", "矿石 / 燃料存储",
"power-card", "西部模块", 5), {"radiator": True},
# power-east groups: fuel_storage_mod, centrifuge_mod ["isru_module", "mining_module", "ore_fuel_storage"]),
("power_east_group", None, 960, 1250, 260, 200, ("power_east_group", 960, 1250, 260, 200, "power-card",
"power-card", "东部模块", 5), "东部模块", 5, "核燃料存储 · 离心机", None,
{"radiator": True},
["fuel_storage_mod", "centrifuge_mod"]),
# Bingluns # Bingluns
("binglun_1", comp_map.get("binglun_1"), 30, 1280, 210, 140, ("binglun_1", 30, 1280, 210, 140, "vehicle-card", None, 2,
"vehicle-card", None, 2), "电力模块西侧", "燃料运输", {}, ["binglun_1"]),
("binglun_2", comp_map.get("binglun_2"), 645, 1740, 210, 120, ("binglun_2", 645, 1740, 210, 120, "vehicle-card", None, 2,
"vehicle-card", None, 2), "电力模块南侧", None, {}, ["binglun_2"]),
("binglun_3", comp_map.get("binglun_3"), 1260, 1280, 210, 140, ("binglun_3", 1260, 1280, 210, 140, "vehicle-card", None, 2,
"vehicle-card", None, 2), "电力模块东侧", "Block 2", {}, ["binglun_3"]),
] ]
# Delete existing nodes and re-insert # Delete existing nodes and re-insert
cur.execute("DELETE FROM diagram_nodes WHERE layout_id = %(lid)s", cur.execute("DELETE FROM diagram_nodes WHERE layout_id = %(lid)s",
{"lid": layout_id}) {"lid": layout_id})
for node_key, comp_id, x, y, w, h, style, label, z in nodes: for (
node_key, x, y, w, h, style, label, z, display_meta,
display_hint, decorations, member_keys,
) in nodes:
primary_component_id = (
comp_map[member_keys[0]] if len(member_keys) == 1 else None
)
cur.execute(""" cur.execute("""
INSERT INTO diagram_nodes INSERT INTO diagram_nodes
(id, layout_id, component_id, node_key, x, y, (id, layout_id, component_id, node_key, x, y,
width, height, style_key, label_override, z_index) width, height, style_key, label_override, z_index,
display_meta, display_hint, decorations)
VALUES (gen_random_uuid(), %(lid)s, %(cid)s, %(nkey)s, VALUES (gen_random_uuid(), %(lid)s, %(cid)s, %(nkey)s,
%(x)s, %(y)s, %(w)s, %(h)s, %(x)s, %(y)s, %(w)s, %(h)s,
%(style)s, %(label)s, %(z)s) %(style)s, %(label)s, %(z)s,
%(meta)s, %(hint)s, %(decorations)s)
RETURNING id
""", { """, {
"lid": layout_id, "cid": comp_id, "nkey": node_key, "lid": layout_id, "cid": primary_component_id,
"nkey": node_key,
"x": x, "y": y, "w": w, "h": h, "x": x, "y": y, "w": w, "h": h,
"style": style, "label": label, "z": z, "style": style, "label": label, "z": z,
"meta": display_meta, "hint": display_hint,
"decorations": json.dumps(decorations),
})
node_id = cur.fetchone()["id"]
for display_order, member_key in enumerate(member_keys):
cur.execute("""
INSERT INTO diagram_node_members
(diagram_node_id, component_id, display_order)
VALUES (%(node)s, %(component)s, %(display_order)s)
""", {
"node": node_id,
"component": comp_map[member_key],
"display_order": display_order,
}) })
print(f"Nodes inserted: {len(nodes)}") 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__": if __name__ == "__main__":
seed() seed()