dsh-plugin-show-me-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Line, area and sparkline path builders (docs/05 §4.1).
3
+ *
4
+ * @module core/chart/line
5
+ */
6
+ import { extent, linearScale, thin } from './scale.js'
7
+
8
+ /**
9
+ * @typedef {Object} ChartGeometry
10
+ * @property {number} width - svg width in px.
11
+ * @property {number} height - svg height in px.
12
+ * @property {number} [padding] - uniform inset, or use 'paddingX'/'paddingY'.
13
+ * @property {number} [paddingX] - horizontal inset.
14
+ * @property {number} [paddingY] - vertical inset.
15
+ * @property {number} [padLeft] - left inset (wins over 'paddingX').
16
+ * @property {number} [padRight] - right inset (the value axis lives here).
17
+ * @property {number} [padTop] - top inset.
18
+ * @property {number} [padBottom] - bottom inset (the time axis lives here).
19
+ * @property {number} [innerW] - explicit inner width, for index-based builders.
20
+ * @property {number} [innerH] - explicit inner height.
21
+ * @property {number} [padX] - explicit left inset (already resolved).
22
+ * @property {{ min: number, max: number }} [yDomain] - explicit y domain.
23
+ * @property {number} [maxPoints] - point budget before thinning.
24
+ */
25
+
26
+ /**
27
+ * Resolve padding and plotting box from the geometry spec.
28
+ *
29
+ * @param {ChartGeometry} geometry - chart geometry.
30
+ * @returns {{ width: number, height: number, padX: number, padY: number, innerW: number, innerH: number }} box.
31
+ */
32
+ export function box(geometry) {
33
+ const { width, height } = geometry
34
+ if (!(width > 0) || !(height > 0)) {
35
+ throw new Error(`chart geometry requires positive width/height (got ${width}x${height})`)
36
+ }
37
+ // Asymmetric gutters matter: the value axis is read on the right and the time
38
+ // axis along the bottom, so a uniform inset either clips labels or wastes the
39
+ // left edge. Explicit 'padX'/'padY'/'innerW'/'innerH' still win, because the
40
+ // chart component resolves the plot box once and passes it down.
41
+ const padX = geometry.padX ?? geometry.padLeft ?? geometry.paddingX ?? geometry.padding ?? 0
42
+ const padY = geometry.padY ?? geometry.padTop ?? geometry.paddingY ?? geometry.padding ?? 0
43
+ const padRight = geometry.padRight ?? geometry.paddingX ?? geometry.padding ?? padX
44
+ const padBottom = geometry.padBottom ?? geometry.paddingY ?? geometry.padding ?? padY
45
+ return {
46
+ width,
47
+ height,
48
+ padX,
49
+ padY,
50
+ padRight,
51
+ padBottom,
52
+ innerW: geometry.innerW ?? Math.max(1, width - padX - padRight),
53
+ innerH: geometry.innerH ?? Math.max(1, height - padY - padBottom),
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Format a path coordinate: two decimals is sub-pixel on any display and keeps
59
+ * the generated path strings small.
60
+ *
61
+ * @param {number} value - coordinate.
62
+ * @returns {string} formatted coordinate.
63
+ */
64
+ function coord(value) {
65
+ const rounded = Math.round(value * 100) / 100
66
+ return String(Object.is(rounded, -0) ? 0 : rounded)
67
+ }
68
+
69
+ /**
70
+ * Build the list of straight (non-NaN) runs in a point list. A 'NaN' value is a
71
+ * genuine break — the series is drawn as two sub-paths, never bridged
72
+ * (docs/05 §4.1).
73
+ *
74
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
75
+ * @returns {Array<Array<{ t: string, v: number }>>} runs.
76
+ */
77
+ export function runs(points) {
78
+ const out = []
79
+ let current = []
80
+ for (const point of points) {
81
+ if (Number.isFinite(point?.v)) {
82
+ current.push(point)
83
+ } else if (current.length > 0) {
84
+ out.push(current)
85
+ current = []
86
+ }
87
+ }
88
+ if (current.length > 0) out.push(current)
89
+ return out
90
+ }
91
+
92
+ /**
93
+ * Build an SVG path for a line chart.
94
+ *
95
+ * - '[]' → ''''
96
+ * - one point → ''M x y'' (a move only; no 'L')
97
+ * - a flat series → a horizontal line at the vertical centre
98
+ *
99
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
100
+ * @param {ChartGeometry} geometry - chart geometry.
101
+ * @returns {string} SVG path data.
102
+ */
103
+ export function buildLinePath(points, geometry) {
104
+ if (!Array.isArray(points) || points.length === 0) return ''
105
+ const { innerW, innerH, padX, padY } = box(geometry)
106
+ if (!points.some((p) => Number.isFinite(p?.v))) return ''
107
+
108
+ const budget = geometry.maxPoints ?? Number.POSITIVE_INFINITY
109
+ // The x axis is always proportional to the *declared* index span, so a `NaN`
110
+ // hole keeps its width instead of compressing the visible series. Thinning
111
+ // (only for huge series) collapses missing entries, which is acceptable at
112
+ // that scale.
113
+ const thinFirst = points.map((p, index) => ({ t: p.t, v: Number.isFinite(p?.v) ? p.v : Number.NaN, i: index }))
114
+ const drawn = Number.isFinite(budget) && points.length > budget
115
+ ? thin(thinFirst, budget)
116
+ : thinFirst
117
+ const finite = thinFirst.filter((p) => Number.isFinite(p.v))
118
+ if (finite.length === 0) return ''
119
+ const xSpan = Math.max(1, points.length - 1)
120
+ const x = (index) => padX + (index / xSpan) * innerW
121
+
122
+ const domain = extent(finite.map((p) => p.v))
123
+ const yDomain = geometry.yDomain ?? domain
124
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
125
+
126
+ const parts = []
127
+ for (const run of runs(drawn)) {
128
+ run.forEach((point, runIndex) => {
129
+ const command = runIndex === 0 ? 'M' : 'L'
130
+ parts.push(`${command}${coord(x(point.i))} ${coord(y(point.v))}`)
131
+ })
132
+ }
133
+ return parts.join('')
134
+ }
135
+
136
+ /**
137
+ * Build a closed area path: the line plus a drop to the baseline.
138
+ *
139
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
140
+ * @param {ChartGeometry} geometry - chart geometry.
141
+ * @returns {string} SVG path data ('''' when there is nothing to draw).
142
+ */
143
+ export function buildAreaPath(points, geometry) {
144
+ const line = buildLinePath(points, geometry)
145
+ if (line === '') return ''
146
+ const { innerH, padY } = box(geometry)
147
+ const baseline = padY + innerH
148
+ const coordinates = line.match(/[ML]([\d.-]+) ([\d.-]+)/g) ?? []
149
+ if (coordinates.length === 0) return ''
150
+ const first = coordinates[0].slice(1).split(' ')[0]
151
+ const last = coordinates[coordinates.length - 1].slice(1).split(' ')[0]
152
+ return `${line}L${last} ${coord(baseline)}L${first} ${coord(baseline)}Z`
153
+ }
154
+
155
+ /**
156
+ * Build a sparkline path (no axes, no grid). A single point yields '''' so the
157
+ * card can draw a dot instead of a degenerate path — frozen by tests.
158
+ *
159
+ * @param {number[]} values - values, oldest first.
160
+ * @param {{ width: number, height: number, padding?: number }} geometry - sparkline geometry.
161
+ * @returns {string} SVG path data.
162
+ */
163
+ export function buildSparkline(values, geometry) {
164
+ if (!Array.isArray(values) || values.length < 2) return ''
165
+ const points = values.map((v, i) => ({ t: String(i), v }))
166
+ return buildLinePath(points, { width: geometry.width, height: geometry.height, padding: geometry.padding ?? 1 })
167
+ }
168
+
169
+ /**
170
+ * Build the SVG markup for a sparkline, including the end-point dot. Returns
171
+ * '''' when there is nothing to draw, which the card renders as a placeholder.
172
+ *
173
+ * @param {number[]} values - values, oldest first.
174
+ * @param {{ width: number, height: number, padding?: number, dotRadius?: number }} geometry - sparkline geometry.
175
+ * @returns {{ path: string, dot: { cx: number, cy: number, r: number }|null }} path and end dot.
176
+ */
177
+ export function buildSparklineShape(values, geometry) {
178
+ const path = buildSparkline(values, geometry)
179
+ if (path === '') return { path: '', dot: null }
180
+ const matches = [...path.matchAll(/[ML]([\d.-]+) ([\d.-]+)/g)]
181
+ const last = matches[matches.length - 1]
182
+ return {
183
+ path,
184
+ dot: { cx: Number(last[1]), cy: Number(last[2]), r: geometry.dotRadius ?? 1.5 },
185
+ }
186
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * SVG path geometry for the panel's charts (docs/05 §4.1).
3
+ *
4
+ * Everything here is a pure function of numbers to strings/arrays, so the charts
5
+ * are unit-testable without a browser — that is the whole reason the panel draws
6
+ * its own SVG instead of pulling in a chart library (docs/02 §1.1).
7
+ *
8
+ * @module core/chart/scale
9
+ */
10
+
11
+ /**
12
+ * Clamp a number into a closed range.
13
+ *
14
+ * @param {number} value - candidate.
15
+ * @param {number} min - lower bound.
16
+ * @param {number} max - upper bound.
17
+ * @returns {number} clamped value.
18
+ */
19
+ export function clamp(value, min, max) {
20
+ return Math.min(Math.max(value, min), max)
21
+ }
22
+
23
+ /**
24
+ * Build a linear scale from a data domain to a pixel range.
25
+ *
26
+ * A degenerate domain ('min === max', or any non-finite bound) maps every value
27
+ * to the middle of the range instead of dividing by zero — the branch that turns
28
+ * a flat series into 'NaN' paths (docs/08 §2 defect 5).
29
+ *
30
+ * @param {{ domain: [number, number], range: [number, number] }} spec - scale spec.
31
+ * @returns {(value: number) => number} scale function.
32
+ */
33
+ export function linearScale({ domain, range }) {
34
+ const [d0, d1] = domain
35
+ const [r0, r1] = range
36
+ if (!Number.isFinite(d0) || !Number.isFinite(d1) || d0 === d1) {
37
+ const middle = (r0 + r1) / 2
38
+ return () => middle
39
+ }
40
+ const factor = (r1 - r0) / (d1 - d0)
41
+ return (value) => r0 + (value - d0) * factor
42
+ }
43
+
44
+ /**
45
+ * Extend a domain to the nearest "nice" bounds and pick evenly spaced ticks
46
+ * built from 1/2/5 × 10ⁿ steps (docs/05 §4.1).
47
+ *
48
+ * @param {number} min - data minimum.
49
+ * @param {number} max - data maximum.
50
+ * @param {number} [count] - desired tick count (approximate).
51
+ * @returns {{ min: number, max: number, ticks: number[] }} nice domain and ticks.
52
+ */
53
+ export function niceTicks(min, max, count = 5) {
54
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
55
+ return { min: 0, max: 1, ticks: [0, 0.5, 1] }
56
+ }
57
+ if (min === max) {
58
+ const pad = Math.abs(min) > 0 ? Math.abs(min) * 0.1 : 1
59
+ const lo = min - pad
60
+ const hi = max + pad
61
+ return { min: lo, max: hi, ticks: [lo, (lo + hi) / 2, hi] }
62
+ }
63
+ const steps = Math.max(1, Math.floor(count))
64
+ const rawStep = (max - min) / steps
65
+ const magnitude = 10 ** Math.floor(Math.log10(rawStep))
66
+ const normalized = rawStep / magnitude
67
+ const niceStep = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude
68
+ const lo = Math.floor(min / niceStep) * niceStep
69
+ const hi = Math.ceil(max / niceStep) * niceStep
70
+ const ticks = []
71
+ for (let value = lo; value <= hi + niceStep / 2; value += niceStep) {
72
+ // Round away accumulated float noise (0.30000000000000004 → 0.3).
73
+ ticks.push(Number(value.toFixed(12)))
74
+ }
75
+ return { min: lo, max: hi, ticks }
76
+ }
77
+
78
+ /**
79
+ * Min/max of a numeric list, ignoring non-finite entries.
80
+ *
81
+ * @param {number[]} values - candidate values.
82
+ * @returns {{ min: number, max: number }|undefined} extent, or 'undefined' when nothing is finite.
83
+ */
84
+ export function extent(values) {
85
+ let min = Number.POSITIVE_INFINITY
86
+ let max = Number.NEGATIVE_INFINITY
87
+ let seen = false
88
+ for (const value of values) {
89
+ if (!Number.isFinite(value)) continue
90
+ seen = true
91
+ if (value < min) min = value
92
+ if (value > max) max = value
93
+ }
94
+ return seen ? { min, max } : undefined
95
+ }
96
+
97
+ /**
98
+ * Reduce a series to at most two points per pixel column, preserving the first
99
+ * and last points plus each bucket's extremes so the shape is not flattened
100
+ * (docs/05 §4.1: 10k points must not produce 10k path segments).
101
+ *
102
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
103
+ * @param {number} maxPoints - budget.
104
+ * @returns {Array<{ t: string, v: number }>} thinned points.
105
+ */
106
+ export function thin(points, maxPoints) {
107
+ if (points.length <= maxPoints) return points
108
+ const finite = points.filter((p) => Number.isFinite(p.v))
109
+ if (finite.length <= maxPoints) return points
110
+ const bucketCount = Math.max(1, Math.floor(maxPoints / 2))
111
+ const bucketSize = finite.length / bucketCount
112
+ const out = []
113
+ for (let bucket = 0; bucket < bucketCount; bucket += 1) {
114
+ const start = Math.floor(bucket * bucketSize)
115
+ const end = Math.min(finite.length, Math.floor((bucket + 1) * bucketSize))
116
+ if (end <= start) continue
117
+ let lowest = finite[start]
118
+ let highest = finite[start]
119
+ for (let i = start; i < end; i += 1) {
120
+ if (finite[i].v < lowest.v) lowest = finite[i]
121
+ if (finite[i].v > highest.v) highest = finite[i]
122
+ }
123
+ const pair = lowest.t <= highest.t ? [lowest, highest] : [highest, lowest]
124
+ for (const point of pair) {
125
+ if (out.length === 0 || out[out.length - 1].t !== point.t) out.push(point)
126
+ }
127
+ }
128
+ // Always keep the true endpoints so the line spans the full width.
129
+ const last = finite[finite.length - 1]
130
+ if (out.length === 0 || out[out.length - 1].t !== last.t) out.push(last)
131
+ return out
132
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Formatting shared by the panel and (for the digest) the host — pure functions,
3
+ * no DOM, no locale API (docs/05 §4.3, §6).
4
+ *
5
+ * @module core/format
6
+ */
7
+
8
+ /** Semantic colour keys the panel maps to theme tokens. */
9
+ export const COLORS = ['up', 'down', 'neutral', 'warn', 'error']
10
+
11
+ /**
12
+ * Format a number for display: fixed decimals, grouped thousands, explicit
13
+ * sign where a sign carries meaning.
14
+ *
15
+ * @param {number|undefined} value - value.
16
+ * @param {{ decimals?: number, signed?: boolean, compact?: boolean }} [options] - formatting options.
17
+ * @returns {string} display text (''—'' when absent).
18
+ */
19
+ export function formatValue(value, { decimals = 2, signed = false, compact = false } = {}) {
20
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
21
+ const fixed = value.toFixed(Math.max(0, Math.min(8, decimals)))
22
+ const [intPart, fraction] = fixed.split('.')
23
+ const negative = intPart.startsWith('-')
24
+ const digits = negative ? intPart.slice(1) : intPart
25
+ let grouped = digits
26
+ if (compact && digits.length > 9) {
27
+ const billions = Number(digits) / 1e9
28
+ grouped = `${billions.toFixed(1)}B`
29
+ return `${negative ? '-' : signed ? '+' : ''}${grouped}`
30
+ }
31
+ if (digits.length > 3) grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
32
+ const sign = negative ? '-' : signed ? '+' : ''
33
+ return fraction === undefined ? `${sign}${grouped}` : `${sign}${grouped}.${fraction}`
34
+ }
35
+
36
+ /**
37
+ * Format a change with a direction arrow, for card deltas.
38
+ *
39
+ * @param {number|undefined} changeAbs - absolute change.
40
+ * @param {number|undefined} changePct - percentage change.
41
+ * @param {string} unit - unit suffix.
42
+ * @param {number} [decimals] - decimals for the absolute change.
43
+ * @returns {string} display text.
44
+ */
45
+ export function formatChange(changeAbs, changePct, unit, decimals = 2) {
46
+ if (typeof changeAbs !== 'number' || !Number.isFinite(changeAbs)) return '—'
47
+ const arrow = changeAbs > 0 ? '▲' : changeAbs < 0 ? '▼' : '·'
48
+ const absolute = formatValue(changeAbs, { decimals, signed: true })
49
+ const percent = typeof changePct === 'number' && Number.isFinite(changePct) ? ` (${formatValue(changePct, { decimals: 1, signed: true })}%)` : ''
50
+ return `${arrow} ${absolute}${unit === '' ? '' : unit}${percent}`
51
+ }
52
+
53
+ /**
54
+ * Compact relative time for "last updated" labels.
55
+ *
56
+ * @param {number} minutes - age in minutes.
57
+ * @returns {string} display text.
58
+ */
59
+ export function formatAge(minutes) {
60
+ if (!Number.isFinite(minutes) || minutes < 0) return '未知时间'
61
+ if (minutes < 1) return '刚刚'
62
+ if (minutes < 60) return `${Math.round(minutes)} 分钟前`
63
+ if (minutes < 60 * 24) return `${Math.round(minutes / 60)} 小时前`
64
+ const days = Math.round(minutes / (60 * 24))
65
+ return days === 1 ? '昨天' : `${days} 天前`
66
+ }
67
+
68
+ /**
69
+ * Whether a change should read as good, bad or neutral — decided by the
70
+ * indicator's 'polarity', never by the sign of the number alone
71
+ * (docs/05 §4.3: rising CPI is not "red" the way rising unemployment is).
72
+ *
73
+ * @param {number|undefined} change - signed change.
74
+ * @param {'up-is-good'|'down-is-good'|'neutral'} [polarity] - catalog polarity.
75
+ * @returns {'up'|'down'|'neutral'} colour key.
76
+ */
77
+ export function changeColor(change, polarity = 'neutral') {
78
+ if (typeof change !== 'number' || !Number.isFinite(change) || change === 0) return 'neutral'
79
+ if (polarity === 'neutral') return 'neutral'
80
+ const good = polarity === 'up-is-good' ? change > 0 : change < 0
81
+ return good ? 'up' : 'down'
82
+ }
83
+
84
+ /**
85
+ * Status dot semantics (docs/03 §1.7).
86
+ *
87
+ * @param {'fresh'|'stale'|'error'|'missing'} status - metric status.
88
+ * @returns {{ color: 'up'|'warn'|'error'|'neutral', label: string }} dot description.
89
+ */
90
+ export function statusDot(status) {
91
+ switch (status) {
92
+ case 'fresh':
93
+ return { color: 'up', label: '数据最新' }
94
+ case 'stale':
95
+ return { color: 'warn', label: '数据可能陈旧' }
96
+ case 'error':
97
+ return { color: 'error', label: '数据源暂不可用' }
98
+ default:
99
+ return { color: 'neutral', label: '本范围内无数据' }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Format a calendar date for display.
105
+ *
106
+ * @param {string|undefined} date - 'YYYY-MM-DD'.
107
+ * @param {{ withYear?: boolean }} [options] - options.
108
+ * @returns {string} display text.
109
+ */
110
+ export function formatDate(date, { withYear = true } = {}) {
111
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return '—'
112
+ return withYear ? date : date.slice(5)
113
+ }
114
+
115
+ /**
116
+ * Build the tooltip text for one card, used by the 'title' attribute and by the
117
+ * accessible label.
118
+ *
119
+ * @param {object} metric - metric card.
120
+ * @returns {string} tooltip.
121
+ */
122
+ export function metricTooltip(metric) {
123
+ const parts = [
124
+ `${metric.label?.zh ?? metric.indicatorId}(${metric.indicatorId})`,
125
+ `最新:${formatValue(metric.latest, { decimals: metric.display?.decimals ?? 2 })}${metric.unit ?? ''} @ ${formatDate(metric.latestAt)}`,
126
+ ]
127
+ if (metric.notes?.zh) parts.push(metric.notes.zh)
128
+ if (metric.sourceRef?.label) parts.push(`来源:${metric.sourceRef.label}`)
129
+ parts.push(`状态:${statusDot(metric.status).label}`)
130
+ return parts.join('\n')
131
+ }
132
+
133
+ /**
134
+ * Truncate text for a compact label, keeping the tail visible with an ellipsis.
135
+ *
136
+ * @param {string} text - input.
137
+ * @param {number} max - maximum characters.
138
+ * @returns {string} truncated text.
139
+ */
140
+ export function truncate(text, max) {
141
+ const value = String(text ?? '')
142
+ return value.length <= max ? value : `${value.slice(0, Math.max(1, max - 1))}…`
143
+ }