diff --git a/topology_api/migrations/006_presentation_styles.sql b/topology_api/migrations/006_presentation_styles.sql new file mode 100644 index 0000000..9c2e3d3 --- /dev/null +++ b/topology_api/migrations/006_presentation_styles.sql @@ -0,0 +1,95 @@ +-- topology_api/migrations/006_presentation_styles.sql +-- Database-driven presentation semantics for topology diagrams. + +CREATE TABLE IF NOT EXISTS topology.diagram_themes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + base_id uuid NOT NULL REFERENCES topology.bases(id) ON DELETE CASCADE, + theme_key text NOT NULL, + name text NOT NULL, + description text, + tokens jsonb NOT NULL DEFAULT '{}'::jsonb, + is_default boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_themes_base_key UNIQUE (base_id, theme_key) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_diagram_themes_default_per_base + ON topology.diagram_themes(base_id) + WHERE is_default; + +CREATE TABLE IF NOT EXISTS topology.diagram_node_styles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, + style_key text NOT NULL, + display_name text NOT NULL, + fill_color text NOT NULL, + stroke_color text NOT NULL, + stroke_width numeric NOT NULL, + corner_radius numeric NOT NULL DEFAULT 16, + title_color text, + meta_color text, + hint_color text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_node_styles_theme_key UNIQUE (theme_id, style_key), + CONSTRAINT ck_diagram_node_stroke_width CHECK (stroke_width >= 0), + CONSTRAINT ck_diagram_node_corner_radius CHECK (corner_radius >= 0) +); + +CREATE TABLE IF NOT EXISTS topology.diagram_edge_styles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, + connection_type text NOT NULL, + display_name text NOT NULL, + stroke_color text NOT NULL, + stroke_width numeric NOT NULL, + dash_array text, + line_cap text NOT NULL DEFAULT 'square', + legend_group text, + legend_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_edge_styles_theme_type UNIQUE (theme_id, connection_type), + CONSTRAINT ck_diagram_edge_stroke_width CHECK (stroke_width >= 0), + CONSTRAINT ck_diagram_edge_line_cap CHECK (line_cap IN ('butt', 'round', 'square')), + CONSTRAINT ck_diagram_edge_dash_array CHECK ( + dash_array IS NULL OR dash_array ~ '^[0-9]+( [0-9]+)*$' + ) +); + +CREATE TABLE IF NOT EXISTS topology.diagram_decoration_styles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, + decoration_key text NOT NULL, + display_name text NOT NULL, + fill_color text NOT NULL, + stroke_color text NOT NULL, + stroke_width numeric NOT NULL, + label text, + label_color text, + render_params jsonb NOT NULL DEFAULT '{}'::jsonb, + legend_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_decoration_styles_theme_key UNIQUE (theme_id, decoration_key), + CONSTRAINT ck_diagram_decoration_stroke_width CHECK (stroke_width >= 0) +); + +CREATE TABLE IF NOT EXISTS topology.diagram_legend_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, + legend_key text NOT NULL, + label text NOT NULL, + item_type text NOT NULL, + style_ref text NOT NULL, + display_order integer NOT NULL DEFAULT 0, + is_visible boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_legend_items_theme_key UNIQUE (theme_id, legend_key), + CONSTRAINT ck_diagram_legend_item_type CHECK (item_type IN ('edge', 'decoration', 'node')) +); + +ALTER TABLE topology.diagram_layouts + ADD COLUMN IF NOT EXISTS theme_id uuid REFERENCES topology.diagram_themes(id); diff --git a/topology_api/seed/seed_layout.py b/topology_api/seed/seed_layout.py index cdf6196..9caee25 100644 --- a/topology_api/seed/seed_layout.py +++ b/topology_api/seed/seed_layout.py @@ -3,6 +3,60 @@ import json from topology_api.db import write_cursor +THEME_KEY = "guanghan_dark_engineering_v1" + +THEME_TOKENS = { + "textColor": "#eaf2ff", + "mutedTextColor": "#91a3bd", + "metaColor": "#a6b5c9", + "hintColor": "#71849e", +} + +NODE_STYLES = [ + ("node-card", "普通舱段", "#10263a", "#52baff", 3, 16), + ("core-card", "核心舱", "#3b250d", "#ffad42", 5, 16), + ("power-card", "电力模块分支", "#2a2810", "#d5b642", 3, 16), + ("power-node-card", "电力模块中心", "#393412", "#d5b642", 5, 16), + ("vehicle-card", "车辆", "#291c34", "#c28cdf", 3, 16), +] + +EDGE_STYLES = [ + ("structural_mount", "结构直连", "#8fa1b8", 6, None, "square", "structural", 10), + ("pressurized_passage", "加压走道", "#51e89c", 9, None, "square", "pressurized", 20), + ("fuel", "燃料 / 供电", "#b178e6", 4, "13 10", "square", "utility", 30), + ("power", "燃料 / 供电", "#b178e6", 4, "13 10", "square", "utility", 30), + ("cooling", "冷却", "#78d6e6", 4, "10 8", "square", "cooling", 40), + ("docking", "对接口", "#c28cdf", 4, "10 8", "square", "docking", 50), +] + +DECORATION_STYLES = [ + ( + "radiator", + "大型折叠散热面板", + "#6b5814", + "#f0d869", + 2, + "散热器", + "#f4df7b", + { + "widthRatio": 0.55, + "maxWidth": 130, + "height": 16, + "offsetXRatio": 0.55, + "offsetY": -18, + "ribCount": 3, + }, + 40, + ), +] + +LEGEND_ITEMS = [ + ("structural", "结构直连", "edge", "structural_mount", 10), + ("pressurized", "加压走道", "edge", "pressurized_passage", 20), + ("utility", "燃料 / 供电", "edge", "fuel", 30), + ("radiator", "大型折叠散热面板", "decoration", "radiator", 40), +] + def seed(): with write_cursor() as cur: @@ -10,19 +64,144 @@ def seed(): cur.execute("SELECT id FROM bases WHERE code = 'guanghan'") base_id = cur.fetchone()["id"] + cur.execute(""" + INSERT INTO diagram_themes + (id, base_id, theme_key, name, description, tokens, is_default) + VALUES ( + gen_random_uuid(), + %(base)s, + %(theme_key)s, + '广寒深色工程图', + '广寒基地工程总图默认表现层主题', + %(tokens)s, + true + ) + ON CONFLICT (base_id, theme_key) DO UPDATE + SET name = EXCLUDED.name, + description = EXCLUDED.description, + tokens = EXCLUDED.tokens, + is_default = true, + updated_at = now() + RETURNING id + """, { + "base": base_id, + "theme_key": THEME_KEY, + "tokens": json.dumps(THEME_TOKENS), + }) + theme_id = cur.fetchone()["id"] + print(f"Theme: {theme_id}") + # Upsert layout cur.execute(""" INSERT INTO diagram_layouts (id, base_id, layout_key, name, - canvas_width, canvas_height, is_default) + canvas_width, canvas_height, is_default, theme_id) VALUES (gen_random_uuid(), %(base)s, 'engineering_overview_v1', - '工程总图 v1', 1500, 1900, true) + '工程总图 v1', 1500, 1900, true, %(theme)s) ON CONFLICT (base_id, layout_key) DO UPDATE - SET name = EXCLUDED.name, is_default = true + SET name = EXCLUDED.name, + is_default = true, + theme_id = EXCLUDED.theme_id RETURNING id - """, {"base": base_id}) + """, {"base": base_id, "theme": theme_id}) layout_id = cur.fetchone()["id"] print(f"Layout: {layout_id}") + cur.execute("DELETE FROM diagram_legend_items WHERE theme_id = %(theme)s", {"theme": theme_id}) + cur.execute("DELETE FROM diagram_decoration_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) + cur.execute("DELETE FROM diagram_edge_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) + cur.execute("DELETE FROM diagram_node_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) + + for style_key, display_name, fill, stroke, stroke_width, corner_radius in NODE_STYLES: + cur.execute(""" + INSERT INTO diagram_node_styles + (theme_id, style_key, display_name, fill_color, + stroke_color, stroke_width, corner_radius, + title_color, meta_color, hint_color) + VALUES + (%(theme)s, %(style_key)s, %(display_name)s, %(fill)s, + %(stroke)s, %(stroke_width)s, %(corner_radius)s, + %(title_color)s, %(meta_color)s, %(hint_color)s) + """, { + "theme": theme_id, + "style_key": style_key, + "display_name": display_name, + "fill": fill, + "stroke": stroke, + "stroke_width": stroke_width, + "corner_radius": corner_radius, + "title_color": THEME_TOKENS["textColor"], + "meta_color": THEME_TOKENS["metaColor"], + "hint_color": THEME_TOKENS["hintColor"], + }) + + for ( + connection_type, display_name, stroke, stroke_width, + dash_array, line_cap, legend_group, legend_order, + ) in EDGE_STYLES: + cur.execute(""" + INSERT INTO diagram_edge_styles + (theme_id, connection_type, display_name, stroke_color, + stroke_width, dash_array, line_cap, legend_group, + legend_order) + VALUES + (%(theme)s, %(connection_type)s, %(display_name)s, + %(stroke)s, %(stroke_width)s, %(dash_array)s, + %(line_cap)s, %(legend_group)s, %(legend_order)s) + """, { + "theme": theme_id, + "connection_type": connection_type, + "display_name": display_name, + "stroke": stroke, + "stroke_width": stroke_width, + "dash_array": dash_array, + "line_cap": line_cap, + "legend_group": legend_group, + "legend_order": legend_order, + }) + + for ( + decoration_key, display_name, fill, stroke, stroke_width, label, + label_color, render_params, legend_order, + ) in DECORATION_STYLES: + cur.execute(""" + INSERT INTO diagram_decoration_styles + (theme_id, decoration_key, display_name, fill_color, + stroke_color, stroke_width, label, label_color, + render_params, legend_order) + VALUES + (%(theme)s, %(decoration_key)s, %(display_name)s, + %(fill)s, %(stroke)s, %(stroke_width)s, %(label)s, + %(label_color)s, %(render_params)s, %(legend_order)s) + """, { + "theme": theme_id, + "decoration_key": decoration_key, + "display_name": display_name, + "fill": fill, + "stroke": stroke, + "stroke_width": stroke_width, + "label": label, + "label_color": label_color, + "render_params": json.dumps(render_params), + "legend_order": legend_order, + }) + + for legend_key, label, item_type, style_ref, display_order in LEGEND_ITEMS: + cur.execute(""" + INSERT INTO diagram_legend_items + (theme_id, legend_key, label, item_type, + style_ref, display_order, is_visible) + VALUES + (%(theme)s, %(legend_key)s, %(label)s, %(item_type)s, + %(style_ref)s, %(display_order)s, true) + """, { + "theme": theme_id, + "legend_key": legend_key, + "label": label, + "item_type": item_type, + "style_ref": style_ref, + "display_order": display_order, + }) + # Component key → component_id mapping cur.execute("SELECT component_key, id FROM components WHERE base_id = %(base)s", {"base": base_id})