| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- /**
- * 折线图的**尺寸自适应重算**(票 64:宽屏填充高度并重算纵轴)。
- *
- * 分工:几何形状归 domain,可用尺寸归渲染层。
- * · `trendBlock()` 只按数据算归一化点列(它不知道容器多大,也不该知道),
- * 给出的 `yTicks`/`axisIndices` 是**正常屏的定值**(3 档纵轴、最多 4 个日期标签);
- * · 渲染层量到容器真实像素后,把 `heightPx`/`widthPx` 喂进本模块,
- * 由它回答「这个高度放几档刻度、这个宽度留几个日期标签」。
- *
- * 为什么判据是像素而不是 `--u`:刻度与日期标签的字号是固定像素(`--fs-caption` 10.5px),
- * 不随 `--u` 缩放;「会不会挤在一起」是排版事实,按真实像素判才成立。
- * 也**没有**视口断点:宽屏与否由渲染层量到的可用高度决定,本模块只认那个数。
- */
- /** 一格网格的最小高度(px):10.5px 等宽标签 + 上下一线呼吸感,再低就互相压。 */
- export const TICK_MIN_PX = 26
- /**
- * 测量 → 算式 的接线(票 64):渲染层量到的几个数 → `fitTrendHeight()` 的入参。
- *
- * 为什么这一步也要放 domain:`othersPx` 的定义是"**其它块**的自然高",
- * 它必须从内容总高里**扣掉折线块自己当前的高度** —— 不扣的话富余恒 ≤ 0、
- * 整个特性静默失效(屏上只是"没生效",没有任何报错)。这种静默失效必须能被离线断言
- * (见 t64 闸门的 C10),所以定义不能留在组件里靠字符串守卫看着。
- */
- export function trendFitInputs({ hostHeightPx, paddingTopPx = 0, paddingBottomPx = 0, contentHeightPx, chartHeightsPx = [] } = {}) {
- const heights = chartHeightsPx.map((value) => Number(value) || 0)
- return {
- budgetPx: (Number(hostHeightPx) || 0) - (Number(paddingTopPx) || 0) - (Number(paddingBottomPx) || 0),
- othersPx: (Number(contentHeightPx) || 0) - heights.reduce((sum, value) => sum + value, 0),
- count: heights.length,
- }
- }
- /** 宽屏折线图顶部呼吸空间:可用净高的 9%(小标题/点顶数值在上方,必须比下方更宽松)。 */
- export const TREND_TOP_BREATHING_RATIO = 0.09
- /** 宽屏折线图底部呼吸空间:可用净高的 5%(X 轴已有独立标签行,下方可少留)。 */
- export const TREND_BOTTOM_BREATHING_RATIO = 0.05
- /** 上下呼吸空间合计(14%):比上一轮 12% 小幅增加,并把新增空间集中到上方。 */
- export const TREND_VERTICAL_BREATHING_RATIO = TREND_TOP_BREATHING_RATIO + TREND_BOTTOM_BREATHING_RATIO
- /**
- * 接管时必须留出的**安全余量**(px):宁可少长 10px,也不能贴着面板体边界。
- *
- * 维护者 2026-10-28 反馈:拉伸过头会让本该不出滚动条的面板出滚动条。边界上任何
- * 亚像素/取整/后到的内容(例如清单数据比图表晚一帧到达)都会把面板体顶出 1px,
- * 而 1px 就足够让 `overflow-y: auto` 画出滚动条 —— 所以这里留的是绝对余量,不是比例。
- */
- export const TREND_SAFETY_SLACK_PX = 10
- /**
- * 宽屏折线的真实布局预算:呼吸空间必须**落到图表内部的 padding**,不能只从总高扣掉。
- *
- * 返回全 0 表示不接管(正常屏仍由 64u 下限与既有 padding 管);接管时:
- * · heightPx = 可用净高 − 安全余量(**含**它自己的上下 padding,border-box);
- * · paddingTopPx / paddingBottomPx = 按可用净高 9% / 5% 明确写进 CSS。
- *
- * 两条"不许接管"的边界,都是为了**永不把面板体顶出滚动条**:
- * 1. 净高不足(≤ 0)——内容本来就比面板长,接管只会更糟;
- * 2. 算出来的高度**不大于 CSS 的 min-height**(`minHeightPx`)—— 那种情况下 CSS 的下限会赢,
- * 块还是被撑到 min-height,而预算只按更小的值算,于是必然溢出
- * (真机实测:3440×1440 概览面板 fit=79 但 min-height=114 → 溢出 34px)。
- */
- /**
- * 折线高度占**面板**的比例区间(维护者 2026-10-29)。
- *
- * 原话:「限制最小和最大高度范围,避免太窄看不清,太高观感怪(最小大约占面板的八分之一,
- * 最大占面板的三分之一,其他的折线图也按这样相同规格去规定)」。
- * 所以区间按**面板体可用高度**取比例,而不是写死像素:换屏幕/换面板分配都不用重算,
- * 且全仓所有折线(预警处置三块、概览、运行概况…)共用这一条规格。
- */
- // 维护者 2026-10-30 第 2 次反馈:1/8 与旧默认 64u(--u=2px 时即 128px)几乎重合,
- // 屏上看不出变化;改 1/6 让「最小高度」真正可见。
- export const TREND_HEIGHT_MIN_RATIO = 1 / 6
- export const TREND_HEIGHT_MAX_RATIO = 1 / 3
- /**
- * 面板高度 → 折线高度的上下界(px)。面板高度未知(≤0)时回 0,表示「不设界」。
- * @param {number} panelHeightPx 面板体可用高度
- */
- export function trendHeightBounds(panelHeightPx, wide = false) {
- const height = Math.max(0, Number(panelHeightPx) || 0)
- if (!(height > 0)) return { minPx: 0, maxPx: 0 }
- const maxPx = Math.floor(height * TREND_HEIGHT_MAX_RATIO)
- // 维护者 2026-10-30 第 3 次反馈:宽屏 1/6~1/3;**普通屏固定 1/3**(「再×2」)。
- // 普通屏面板更高、内容更满,1/6 的下限会让折线显得过矮,所以直接钉在 1/3。
- // `wide` 由渲染层按与 CockpitShell 同源的超宽断点(aspect ≥ 3456/1239)判定后传入 ——
- // 领域层不碰视口,只认这个布尔。
- const minPx = wide ? Math.floor(height * TREND_HEIGHT_MIN_RATIO) : maxPx
- return { minPx, maxPx }
- }
- export function fitTrendLayout({ budgetPx, othersPx, count, minHeightPx = 0, wide = false } = {}) {
- const budget = Number(budgetPx) || 0
- const others = Number(othersPx) || 0
- const charts = Math.max(1, Math.floor(Number(count) || 0))
- const floor = Math.max(0, Number(minHeightPx) || 0)
- const idle = { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
- if (!(budget > 0)) return idle
- const available = (budget - others) / charts
- const room = Math.floor(available)
- // 维护者 2026-10-30 第 1 条:「1/8 ~ 1/3 好像没有成功生效,希望宽屏和窄屏同时生效」。
- // 旧实现只有**有富余**时才接管,而真实面板几乎总是内容更高 ⇒ 恒走 CSS 的 64u 定高,
- // 比例区间等于没生效。现在改成:**只要量到面板高度就按比例出手** ——
- // · 有富余:取富余(再夹进区间);
- // · 没富余:取区间下限(1/8)—— 这就是「不低于八分之一」的字面含义,窄屏也成立。
- const bounds = trendHeightBounds(budget, wide)
- let heightPx = room > 0 ? room - TREND_SAFETY_SLACK_PX : bounds.minPx
- // 富余极小(或负数)时回落规格下限 —— 普通屏「固定 1/3」必须真的兑现。
- if (!(heightPx > 0)) heightPx = bounds.minPx
- if (bounds.maxPx > 0 && heightPx > bounds.maxPx) heightPx = bounds.maxPx
- // 下限是否强制:宽屏按 1/6 保底、**普通屏单张折线严格 1/3**;
- // 同面板多张时不强制(否则 N 张就是 N/3,必然把面板挤出滚动条)——多张按可用空间均分。
- const enforceMin = wide || charts === 1 || bounds.minPx <= room
- if (bounds.minPx > 0 && heightPx < bounds.minPx && enforceMin) heightPx = bounds.minPx
- // 唯一例外:CSS 的 min-height 比规格下限还高时以它为准(否则 CSS 会赢、而预算按更小的值算 → 溢出);
- // 若这样得到的高度又超出规格上限,说明面板小到装不下一条合规折线,交还 CSS。
- if (heightPx < floor) heightPx = floor
- if (bounds.maxPx > 0 && heightPx > bounds.maxPx) return idle
- // 呼吸留白按**夹取后**的高度算(按夹取前的净预算算会把绘图区挤没)。
- return {
- heightPx,
- paddingTopPx: Math.floor(heightPx * TREND_TOP_BREATHING_RATIO),
- paddingBottomPx: Math.floor(heightPx * TREND_BOTTOM_BREATHING_RATIO),
- }
- }
- /**
- * 单块折线图该分到多高(px)。**0 = 不接管**,由样式的 `min-height` 兜住正常屏设计高度。
- * 保留这个导出给既有调用/闸门;新渲染层用 `fitTrendLayout()` 同时拿高与上下 padding。
- *
- * 这是票 64 要求 3(「不得把正常屏的图表撑高挤出滚动条」)的**唯一算式**,
- * 所以放在 domain 里而不是组件里:可以离线断言,也不会和渲染层长出第二份算术。
- *
- * @param {{budgetPx?: number, othersPx?: number, count?: number}} box
- * budgetPx 面板体可用高度(内容盒);othersPx 同面板其它块占掉的高度;
- * count 同面板折线块个数(富余要均分,摊多了就是滚动条)。
- */
- export function fitTrendHeight(box = {}) {
- return fitTrendLayout(box).heightPx
- }
- /** 一个横轴日期标签(如 `MM-DD`,5 个等宽字符)的最小占位宽度(px)。 */
- export const AXIS_LABEL_MIN_PX = 46
- /**
- * 刻度条数档位 —— 只放能整除 100 的档:3 档=0/半程/满、5 档=四分、9 档=八分。
- * 不放 4/6/7 档:它们的刻度值是 max 的 1/3、1/6 这类除不尽的数,
- * 屏上就变成 `2.33 / 4.67` 这种「精确但没用」的读数(票 11 的等宽数字会被撑宽)。
- */
- const TICK_COUNTS = [3, 5, 9]
- /**
- * 该高度能放下几档刻度,返回自上而下(0% → 100%)的比例,最后一项恒为 100。
- * 单调:高度只增不减 ⇒ 档位只会往更大档走(3 → 5 → 9),不会来回跳。
- */
- export function tickRatios(heightPx) {
- const fit = Math.floor((Number(heightPx) || 0) / TICK_MIN_PX)
- let count = TICK_COUNTS[0]
- for (const candidate of TICK_COUNTS) {
- if (fit >= candidate) count = candidate
- }
- return Array.from({ length: count }, (_, i) => (i / (count - 1)) * 100)
- }
- /**
- * 刻度标签的可读化取整:**只取整标签,不改几何**(`pct` 仍是真实比例),
- * 且恒有 `value ≤ max`(pct ≤ 100)——不外包络、不外插。
- * 大数取整、小数留一位(与既有的 `max × 1/2` 同口径,如 max=3 时中档仍是 1.5)。
- */
- function tickValue(max, pct) {
- const raw = (max * pct) / 100
- return max >= 10 ? Math.round(raw) : Math.round(raw * 10) / 10
- }
- /**
- * 折线纵轴刻度:零序列只画底部 0(与 `trendBlock()` 的既有口径一字不差),
- * 非零序列按可用高度给 3/5/9 档。
- * @param {number} max 归一化用的最大值(多序列时由调用方给联合最大值)
- * @param {number} heightPx 绘图区实测高度
- */
- export function trendTicks(max, heightPx) {
- const top = Number(max) || 0
- if (!(top > 0)) return [{ value: 0, pct: 0 }]
- return tickRatios(heightPx).map((pct) => ({ value: tickValue(top, pct), pct }))
- }
- /**
- * 横轴日期标签抽稀:**放得下就全显**,放不下按等步长抽稀,首尾必显。
- *
- * 首尾必显是时间轴的语义要求:抽掉的中间点读者能靠位置与悬停补,两端缺了就读不出窗口起止。
- * @param {number} count 点数(桶数,来自后端,本层不重采样)
- * @param {number} widthPx 横轴实测宽度
- */
- export function axisLabelIndices(count, widthPx) {
- const n = Math.max(0, Math.floor(Number(count) || 0))
- if (n <= 1) return n === 1 ? [0] : []
- const capacity = Math.max(2, Math.floor((Number(widthPx) || 0) / AXIS_LABEL_MIN_PX))
- if (n <= capacity) return Array.from({ length: n }, (_, i) => i)
- const step = Math.ceil((n - 1) / (capacity - 1))
- const indices = []
- for (let i = 0; i < n - 1; i += step) indices.push(i)
- indices.push(n - 1)
- return indices
- }
- /** 抽稀步长的「代表性」值:末段可能更密(首尾必显导致),取首个间隔即可。 */
- function axisStep(indices) {
- return Array.isArray(indices) && indices.length >= 2 ? indices[1] - indices[0] : 1
- }
- /**
- * 把一个折线块按实测容器尺寸重算几何。非折线块、或还没量到尺寸时**原样返回**
- * (首帧=domain 的定值,正常屏观感与改动前一致;量到之后才接管)。
- *
- * 只改几何三个字段(`yTicks`/`axisIndices`/`axisEvery`):
- * 点列、日期、色键、面积与标注规则一概不动(票 64 的边界:不改数据语义)。
- * @param {object} block `trendBlock()` 产出的折线块
- * @param {{heightPx?: number, widthPx?: number}} [box] 实测尺寸(绘图区高度 / 横轴宽度)
- */
- export function fitTrendBlock(block, box = {}) {
- if (!block || block.kind !== 'trend' || block.chart !== 'line') return block
- const heightPx = Number(box.heightPx) || 0
- const widthPx = Number(box.widthPx) || 0
- if (heightPx <= 0 && widthPx <= 0) return block
- const axisIndices = widthPx > 0
- ? axisLabelIndices((block.points || []).length, widthPx)
- : block.axisIndices
- return {
- ...block,
- yTicks: heightPx > 0 ? trendTicks(block.multiSeries ? block.seriesMax : block.max, heightPx) : block.yTicks,
- axisIndices,
- axisEvery: axisStep(axisIndices),
- }
- }
|