From 0dbcd3086eed1157e6beb91b17d1895079ed3e41 Mon Sep 17 00:00:00 2001 From: Armor00 <2654988228@qq.com> Date: Thu, 9 Jul 2026 21:53:00 +0800 Subject: [PATCH] feat(topology): persist complete diagram semantics --- tests/test_topology_api.py | 40 +++++++ .../migrations/005_layout_presentation.sql | 18 +++ topology_api/queries/snapshot_queries.py | 18 ++- topology_api/seed/seed_layout.py | 107 +++++++++++------- 4 files changed, 139 insertions(+), 44 deletions(-) create mode 100644 topology_api/migrations/005_layout_presentation.sql diff --git a/tests/test_topology_api.py b/tests/test_topology_api.py index 0d5690c..04079ca 100644 --- a/tests/test_topology_api.py +++ b/tests/test_topology_api.py @@ -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__": unittest.main() diff --git a/topology_api/migrations/005_layout_presentation.sql b/topology_api/migrations/005_layout_presentation.sql new file mode 100644 index 0000000..d6fdc91 --- /dev/null +++ b/topology_api/migrations/005_layout_presentation.sql @@ -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); diff --git a/topology_api/queries/snapshot_queries.py b/topology_api/queries/snapshot_queries.py index ae59bf9..6adb16b 100644 --- a/topology_api/queries/snapshot_queries.py +++ b/topology_api/queries/snapshot_queries.py @@ -247,10 +247,22 @@ def fetch_layout(): dn.component_id, c.component_key, 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 JOIN diagram_nodes dn ON dn.layout_id = dl.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') AND dl.is_default = true ORDER BY dn.z_index, dn.node_key @@ -275,5 +287,9 @@ def fetch_layout(): "styleKey": row["style_key"], "labelOverride": row["label_override"], "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 diff --git a/topology_api/seed/seed_layout.py b/topology_api/seed/seed_layout.py index 95096f2..cdf6196 100644 --- a/topology_api/seed/seed_layout.py +++ b/topology_api/seed/seed_layout.py @@ -28,75 +28,96 @@ def seed(): {"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) + # Layout and concise engineering labels. All visual semantics live here, + # not in the Wiki page. 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), + ("core_cabin", 630, 470, 240, 200, "core-card", None, 10, + "四向安装节点", "北 / 东 / 南 / 西", {}, ["core_cabin"]), + ("habitat_2", 630, 220, 240, 140, "node-card", "居住舱#2", 5, + "核心舱北部安装口", "4铺位 + 2睡袋", {}, ["habitat_2"]), + ("habitat_1", 630, 760, 240, 120, "node-card", "居住舱#1", 5, + "核心舱南部安装口", None, {}, ["habitat_1"]), + ("greenhouse", 330, 490, 220, 160, "node-card", "温室模块", 5, + "核心舱西部安装口", "远端连接吴刚二号", {}, ["greenhouse"]), + ("lab_module", 950, 490, 220, 160, "node-card", "实验舱", 5, + "核心舱东部安装口", "三温区实验舱", {}, ["lab_module"]), + ("wugang_2", 40, 495, 210, 150, "vehicle-card", None, 3, + "Block 2 载人着陆器", "温室远端接口", {}, ["wugang_2"]), # 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_module_hub", 650, 1260, 200, 180, "power-node-card", + "电力模块", 8, "四向安装节点", "核电与资源中枢", {}, + ["power_module_hub"]), + ("power_storage_rack", 600, 990, 300, 160, "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 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), + ("power_west_group", 280, 1250, 260, 200, "power-card", + "西部模块", 5, "ISRU · 采矿", "矿石 / 燃料存储", + {"radiator": True}, + ["isru_module", "mining_module", "ore_fuel_storage"]), + ("power_east_group", 960, 1250, 260, 200, "power-card", + "东部模块", 5, "核燃料存储 · 离心机", None, + {"radiator": True}, + ["fuel_storage_mod", "centrifuge_mod"]), # 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), + ("binglun_1", 30, 1280, 210, 140, "vehicle-card", None, 2, + "电力模块西侧", "燃料运输", {}, ["binglun_1"]), + ("binglun_2", 645, 1740, 210, 120, "vehicle-card", None, 2, + "电力模块南侧", None, {}, ["binglun_2"]), + ("binglun_3", 1260, 1280, 210, 140, "vehicle-card", None, 2, + "电力模块东侧", "Block 2", {}, ["binglun_3"]), ] # 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: + 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(""" INSERT INTO diagram_nodes (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, %(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, "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)}") - # 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()