纯标准库实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」性能口径: - perf_budget.py:PerformanceBudget(首屏 2000ms + 6 类资源预算)、 ResourceMeasurement(block_render 区分关键路径)、BudgetVerifier (阻塞串行+非阻塞取最大口径,逐类型比对预算,PASS/FAIL/EMPTY + 类型化优化建议,reason 可解释)。 - lazy_load.py:LazyLoadStrategy(immediate/visible/idle/never)、 LazyLoadPolicy(视口行数+预加载+分页阈值+虚拟滚动)、LazyLoadPlanner (widget 坐标+滚动位置 → 决策+分页计划+虚拟滚动窗口)。 - tests:32 用例覆盖预算构造/查询、关键路径口径、达标/超预算场景、 always_load 强制加载、视口边界、分页/虚拟滚动、输入校验。 - _sanity_check.py:冒烟验证达标/超预算/懒加载分流。
329 lines
14 KiB
Python
329 lines
14 KiB
Python
# -*- 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),
|
||
)
|