| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173 |
- /* ==============================================================
- * 指标变化展示规则纯函数 - 单一事实来源
- * 规则来源:design.md §6.6 + PRD §5.5 + §6.2.1 trendCode 五值契约
- * 核心约束:前端**不得**根据 `delta` 的正负自行推导"改善/恶化",
- * 只能消费后端 `trendCode` 枚举。这是保证全系统一致的唯一可靠方式。
- * ============================================================== */
- /**
- * 【单一事实来源】由后端 trendCode + movement 推导展示样式。
- * 所有展示变化的位置(报告详情/身维度/速查/菌群/时间轴/管理端)必须走这里。
- *
- * trendCode 五值枚举(含 D12 的 pending_backfill):
- * 'improved' | 'worsened' | 'flat' | 'first_record' | 'pending_backfill'
- *
- * 关键约定:
- * 1. trendLabel 优先取后端下发的值,前端不改写(合规:文案统一)
- * 2. 前端【禁止】用 previous == null 自行判断是"首次"还是"整理中" —— 只能看 trendCode
- * 3. movement === 'none' 时不渲染箭头
- *
- * @param {string} trendCode
- * @param {string} movement 'up'|'down'|'flat'|'changed'|'unchanged'|'none'
- * @param {string} backendLabel 后端下发的 trendLabel(优先使用,为空时才用本地兜底)
- * @returns {{trendLabel:string, cssClass:string, arrow:string, colorClass:string, showArrow:boolean}}
- */
- export function resolveChange(trendCode, movement, backendLabel) {
- var mv = movement || 'flat'
- var arrow = mv === 'up' ? '↑' : (mv === 'down' ? '↓' : '→')
- var label = backendLabel || ''
- // D12 新增两档:无对比对象。灰色、无箭头。
- if (trendCode === 'first_record') {
- return { trendLabel: label || '首次记录 · 暂无对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false }
- }
- if (trendCode === 'pending_backfill') {
- return { trendLabel: label || '历史数据整理中,暂无法对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false }
- }
- if (trendCode === 'improved') {
- return { trendLabel: label || '改善', cssClass: 'impr', arrow: arrow, colorClass: 'green-text', showArrow: true }
- }
- if (trendCode === 'worsened') {
- return { trendLabel: label || '恶化', cssClass: 'worst', arrow: arrow, colorClass: 'red-text', showArrow: true }
- }
- return { trendLabel: label || '基本持平', cssClass: 'flat', arrow: mv === 'none' ? '' : arrow, colorClass: 'gray-text', showArrow: mv !== 'none' }
- }
- /**
- * 非数值指标:有变化 / 无变化,不给颜色(合规红线)
- */
- export function resolveTextChange(movement) {
- return movement === 'unchanged'
- ? { trendLabel: '无变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' }
- : { trendLabel: '有变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' }
- }
- /**
- * Δ 展示文本(PRD §6.4)。列表窄位省略单位由调用方传 unit='' 实现。
- * @param {number|null} delta
- * @param {number|null} deltaPct
- * @param {boolean} unitMismatch 单位跨期不一致 → 百分比显示 '—'
- */
- export function formatDelta(delta, deltaPct, unitMismatch) {
- if (delta === null || delta === undefined || isNaN(delta)) return '--'
- var sign = delta > 0 ? '+' : ''
- var d = roundSig(delta) // PRD §6.4 有效数字规则
- if (unitMismatch || deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) {
- return sign + d + ' (—)'
- }
- return sign + d + ' (' + (deltaPct > 0 ? '+' : '') + deltaPct + '%)'
- }
- /** PRD §6.4 有效数字:≥100 整数;10–100 一位小数;<10 两位小数 */
- export function roundSig(v) {
- var a = Math.abs(v)
- if (a >= 100) return String(Math.round(v))
- if (a >= 10) return v.toFixed(1)
- return v.toFixed(2)
- }
- /**
- * 「较 MM-DD」日期文案(PRD §6.6)
- * @param {string} prevDate 'yyyy-MM-dd'
- * @param {string} currDate 'yyyy-MM-dd' 用于判断是否同年
- * @param {string} dateSource 'real'|'upload'|'confirm'|'unknown'
- */
- export function formatCompareDate(prevDate, currDate, dateSource) {
- if (!prevDate) return '日期未知'
- var star = (dateSource && dateSource !== 'real' && dateSource !== 'unknown') ? '*' : ''
- if (!currDate) return prevDate + star
- var sameYear = String(prevDate).slice(0, 4) === String(currDate).slice(0, 4)
- return (sameYear ? String(prevDate).slice(5) : prevDate) + star
- }
- /**
- * 图表形态 → 是否画图 / 是否标值(消费后端 chartMode,不自行判断)
- * @param {string} chartMode 'empty'|'single'|'pair'|'line'|'line_aggregated'
- * @param {number} n 期数
- */
- export function chartRenderConfig(chartMode, n) {
- if (chartMode === 'empty' || chartMode === 'single') return { draw: false, showValues: false, labelMode: 'none' }
- if (chartMode === 'pair') return { draw: false, showValues: true, labelMode: 'all' }
- if (chartMode === 'line_aggregated') return { draw: true, showValues: false, labelMode: 'sparse' }
- return { draw: true, showValues: n <= 8, labelMode: n <= 8 ? 'all' : 'sparse' }
- }
- /** X 轴标签下标抽稀(PRD §5.2 选型表) */
- export function axisLabelIndices(n) {
- if (n <= 8) { var all = []; for (var i = 0; i < n; i++) all.push(i); return all }
- if (n <= 19) return unique([0, Math.floor((n - 1) / 3), Math.floor(2 * (n - 1) / 3), n - 1])
- return unique([0, Math.floor(n / 2), n - 1])
- }
- // ---------- 兼容保留 ----------
- /**
- * @deprecated 仅供无 trendCode 的老数据兜底(如 member_indicator_current.trend)。
- * 新代码一律用 resolveChange(trendCode, movement)。
- * 保留原实现不删(member_indicator_current 链路仍只有 trend 字段,无 trendCode)。
- *
- * 由 good_direction × movement 推导展示样式
- * @param {string} goodDirection higher_is_better|lower_is_better|in_range_is_better|neutral
- * @param {string} movement up|down|flat
- * @returns {{trendLabel:string, cssClass:string, arrow:string}}
- */
- export function resolveChangeStyle(goodDirection, movement) {
- var gd = goodDirection || 'neutral'
- var mv = movement || 'flat'
- if (mv === 'flat') {
- return { trendLabel: '中性', cssClass: 'flat', arrow: '→' }
- }
- // 是否"改善":higher_is_better 升=改善;lower_is_better 降=改善;in_range 视区间判定(此处交给 withRange 变体)
- var isImprove = false
- if (gd === 'higher_is_better') {
- isImprove = mv === 'up'
- } else if (gd === 'lower_is_better') {
- isImprove = mv === 'down'
- } else {
- // neutral / in_range_is_better(一期 in_range 的管理端手配方向;区间内移动判定留二期,先按中性展示)
- return {
- trendLabel: '中性',
- cssClass: gd === 'in_range_is_better' ? 'neutral-up' : mv === 'up' ? 'neutral-up' : 'neutral-down',
- arrow: mv === 'up' ? '↑' : '↓'
- }
- }
- if (isImprove) {
- return { trendLabel: '改善', cssClass: gd === 'higher_is_better' ? 'impr-up' : 'impr-down', arrow: mv === 'up' ? '↑' : '↓' }
- }
- return { trendLabel: '恶化', cssClass: gd === 'higher_is_better' ? 'worst-down' : 'worst-up', arrow: mv === 'up' ? '↑' : '↓' }
- }
- /**
- * CSS 颜色类:改善=green-text 恶化=red-text 中性=gray-text
- * @param {string} cssClass resolveChangeStyle 返回的类
- * @returns {string}
- */
- export function colorClassFor(cssClass) {
- if (!cssClass) return 'gray-text'
- if (cssClass.indexOf('impr') === 0) return 'green-text'
- if (cssClass.indexOf('worst') === 0) return 'red-text'
- return 'gray-text'
- }
- /** 去重并升序(下标抽稀用) */
- function unique(arr) {
- var seen = {}
- var out = []
- for (var i = 0; i < arr.length; i++) {
- var v = arr[i]
- if (!seen[v]) { seen[v] = true; out.push(v) }
- }
- out.sort(function(a, b) { return a - b })
- return out
- }
|