indicator-change.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. /* ==============================================================
  2. * 指标变化展示规则纯函数 - 单一事实来源
  3. * 规则来源:design.md §6.6 + PRD §5.5 + §6.2.1 trendCode 五值契约
  4. * 核心约束:前端**不得**根据 `delta` 的正负自行推导"改善/恶化",
  5. * 只能消费后端 `trendCode` 枚举。这是保证全系统一致的唯一可靠方式。
  6. * ============================================================== */
  7. /**
  8. * 【单一事实来源】由后端 trendCode + movement 推导展示样式。
  9. * 所有展示变化的位置(报告详情/身维度/速查/菌群/时间轴/管理端)必须走这里。
  10. *
  11. * trendCode 五值枚举(含 D12 的 pending_backfill):
  12. * 'improved' | 'worsened' | 'flat' | 'first_record' | 'pending_backfill'
  13. *
  14. * 关键约定:
  15. * 1. trendLabel 优先取后端下发的值,前端不改写(合规:文案统一)
  16. * 2. 前端【禁止】用 previous == null 自行判断是"首次"还是"整理中" —— 只能看 trendCode
  17. * 3. movement === 'none' 时不渲染箭头
  18. *
  19. * @param {string} trendCode
  20. * @param {string} movement 'up'|'down'|'flat'|'changed'|'unchanged'|'none'
  21. * @param {string} backendLabel 后端下发的 trendLabel(优先使用,为空时才用本地兜底)
  22. * @returns {{trendLabel:string, cssClass:string, arrow:string, colorClass:string, showArrow:boolean}}
  23. */
  24. export function resolveChange(trendCode, movement, backendLabel) {
  25. var mv = movement || 'flat'
  26. var arrow = mv === 'up' ? '↑' : (mv === 'down' ? '↓' : '→')
  27. var label = backendLabel || ''
  28. // D12 新增两档:无对比对象。灰色、无箭头。
  29. if (trendCode === 'first_record') {
  30. return { trendLabel: label || '首次记录 · 暂无对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false }
  31. }
  32. if (trendCode === 'pending_backfill') {
  33. return { trendLabel: label || '历史数据整理中,暂无法对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false }
  34. }
  35. if (trendCode === 'improved') {
  36. return { trendLabel: label || '改善', cssClass: 'impr', arrow: arrow, colorClass: 'green-text', showArrow: true }
  37. }
  38. if (trendCode === 'worsened') {
  39. return { trendLabel: label || '恶化', cssClass: 'worst', arrow: arrow, colorClass: 'red-text', showArrow: true }
  40. }
  41. return { trendLabel: label || '基本持平', cssClass: 'flat', arrow: mv === 'none' ? '' : arrow, colorClass: 'gray-text', showArrow: mv !== 'none' }
  42. }
  43. /**
  44. * 非数值指标:有变化 / 无变化,不给颜色(合规红线)
  45. */
  46. export function resolveTextChange(movement) {
  47. return movement === 'unchanged'
  48. ? { trendLabel: '无变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' }
  49. : { trendLabel: '有变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' }
  50. }
  51. /**
  52. * Δ 展示文本(PRD §6.4)。列表窄位省略单位由调用方传 unit='' 实现。
  53. * @param {number|null} delta
  54. * @param {number|null} deltaPct
  55. * @param {boolean} unitMismatch 单位跨期不一致 → 百分比显示 '—'
  56. */
  57. export function formatDelta(delta, deltaPct, unitMismatch) {
  58. if (delta === null || delta === undefined || isNaN(delta)) return '--'
  59. var sign = delta > 0 ? '+' : ''
  60. var d = roundSig(delta) // PRD §6.4 有效数字规则
  61. if (unitMismatch || deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) {
  62. return sign + d + ' (—)'
  63. }
  64. return sign + d + ' (' + (deltaPct > 0 ? '+' : '') + deltaPct + '%)'
  65. }
  66. /** PRD §6.4 有效数字:≥100 整数;10–100 一位小数;<10 两位小数 */
  67. export function roundSig(v) {
  68. var a = Math.abs(v)
  69. if (a >= 100) return String(Math.round(v))
  70. if (a >= 10) return v.toFixed(1)
  71. return v.toFixed(2)
  72. }
  73. /**
  74. * 「较 MM-DD」日期文案(PRD §6.6)
  75. * @param {string} prevDate 'yyyy-MM-dd'
  76. * @param {string} currDate 'yyyy-MM-dd' 用于判断是否同年
  77. * @param {string} dateSource 'real'|'upload'|'confirm'|'unknown'
  78. */
  79. export function formatCompareDate(prevDate, currDate, dateSource) {
  80. if (!prevDate) return '日期未知'
  81. var star = (dateSource && dateSource !== 'real' && dateSource !== 'unknown') ? '*' : ''
  82. if (!currDate) return prevDate + star
  83. var sameYear = String(prevDate).slice(0, 4) === String(currDate).slice(0, 4)
  84. return (sameYear ? String(prevDate).slice(5) : prevDate) + star
  85. }
  86. /**
  87. * 图表形态 → 是否画图 / 是否标值(消费后端 chartMode,不自行判断)
  88. * @param {string} chartMode 'empty'|'single'|'pair'|'line'|'line_aggregated'
  89. * @param {number} n 期数
  90. */
  91. export function chartRenderConfig(chartMode, n) {
  92. if (chartMode === 'empty' || chartMode === 'single') return { draw: false, showValues: false, labelMode: 'none' }
  93. if (chartMode === 'pair') return { draw: false, showValues: true, labelMode: 'all' }
  94. if (chartMode === 'line_aggregated') return { draw: true, showValues: false, labelMode: 'sparse' }
  95. return { draw: true, showValues: n <= 8, labelMode: n <= 8 ? 'all' : 'sparse' }
  96. }
  97. /** X 轴标签下标抽稀(PRD §5.2 选型表) */
  98. export function axisLabelIndices(n) {
  99. if (n <= 8) { var all = []; for (var i = 0; i < n; i++) all.push(i); return all }
  100. if (n <= 19) return unique([0, Math.floor((n - 1) / 3), Math.floor(2 * (n - 1) / 3), n - 1])
  101. return unique([0, Math.floor(n / 2), n - 1])
  102. }
  103. // ---------- 兼容保留 ----------
  104. /**
  105. * @deprecated 仅供无 trendCode 的老数据兜底(如 member_indicator_current.trend)。
  106. * 新代码一律用 resolveChange(trendCode, movement)。
  107. * 保留原实现不删(member_indicator_current 链路仍只有 trend 字段,无 trendCode)。
  108. *
  109. * 由 good_direction × movement 推导展示样式
  110. * @param {string} goodDirection higher_is_better|lower_is_better|in_range_is_better|neutral
  111. * @param {string} movement up|down|flat
  112. * @returns {{trendLabel:string, cssClass:string, arrow:string}}
  113. */
  114. export function resolveChangeStyle(goodDirection, movement) {
  115. var gd = goodDirection || 'neutral'
  116. var mv = movement || 'flat'
  117. if (mv === 'flat') {
  118. return { trendLabel: '中性', cssClass: 'flat', arrow: '→' }
  119. }
  120. // 是否"改善":higher_is_better 升=改善;lower_is_better 降=改善;in_range 视区间判定(此处交给 withRange 变体)
  121. var isImprove = false
  122. if (gd === 'higher_is_better') {
  123. isImprove = mv === 'up'
  124. } else if (gd === 'lower_is_better') {
  125. isImprove = mv === 'down'
  126. } else {
  127. // neutral / in_range_is_better(一期 in_range 的管理端手配方向;区间内移动判定留二期,先按中性展示)
  128. return {
  129. trendLabel: '中性',
  130. cssClass: gd === 'in_range_is_better' ? 'neutral-up' : mv === 'up' ? 'neutral-up' : 'neutral-down',
  131. arrow: mv === 'up' ? '↑' : '↓'
  132. }
  133. }
  134. if (isImprove) {
  135. return { trendLabel: '改善', cssClass: gd === 'higher_is_better' ? 'impr-up' : 'impr-down', arrow: mv === 'up' ? '↑' : '↓' }
  136. }
  137. return { trendLabel: '恶化', cssClass: gd === 'higher_is_better' ? 'worst-down' : 'worst-up', arrow: mv === 'up' ? '↑' : '↓' }
  138. }
  139. /**
  140. * CSS 颜色类:改善=green-text 恶化=red-text 中性=gray-text
  141. * @param {string} cssClass resolveChangeStyle 返回的类
  142. * @returns {string}
  143. */
  144. export function colorClassFor(cssClass) {
  145. if (!cssClass) return 'gray-text'
  146. if (cssClass.indexOf('impr') === 0) return 'green-text'
  147. if (cssClass.indexOf('worst') === 0) return 'red-text'
  148. return 'gray-text'
  149. }
  150. /** 去重并升序(下标抽稀用) */
  151. function unique(arr) {
  152. var seen = {}
  153. var out = []
  154. for (var i = 0; i < arr.length; i++) {
  155. var v = arr[i]
  156. if (!seen[v]) { seen[v] = true; out.push(v) }
  157. }
  158. out.sort(function(a, b) { return a - b })
  159. return out
  160. }