chartFit.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * 折线图的**尺寸自适应重算**(票 64:宽屏填充高度并重算纵轴)。
  3. *
  4. * 分工:几何形状归 domain,可用尺寸归渲染层。
  5. * · `trendBlock()` 只按数据算归一化点列(它不知道容器多大,也不该知道),
  6. * 给出的 `yTicks`/`axisIndices` 是**正常屏的定值**(3 档纵轴、最多 4 个日期标签);
  7. * · 渲染层量到容器真实像素后,把 `heightPx`/`widthPx` 喂进本模块,
  8. * 由它回答「这个高度放几档刻度、这个宽度留几个日期标签」。
  9. *
  10. * 为什么判据是像素而不是 `--u`:刻度与日期标签的字号是固定像素(`--fs-caption` 10.5px),
  11. * 不随 `--u` 缩放;「会不会挤在一起」是排版事实,按真实像素判才成立。
  12. * 也**没有**视口断点:宽屏与否由渲染层量到的可用高度决定,本模块只认那个数。
  13. */
  14. /** 一格网格的最小高度(px):10.5px 等宽标签 + 上下一线呼吸感,再低就互相压。 */
  15. export const TICK_MIN_PX = 26
  16. /**
  17. * 测量 → 算式 的接线(票 64):渲染层量到的几个数 → `fitTrendHeight()` 的入参。
  18. *
  19. * 为什么这一步也要放 domain:`othersPx` 的定义是"**其它块**的自然高",
  20. * 它必须从内容总高里**扣掉折线块自己当前的高度** —— 不扣的话富余恒 ≤ 0、
  21. * 整个特性静默失效(屏上只是"没生效",没有任何报错)。这种静默失效必须能被离线断言
  22. * (见 t64 闸门的 C10),所以定义不能留在组件里靠字符串守卫看着。
  23. */
  24. export function trendFitInputs({ hostHeightPx, paddingTopPx = 0, paddingBottomPx = 0, contentHeightPx, chartHeightsPx = [] } = {}) {
  25. const heights = chartHeightsPx.map((value) => Number(value) || 0)
  26. return {
  27. budgetPx: (Number(hostHeightPx) || 0) - (Number(paddingTopPx) || 0) - (Number(paddingBottomPx) || 0),
  28. othersPx: (Number(contentHeightPx) || 0) - heights.reduce((sum, value) => sum + value, 0),
  29. count: heights.length,
  30. }
  31. }
  32. /** 宽屏折线图顶部呼吸空间:可用净高的 9%(小标题/点顶数值在上方,必须比下方更宽松)。 */
  33. export const TREND_TOP_BREATHING_RATIO = 0.09
  34. /** 宽屏折线图底部呼吸空间:可用净高的 5%(X 轴已有独立标签行,下方可少留)。 */
  35. export const TREND_BOTTOM_BREATHING_RATIO = 0.05
  36. /** 上下呼吸空间合计(14%):比上一轮 12% 小幅增加,并把新增空间集中到上方。 */
  37. export const TREND_VERTICAL_BREATHING_RATIO = TREND_TOP_BREATHING_RATIO + TREND_BOTTOM_BREATHING_RATIO
  38. /**
  39. * 接管时必须留出的**安全余量**(px):宁可少长 10px,也不能贴着面板体边界。
  40. *
  41. * 维护者 2026-10-28 反馈:拉伸过头会让本该不出滚动条的面板出滚动条。边界上任何
  42. * 亚像素/取整/后到的内容(例如清单数据比图表晚一帧到达)都会把面板体顶出 1px,
  43. * 而 1px 就足够让 `overflow-y: auto` 画出滚动条 —— 所以这里留的是绝对余量,不是比例。
  44. */
  45. export const TREND_SAFETY_SLACK_PX = 10
  46. /**
  47. * 宽屏折线的真实布局预算:呼吸空间必须**落到图表内部的 padding**,不能只从总高扣掉。
  48. *
  49. * 返回全 0 表示不接管(正常屏仍由 64u 下限与既有 padding 管);接管时:
  50. * · heightPx = 可用净高 − 安全余量(**含**它自己的上下 padding,border-box);
  51. * · paddingTopPx / paddingBottomPx = 按可用净高 9% / 5% 明确写进 CSS。
  52. *
  53. * 两条"不许接管"的边界,都是为了**永不把面板体顶出滚动条**:
  54. * 1. 净高不足(≤ 0)——内容本来就比面板长,接管只会更糟;
  55. * 2. 算出来的高度**不大于 CSS 的 min-height**(`minHeightPx`)—— 那种情况下 CSS 的下限会赢,
  56. * 块还是被撑到 min-height,而预算只按更小的值算,于是必然溢出
  57. * (真机实测:3440×1440 概览面板 fit=79 但 min-height=114 → 溢出 34px)。
  58. */
  59. /**
  60. * 折线高度占**面板**的比例区间(维护者 2026-10-29)。
  61. *
  62. * 原话:「限制最小和最大高度范围,避免太窄看不清,太高观感怪(最小大约占面板的八分之一,
  63. * 最大占面板的三分之一,其他的折线图也按这样相同规格去规定)」。
  64. * 所以区间按**面板体可用高度**取比例,而不是写死像素:换屏幕/换面板分配都不用重算,
  65. * 且全仓所有折线(预警处置三块、概览、运行概况…)共用这一条规格。
  66. */
  67. // 维护者 2026-10-30 第 2 次反馈:1/8 与旧默认 64u(--u=2px 时即 128px)几乎重合,
  68. // 屏上看不出变化;改 1/6 让「最小高度」真正可见。
  69. export const TREND_HEIGHT_MIN_RATIO = 1 / 6
  70. export const TREND_HEIGHT_MAX_RATIO = 1 / 3
  71. /**
  72. * 面板高度 → 折线高度的上下界(px)。面板高度未知(≤0)时回 0,表示「不设界」。
  73. * @param {number} panelHeightPx 面板体可用高度
  74. */
  75. export function trendHeightBounds(panelHeightPx, wide = false) {
  76. const height = Math.max(0, Number(panelHeightPx) || 0)
  77. if (!(height > 0)) return { minPx: 0, maxPx: 0 }
  78. const maxPx = Math.floor(height * TREND_HEIGHT_MAX_RATIO)
  79. // 维护者 2026-10-30 第 3 次反馈:宽屏 1/6~1/3;**普通屏固定 1/3**(「再×2」)。
  80. // 普通屏面板更高、内容更满,1/6 的下限会让折线显得过矮,所以直接钉在 1/3。
  81. // `wide` 由渲染层按与 CockpitShell 同源的超宽断点(aspect ≥ 3456/1239)判定后传入 ——
  82. // 领域层不碰视口,只认这个布尔。
  83. const minPx = wide ? Math.floor(height * TREND_HEIGHT_MIN_RATIO) : maxPx
  84. return { minPx, maxPx }
  85. }
  86. export function fitTrendLayout({ budgetPx, othersPx, count, minHeightPx = 0, wide = false } = {}) {
  87. const budget = Number(budgetPx) || 0
  88. const others = Number(othersPx) || 0
  89. const charts = Math.max(1, Math.floor(Number(count) || 0))
  90. const floor = Math.max(0, Number(minHeightPx) || 0)
  91. const idle = { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
  92. if (!(budget > 0)) return idle
  93. const available = (budget - others) / charts
  94. const room = Math.floor(available)
  95. // 维护者 2026-10-30 第 1 条:「1/8 ~ 1/3 好像没有成功生效,希望宽屏和窄屏同时生效」。
  96. // 旧实现只有**有富余**时才接管,而真实面板几乎总是内容更高 ⇒ 恒走 CSS 的 64u 定高,
  97. // 比例区间等于没生效。现在改成:**只要量到面板高度就按比例出手** ——
  98. // · 有富余:取富余(再夹进区间);
  99. // · 没富余:取区间下限(1/8)—— 这就是「不低于八分之一」的字面含义,窄屏也成立。
  100. const bounds = trendHeightBounds(budget, wide)
  101. let heightPx = room > 0 ? room - TREND_SAFETY_SLACK_PX : bounds.minPx
  102. // 富余极小(或负数)时回落规格下限 —— 普通屏「固定 1/3」必须真的兑现。
  103. if (!(heightPx > 0)) heightPx = bounds.minPx
  104. if (bounds.maxPx > 0 && heightPx > bounds.maxPx) heightPx = bounds.maxPx
  105. // 下限是否强制:宽屏按 1/6 保底、**普通屏单张折线严格 1/3**;
  106. // 同面板多张时不强制(否则 N 张就是 N/3,必然把面板挤出滚动条)——多张按可用空间均分。
  107. const enforceMin = wide || charts === 1 || bounds.minPx <= room
  108. if (bounds.minPx > 0 && heightPx < bounds.minPx && enforceMin) heightPx = bounds.minPx
  109. // 唯一例外:CSS 的 min-height 比规格下限还高时以它为准(否则 CSS 会赢、而预算按更小的值算 → 溢出);
  110. // 若这样得到的高度又超出规格上限,说明面板小到装不下一条合规折线,交还 CSS。
  111. if (heightPx < floor) heightPx = floor
  112. if (bounds.maxPx > 0 && heightPx > bounds.maxPx) return idle
  113. // 呼吸留白按**夹取后**的高度算(按夹取前的净预算算会把绘图区挤没)。
  114. return {
  115. heightPx,
  116. paddingTopPx: Math.floor(heightPx * TREND_TOP_BREATHING_RATIO),
  117. paddingBottomPx: Math.floor(heightPx * TREND_BOTTOM_BREATHING_RATIO),
  118. }
  119. }
  120. /**
  121. * 单块折线图该分到多高(px)。**0 = 不接管**,由样式的 `min-height` 兜住正常屏设计高度。
  122. * 保留这个导出给既有调用/闸门;新渲染层用 `fitTrendLayout()` 同时拿高与上下 padding。
  123. *
  124. * 这是票 64 要求 3(「不得把正常屏的图表撑高挤出滚动条」)的**唯一算式**,
  125. * 所以放在 domain 里而不是组件里:可以离线断言,也不会和渲染层长出第二份算术。
  126. *
  127. * @param {{budgetPx?: number, othersPx?: number, count?: number}} box
  128. * budgetPx 面板体可用高度(内容盒);othersPx 同面板其它块占掉的高度;
  129. * count 同面板折线块个数(富余要均分,摊多了就是滚动条)。
  130. */
  131. export function fitTrendHeight(box = {}) {
  132. return fitTrendLayout(box).heightPx
  133. }
  134. /** 一个横轴日期标签(如 `MM-DD`,5 个等宽字符)的最小占位宽度(px)。 */
  135. export const AXIS_LABEL_MIN_PX = 46
  136. /**
  137. * 刻度条数档位 —— 只放能整除 100 的档:3 档=0/半程/满、5 档=四分、9 档=八分。
  138. * 不放 4/6/7 档:它们的刻度值是 max 的 1/3、1/6 这类除不尽的数,
  139. * 屏上就变成 `2.33 / 4.67` 这种「精确但没用」的读数(票 11 的等宽数字会被撑宽)。
  140. */
  141. const TICK_COUNTS = [3, 5, 9]
  142. /**
  143. * 该高度能放下几档刻度,返回自上而下(0% → 100%)的比例,最后一项恒为 100。
  144. * 单调:高度只增不减 ⇒ 档位只会往更大档走(3 → 5 → 9),不会来回跳。
  145. */
  146. export function tickRatios(heightPx) {
  147. const fit = Math.floor((Number(heightPx) || 0) / TICK_MIN_PX)
  148. let count = TICK_COUNTS[0]
  149. for (const candidate of TICK_COUNTS) {
  150. if (fit >= candidate) count = candidate
  151. }
  152. return Array.from({ length: count }, (_, i) => (i / (count - 1)) * 100)
  153. }
  154. /**
  155. * 刻度标签的可读化取整:**只取整标签,不改几何**(`pct` 仍是真实比例),
  156. * 且恒有 `value ≤ max`(pct ≤ 100)——不外包络、不外插。
  157. * 大数取整、小数留一位(与既有的 `max × 1/2` 同口径,如 max=3 时中档仍是 1.5)。
  158. */
  159. function tickValue(max, pct) {
  160. const raw = (max * pct) / 100
  161. return max >= 10 ? Math.round(raw) : Math.round(raw * 10) / 10
  162. }
  163. /**
  164. * 折线纵轴刻度:零序列只画底部 0(与 `trendBlock()` 的既有口径一字不差),
  165. * 非零序列按可用高度给 3/5/9 档。
  166. * @param {number} max 归一化用的最大值(多序列时由调用方给联合最大值)
  167. * @param {number} heightPx 绘图区实测高度
  168. */
  169. export function trendTicks(max, heightPx) {
  170. const top = Number(max) || 0
  171. if (!(top > 0)) return [{ value: 0, pct: 0 }]
  172. return tickRatios(heightPx).map((pct) => ({ value: tickValue(top, pct), pct }))
  173. }
  174. /**
  175. * 横轴日期标签抽稀:**放得下就全显**,放不下按等步长抽稀,首尾必显。
  176. *
  177. * 首尾必显是时间轴的语义要求:抽掉的中间点读者能靠位置与悬停补,两端缺了就读不出窗口起止。
  178. * @param {number} count 点数(桶数,来自后端,本层不重采样)
  179. * @param {number} widthPx 横轴实测宽度
  180. */
  181. export function axisLabelIndices(count, widthPx) {
  182. const n = Math.max(0, Math.floor(Number(count) || 0))
  183. if (n <= 1) return n === 1 ? [0] : []
  184. const capacity = Math.max(2, Math.floor((Number(widthPx) || 0) / AXIS_LABEL_MIN_PX))
  185. if (n <= capacity) return Array.from({ length: n }, (_, i) => i)
  186. const step = Math.ceil((n - 1) / (capacity - 1))
  187. const indices = []
  188. for (let i = 0; i < n - 1; i += step) indices.push(i)
  189. indices.push(n - 1)
  190. return indices
  191. }
  192. /** 抽稀步长的「代表性」值:末段可能更密(首尾必显导致),取首个间隔即可。 */
  193. function axisStep(indices) {
  194. return Array.isArray(indices) && indices.length >= 2 ? indices[1] - indices[0] : 1
  195. }
  196. /**
  197. * 把一个折线块按实测容器尺寸重算几何。非折线块、或还没量到尺寸时**原样返回**
  198. * (首帧=domain 的定值,正常屏观感与改动前一致;量到之后才接管)。
  199. *
  200. * 只改几何三个字段(`yTicks`/`axisIndices`/`axisEvery`):
  201. * 点列、日期、色键、面积与标注规则一概不动(票 64 的边界:不改数据语义)。
  202. * @param {object} block `trendBlock()` 产出的折线块
  203. * @param {{heightPx?: number, widthPx?: number}} [box] 实测尺寸(绘图区高度 / 横轴宽度)
  204. */
  205. export function fitTrendBlock(block, box = {}) {
  206. if (!block || block.kind !== 'trend' || block.chart !== 'line') return block
  207. const heightPx = Number(box.heightPx) || 0
  208. const widthPx = Number(box.widthPx) || 0
  209. if (heightPx <= 0 && widthPx <= 0) return block
  210. const axisIndices = widthPx > 0
  211. ? axisLabelIndices((block.points || []).length, widthPx)
  212. : block.axisIndices
  213. return {
  214. ...block,
  215. yTicks: heightPx > 0 ? trendTicks(block.multiSeries ? block.seriesMax : block.max, heightPx) : block.yTicks,
  216. axisIndices,
  217. axisEvery: axisStep(axisIndices),
  218. }
  219. }