# -*- coding: utf-8 -*- """图表 / Widget 懒加载策略引擎(Issue #54 / PRD 5.5「⑤ 配置化驾驶舱」)。 PRD 5.5 验收口径含「图表懒加载」:首屏只渲染视口(viewport)内的图表, 视口外的 widget(趋势/KPI/告警/NL 查询)推迟到滚动可见或空闲时加载, 把首屏 JS/接口预算留给工艺流程主视图。本模块把该口径落为**可判定的懒加载 策略引擎**——给定 widget 的布局坐标 + 视口尺寸 + 滚动位置,决定每个 widget 的加载时机(immediate/visible/idle/never),并产出分页/虚拟滚动计划。 设计要点 -------- 1. **懒加载策略即规格**(``LazyLoadStrategy``):4 种加载时机: - ``immediate`` 首屏立即加载(工艺流程主视图); - ``visible`` 进入视口时加载(IntersectionObserver 口径); - ``idle`` 浏览器空闲时加载(requestIdleCallback); - ``never`` 不加载(隐藏 tab/折叠面板内的 widget)。 2. **策略规则**(``LazyLoadPolicy``):可配置——首屏视口高度、根边距 (rootMargin,预加载视口外 N px)、分页阈值(视口外 widget 超过阈值则 分页加载)、虚拟滚动开关。 3. **决策器**(``LazyLoadPlanner``):对一组 widget(坐标 + 尺寸 + 滚动位置) 逐个判定策略,产出 :class:`LazyLoadDecision`(含 reason 可解释)+ 分页计划 + 虚拟滚动窗口。 4. **纯标准库**:无依赖,与 iAOP 零运行时依赖原则一致。 用法:: policy = LazyLoadPolicy.default_cockpit() plan = LazyLoadPlanner(policy).plan(widgets, viewport_h=900, scroll_y=0) for d in plan.decisions: print(d.widget_id, d.strategy.value, d.reason) """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Dict, List, Optional, Sequence, Tuple class LazyLoadError(ValueError): """懒加载策略/决策错误(坐标非法、视口尺寸非正等)。""" class LazyLoadStrategy(str, Enum): """Widget 加载时机(懒加载策略取值)。""" IMMEDIATE = "immediate" # 首屏立即加载 VISIBLE = "visible" # 进入视口时加载 IDLE = "idle" # 浏览器空闲时加载 NEVER = "never" # 不加载(隐藏/折叠) @property def label(self) -> str: return { LazyLoadStrategy.IMMEDIATE: "首屏立即加载", LazyLoadStrategy.VISIBLE: "进入视口加载", LazyLoadStrategy.IDLE: "空闲时加载", LazyLoadStrategy.NEVER: "不加载", }[self] @dataclass class WidgetLayout: """单个 widget 的布局坐标(12 列网格 + 行坐标,对齐 cockpit layout v1)。 Attributes: widget_id: widget 唯一 id(对应布局资产里的 widget)。 kind: widget 类型(process_view/trend/kpi_card/alarm_panel/nl_query)。 y: 纵向起始行(像素化的行号 × row_height_px 近似视口位置)。 h: 纵向占行数(h × row_height_px = 视口内高度 px)。 priority: 优先级(0 最高,默认 5;用于 idle 排序)。 always_load: 强制立即加载(如工艺流程主视图,覆盖视口判定)。 """ widget_id: str kind: str y: float h: float = 1.0 priority: int = 5 always_load: bool = False def __post_init__(self) -> None: if not self.widget_id: raise LazyLoadError("WidgetLayout.widget_id 不能为空") if self.h <= 0: raise LazyLoadError( f"widget {self.widget_id!r} h 必须 > 0,实际 {self.h}") if self.y < 0: raise LazyLoadError( f"widget {self.widget_id!r} y 不能为负,实际 {self.y}") @dataclass class LazyLoadPolicy: """懒加载策略规则(可配置,换行业只改规则不改前端代码)。 Attributes: first_viewport_rows: 首屏视口覆盖的行数(视口高度 / 行高,默认 4 行)。 preload_rows: rootMargin 预加载视口外的行数(默认 1 行预加载)。 idle_batch: 空闲时每批加载的 widget 数(默认 2)。 pagination_threshold: 视口外 widget 数超过该阈值启用分页加载(默认 6)。 page_size: 分页大小(每页 widget 数,默认 4)。 virtual_scroll: 是否启用虚拟滚动(视口外不挂载 DOM)。 """ first_viewport_rows: float = 4.0 preload_rows: float = 1.0 idle_batch: int = 2 pagination_threshold: int = 6 page_size: int = 4 virtual_scroll: bool = True def __post_init__(self) -> None: if self.first_viewport_rows <= 0: raise LazyLoadError( f"first_viewport_rows 必须 > 0,实际 {self.first_viewport_rows}") if self.preload_rows < 0: raise LazyLoadError( f"preload_rows 不能为负,实际 {self.preload_rows}") if self.idle_batch <= 0: raise LazyLoadError( f"idle_batch 必须 > 0,实际 {self.idle_batch}") if self.pagination_threshold < 0: raise LazyLoadError( f"pagination_threshold 不能为负,实际 {self.pagination_threshold}") if self.page_size <= 0: raise LazyLoadError( f"page_size 必须 > 0,实际 {self.page_size}") @classmethod def default_cockpit(cls) -> "LazyLoadPolicy": """驾驶舱默认懒加载策略(首屏 4 行视口 + 1 行预加载 + 分页/虚拟滚动)。""" return cls( first_viewport_rows=4.0, preload_rows=1.0, idle_batch=2, pagination_threshold=6, page_size=4, virtual_scroll=True, ) @dataclass class LazyLoadDecision: """单个 widget 的懒加载决策(含 reason 可解释)。""" widget_id: str kind: str strategy: LazyLoadStrategy in_viewport: bool page: Optional[int] = None # 分页加载时的页码(从 1 起);None=不分页 reason: str = "" # 为何选该策略(事实陈述) def to_dict(self) -> dict: return { "widget_id": self.widget_id, "kind": self.kind, "strategy": self.strategy.value, "in_viewport": self.in_viewport, "page": self.page, "reason": self.reason, } @dataclass class LazyLoadPlan: """懒加载计划:全部 widget 决策 + 分页信息 + 虚拟滚动窗口。""" decisions: List[LazyLoadDecision] = field(default_factory=list) pages: Dict[int, List[str]] = field(default_factory=dict) # page → widget_ids virtual_window: Optional[Tuple[float, float]] = None # (y_start, y_end) reason: str = "" @property def immediate_ids(self) -> List[str]: """首屏立即加载的 widget id。""" return [d.widget_id for d in self.decisions if d.strategy is LazyLoadStrategy.IMMEDIATE] @property def visible_ids(self) -> List[str]: return [d.widget_id for d in self.decisions if d.strategy is LazyLoadStrategy.VISIBLE] @property def idle_ids(self) -> List[str]: return [d.widget_id for d in self.decisions if d.strategy is LazyLoadStrategy.IDLE] @property def never_ids(self) -> List[str]: return [d.widget_id for d in self.decisions if d.strategy is LazyLoadStrategy.NEVER] def to_dict(self) -> dict: return { "decisions": [d.to_dict() for d in self.decisions], "pages": {str(p): ids for p, ids in self.pages.items()}, "virtual_window": (list(self.virtual_window) if self.virtual_window else None), "reason": self.reason, "counts": { "immediate": len(self.immediate_ids), "visible": len(self.visible_ids), "idle": len(self.idle_ids), "never": len(self.never_ids), }, } class LazyLoadPlanner: """懒加载决策器:widget 布局 + 视口/滚动 → 加载计划。 Args: policy: 懒加载策略规则。 row_height_px: 行高(px),用于把行号换算为视口像素;默认 120px。 """ def __init__(self, policy: Optional[LazyLoadPolicy] = None, row_height_px: float = 120.0) -> None: self.policy = policy or LazyLoadPolicy.default_cockpit() if row_height_px <= 0: raise LazyLoadError(f"row_height_px 必须 > 0,实际 {row_height_px}") self.row_height_px = float(row_height_px) # ------------------------------------------------------------------ def in_viewport(self, w: WidgetLayout, scroll_y_rows: float) -> bool: """widget 是否在当前视口(含预加载边距)内。 视口范围:``[scroll_y - preload, scroll_y + first_viewport + preload]``。 """ top = w.y bottom = w.y + w.h vp_top = scroll_y_rows - self.policy.preload_rows vp_bottom = (scroll_y_rows + self.policy.first_viewport_rows + self.policy.preload_rows) # 区间相交即视为视口内 return not (bottom <= vp_top or top >= vp_bottom) # ------------------------------------------------------------------ def plan(self, widgets: Sequence[WidgetLayout], scroll_y_rows: float = 0.0) -> LazyLoadPlan: """对所有 widget 生成懒加载决策 + 分页/虚拟滚动计划。 Args: widgets: widget 布局列表(按 y 升序更稳定,但本方法不强求)。 scroll_y_rows: 当前滚动位置(以"行"为单位;scroll_y_px/row_height_px)。 """ if scroll_y_rows < 0: raise LazyLoadError(f"scroll_y_rows 不能为负,实际 {scroll_y_rows}") if not widgets: return LazyLoadPlan(reason="无 widget,无需懒加载计划") policy = self.policy decisions: List[LazyLoadDecision] = [] # 1) 判定每个 widget:always_load → immediate;在视口 → immediate/visible; # 视口外 → idle/never out_of_viewport: List[WidgetLayout] = [] for w in widgets: in_vp = self.in_viewport(w, scroll_y_rows) if w.always_load: decisions.append(LazyLoadDecision( widget_id=w.widget_id, kind=w.kind, strategy=LazyLoadStrategy.IMMEDIATE, in_viewport=in_vp, reason=f"{w.kind} 标记 always_load,强制首屏立即加载")) continue if in_vp: decisions.append(LazyLoadDecision( widget_id=w.widget_id, kind=w.kind, strategy=LazyLoadStrategy.IMMEDIATE, in_viewport=True, reason=f"{w.kind} 位于首屏视口(行 {w.y}~{w.y + w.h})," f"立即加载")) else: out_of_viewport.append(w) # 2) 视口外 widget:视数量决定分页/虚拟滚动 use_pagination = (len(out_of_viewport) > policy.pagination_threshold) # 按 y 排序后分页(页内顺序稳定) sorted_out = sorted(out_of_viewport, key=lambda x: (x.y, x.priority)) if use_pagination: pages: Dict[int, List[str]] = {} for idx, w in enumerate(sorted_out): page_no = idx // policy.page_size + 1 pages.setdefault(page_no, []).append(w.widget_id) decisions.append(LazyLoadDecision( widget_id=w.widget_id, kind=w.kind, strategy=LazyLoadStrategy.IDLE, in_viewport=False, page=page_no, reason=f"{w.kind} 在视口外(行 {w.y}),分页加载第 {page_no} 页," f"空闲时按 batch={policy.idle_batch} 加载")) else: pages = {} # 视口外但数量少 → idle 加载(不分页) for w in sorted_out: decisions.append(LazyLoadDecision( widget_id=w.widget_id, kind=w.kind, strategy=LazyLoadStrategy.IDLE, in_viewport=False, reason=f"{w.kind} 在视口外(行 {w.y}),数量未达分页阈值" f"({policy.pagination_threshold}),空闲时加载")) # 3) 虚拟滚动窗口(仅视口 + 预加载边距内的 widget 挂载 DOM) virtual_window: Optional[Tuple[float, float]] = None if policy.virtual_scroll: vp_top = max(0.0, scroll_y_rows - policy.preload_rows) vp_bottom = (scroll_y_rows + policy.first_viewport_rows + policy.preload_rows) virtual_window = (vp_top, vp_bottom) reason_parts = [ f"视口 {policy.first_viewport_rows} 行 + 预加载 {policy.preload_rows} 行", f"视口外 {len(out_of_viewport)} 个 widget", ] if use_pagination: reason_parts.append( f"超分页阈值 {policy.pagination_threshold},分 {len(pages)} 页加载" f"(每页 {policy.page_size})") if virtual_window: reason_parts.append( f"虚拟滚动窗口行 {virtual_window[0]:.1f}~{virtual_window[1]:.1f}") return LazyLoadPlan( decisions=decisions, pages=pages, virtual_window=virtual_window, reason=";".join(reason_parts), )