# -*- coding: utf-8 -*- """图表组件配置化渲染引擎(issue #51 / PRD 5.5「⑤ 配置化驾驶舱」)。 本模块在 ``layout.py``(#50 布局 JSON Schema)之上实现**渲染层适配器**: 把一份 ``CockpitLayout`` 内存模型,编译成一份与前端框架无关的**渲染计划 (``RenderPlan``)**——每个 widget 被翻译成一个 ``WidgetRendererSpec``, 描述它该用哪个前端组件、摆在栅格的哪个位置、绑定什么数据、套用哪套主题 token、是否启用性能优化策略。 设计目标(PRD 5.5 验收口径「切换模板零改码」): 前端只需一个固定的「组件注册表」+ 一个通用 ````, 按 ``RenderPlan`` 逐个挂载组件;切换行业模板 = 换一份 ``RenderPlan``, **前端代码零改动**。 本模块不依赖任何第三方库(与 #50 一致,避免引入运行时依赖),只消费 ``cockpit.layout`` 的内存模型,输出可 ``json.dumps`` 的纯数据结构。 """ from __future__ import annotations import json from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple from .layout import ( # 复用 #50 已定义的布局模型与常量 DEFAULT_GRID_COLUMNS, LAYOUT_SCHEMA_ID, PERF_WIDGET_THRESHOLD, VALID_THEMES, VALID_WIDGET_TYPES, CockpitLayout, Grid, Widget, validate_layout, ) # --------------------------------------------------------------------------- # 主题 token(PRD 5.5「配置点:主题」dark / light 两套调色) # --------------------------------------------------------------------------- # 工业驾驶舱约定配色:dark 偏深蓝底 + 青绿强调(车间大屏常用); # light 偏白底 + 蓝色强调(白天值班室)。前端按这些 CSS 变量渲染, # 切换主题 = 切一套变量,组件代码不动。 THEME_TOKENS: Dict[str, Dict[str, str]] = { "dark": { "--cockpit-bg": "#0b1220", "--cockpit-surface": "#13203a", "--cockpit-fg": "#e6edf6", "--cockpit-fg-muted": "#8aa0bd", "--cockpit-accent": "#18d3c8", # 青绿(KPI / 趋势主线) "--cockpit-warn": "#f5a623", # 告警强调 "--cockpit-grid-line": "rgba(255,255,255,0.06)", }, "light": { "--cockpit-bg": "#f5f7fb", "--cockpit-surface": "#ffffff", "--cockpit-fg": "#1f2d3d", "--cockpit-fg-muted": "#6b7c93", "--cockpit-accent": "#2f7af2", # 蓝 "--cockpit-warn": "#e08600", "--cockpit-grid-line": "rgba(0,0,0,0.06)", }, } # --------------------------------------------------------------------------- # widget type → 前端组件名映射(PRD 5.5 能力清单) # --------------------------------------------------------------------------- # 前端「组件注册表」的键;```` 据此选择具体组件实现。 # 命名遵循 Vue3 大驼峰组件约定,便于 ``app.component()`` 注册。 WIDGET_COMPONENT: Dict[str, str] = { "process_view": "ProcessView", # 四状态工艺流程视图(SVG 资源) "trend": "TrendChart", # 实时趋势曲线 "kpi_card": "KpiCard", # KPI 卡片 "alarm_panel": "AlarmPanel", # 告警面板 "nl_query": "NlQuery", # 自然语言查询入口 } # 各组件类型向渲染描述里注入的「绑定字段」:来自布局 widget 的哪个属性。 # 例如 trend 组件的 series 绑定 = widget.bind(点位/指标 ID)。 _WIDGET_BINDING_SOURCE: Dict[str, Tuple[str, ...]] = { "process_view": ("src",), # 流程图资源 "trend": ("bind",), # 趋势点位/指标 "kpi_card": ("metric", "label"), # 指标键 + 展示标签 "alarm_panel": (), # 无特有绑定,订阅告警流即可 "nl_query": (), # 无特有绑定,固定入口 } # --------------------------------------------------------------------------- # 异常 # --------------------------------------------------------------------------- class RenderError(ValueError): """渲染计划生成失败。``render_layout`` 在布局非法或组件缺失时抛出。""" # --------------------------------------------------------------------------- # 渲染描述符(dataclass) # --------------------------------------------------------------------------- @dataclass class GridPlacement: """单个 widget 在 CSS Grid 中的定位(``grid-column/grid-row`` 语法)。 PRD 5.5 栅格基线默认 12 列;这里把布局资产里的整数坐标 (x,y,w,h) 换算成 CSS Grid 的 ``column-start / span`` / ``row-start / span``, 以及占比百分比(便于非 Grid 容器/截图场景使用)。 约定(与 #50 schema 一致):x/y/w/h 为栅格单位,x≥0、y≥0、w>0、h>0。 """ column_start: int # CSS Grid 列起点的 1-based 编号(= x + 1) column_span: int # 跨列数(= w) row_start: int # CSS Grid 行起点的 1-based 编号(= y + 1) row_span: int # 跨行数(= h) width_pct: float # 占栅格总宽的百分比(= w / columns * 100,保留 4 位) style: str # 直接可用的 ``grid-column/grid-row`` CSS 文本 def to_dict(self) -> Dict[str, Any]: return { "columnStart": self.column_start, "columnSpan": self.column_span, "rowStart": self.row_start, "rowSpan": self.row_span, "widthPct": self.width_pct, "style": self.style, } @dataclass class WidgetRendererSpec: """单个驾驶舱组件的渲染描述符(```` 的单一挂载项)。 前端渲染约定:: """ id: str # 渲染层唯一标识(type + 序号),便于 diff / 虚拟滚动 key component: str # 前端组件名(见 ``WIDGET_COMPONENT``) type: str # 原始 widget type(调试/审计用) grid: GridPlacement description: Optional[str] = None # 前端组件 props(绑定字段 + 展示参数),按组件类型组装 props: Dict[str, Any] = field(default_factory=dict) # 性能策略:是否对该组件启用虚拟滚动/采样降频/WebWorker(仅 perf 触发时 True) perf: Dict[str, bool] = field(default_factory=lambda: { "virtualScroll": False, "downsample": False, "worker": False, }) def to_dict(self) -> Dict[str, Any]: return { "id": self.id, "component": self.component, "type": self.type, "grid": self.grid.to_dict(), "description": self.description, "props": dict(self.props), "perf": dict(self.perf), } @dataclass class RenderPlan: """整份驾驶舱布局的渲染计划(```` 的总输入)。 前端拿到本对象即可完成整屏渲染:``themeTokens`` 注入到根容器 CSS 变量, ``specs`` 逐项挂载组件,``perfFlags`` 控制全局性能策略开关。 """ schema: str # 继承自布局资产的 $schema(审计/版本对齐) title: str theme: str theme_tokens: Dict[str, str] # 该主题下的 CSS 变量(来自 ``THEME_TOKENS``) grid_columns: int # 栅格基线(用于前端百分比校验) specs: List[WidgetRendererSpec] widget_count: int # 全局性能标志:组件数 ≥ PERF_WIDGET_THRESHOLD 时为 True(PRD 5.5 复杂仪表盘性能) perf_flags: Dict[str, bool] = field(default_factory=lambda: { "virtualScroll": False, "downsample": False, "worker": False, }) perf_hint: Optional[str] = None # 来自 #50 布局校验的人类可读提示(透传) def to_dict(self) -> Dict[str, Any]: return { "$schema": self.schema, "title": self.title, "theme": self.theme, "themeTokens": dict(self.theme_tokens), "grid": {"columns": self.grid_columns}, "widgets": [s.to_dict() for s in self.specs], "widgetCount": self.widget_count, "perfFlags": dict(self.perf_flags), "perfHint": self.perf_hint, } # --------------------------------------------------------------------------- # 栅格换算 # --------------------------------------------------------------------------- def compute_grid_placement(widget: Widget, grid_columns: int) -> GridPlacement: """把布局资产里的栅格整数坐标换算成 CSS Grid 定位 + 占比百分比。 - CSS Grid 的列/行起点是 1-based,布局资产里的 x/y 是 0-based, 因此 ``column_start = x + 1``、``row_start = y + 1``。 - 跨度直接等于 w/h(栅格单位)。 - 宽度百分比 = w / grid_columns * 100(便于非 Grid 容器降级)。 """ if grid_columns <= 0: raise RenderError(f"栅格列数必须 > 0,实际 {grid_columns}") column_start = widget.x + 1 row_start = widget.y + 1 width_pct = round(widget.w / grid_columns * 100, 4) style = ( f"grid-column: {column_start} / span {widget.w}; " f"grid-row: {row_start} / span {widget.h};" ) return GridPlacement( column_start=column_start, column_span=widget.w, row_start=row_start, row_span=widget.h, width_pct=width_pct, style=style, ) # --------------------------------------------------------------------------- # 组件 props 组装 # --------------------------------------------------------------------------- def _build_props(widget: Widget) -> Dict[str, Any]: """按 widget type 组装前端组件 props(绑定字段 + 展示参数)。 绑定字段来源见 ``_WIDGET_BINDING_SOURCE``;缺字段视为该组件不依赖它 (布局层 #50 已对必填字段做过校验,这里做防御性容错)。 """ props: Dict[str, Any] = {} if widget.type == "process_view": # 流程图视图:资源名 + 四状态语义(前端按 SVG 内联渲染 + 状态着色) props["src"] = widget.src or "" props["states"] = ["running", "warning", "alarm", "offline"] # PRD 四状态 elif widget.type == "trend": # 实时趋势:绑定点位/指标 ID,前端订阅时序总线 props["series"] = widget.bind or "" props["window"] = "PT30M" # 默认 30 分钟滚动窗口(可由模板覆盖) elif widget.type == "kpi_card": # KPI 卡片:指标键 + 标签 props["metric"] = widget.metric or "" props["label"] = widget.label or widget.metric or "" elif widget.type == "alarm_panel": # 告警面板:固定订阅告警流,无特有绑定 props["subscribe"] = "alarm_stream" elif widget.type == "nl_query": # NL 查询入口:固定能力,无特有绑定 props["placeholder"] = "输入自然语言查询(配方 / 质量 / 能耗)" return props # --------------------------------------------------------------------------- # 渲染入口 # --------------------------------------------------------------------------- def render_widget( widget: Widget, index: int, grid_columns: int, perf_enabled: bool = False, ) -> WidgetRendererSpec: """渲染单个 widget 为 ``WidgetRendererSpec``。 - ``index`` 用于生成稳定 id(type + 序号),作为虚拟滚动 key。 - ``perf_enabled`` 为 True(全局性能策略开启)时,trend/alarm_panel 这类 高频刷新组件启用采样降频 + WebWorker(PRD 5.5)。 """ if widget.type not in WIDGET_COMPONENT: # 布局层 #50 已挡住非法 type,这里再防御一次,避免渲染出未注册组件 raise RenderError( f"widget[{index}] type='{widget.type}' 无对应前端组件," f"已注册 {list(WIDGET_COMPONENT)}" ) spec = WidgetRendererSpec( id=f"{widget.type}-{index}", component=WIDGET_COMPONENT[widget.type], type=widget.type, grid=compute_grid_placement(widget, grid_columns), description=widget.description, props=_build_props(widget), ) if perf_enabled: # 高频刷新组件才需要逐组件性能策略:趋势曲线 / 告警面板 if widget.type in ("trend", "alarm_panel"): spec.perf["downsample"] = True spec.perf["worker"] = True # 大流程图(process_view)启用虚拟滚动分块 if widget.type == "process_view": spec.perf["virtualScroll"] = True return spec def render_layout(layout: CockpitLayout) -> RenderPlan: """把一份合法 ``CockpitLayout`` 编译为 ``RenderPlan``。 先复用 #50 的 ``validate_layout`` 再校验一次(防御构造后被篡改), 再逐 widget 渲染,最后汇总主题 token 与全局性能标志。 校验失败抛 ``RenderError``(包装 #50 的错误清单),便于配置台定位。 """ # 1) 复用 #50 校验器,确保布局恒为合法资产 result = validate_layout(layout.to_dict()) if not result.ok: raise RenderError(result.errors) # 2) 主题 token if layout.theme not in THEME_TOKENS: raise RenderError( f"theme='{layout.theme}' 无调色方案,已支持 {list(THEME_TOKENS)}" ) theme_tokens = dict(THEME_TOKENS[layout.theme]) # 3) 全局性能标志(PRD 5.5:组件数 ≥ 阈值 → 虚拟滚动 + 采样降频 + WebWorker) perf_on = result.widget_count >= PERF_WIDGET_THRESHOLD perf_flags = { "virtualScroll": perf_on, "downsample": perf_on, "worker": perf_on, } # 4) 逐 widget 渲染 specs: List[WidgetRendererSpec] = [ render_widget(w, idx, layout.grid.columns, perf_enabled=perf_on) for idx, w in enumerate(layout.widgets) ] return RenderPlan( schema=layout.schema, title=layout.title, theme=layout.theme, theme_tokens=theme_tokens, grid_columns=layout.grid.columns, specs=specs, widget_count=len(specs), perf_flags=perf_flags, perf_hint=result.perf_hint, ) # --------------------------------------------------------------------------- # 序列化 # --------------------------------------------------------------------------- def plan_to_dict(plan: RenderPlan) -> Dict[str, Any]: """渲染计划 → 可发布的 dict(前端直接消费 / 落盘缓存)。""" return plan.to_dict() def plan_to_json(plan: RenderPlan, indent: Optional[int] = 2) -> str: """渲染计划 → JSON 字符串(确保 ASCII 安全,中文转义不影响前端解析)。""" return json.dumps(plan.to_dict(), ensure_ascii=False, indent=indent)