/** * 折线图的**尺寸自适应重算**(票 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), } }