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,216 @@
1
+ /**
2
+ * Candlestick geometry and hover snapping (docs/05 §4).
3
+ *
4
+ * The panel draws its own SVG, so a candle is four numbers turned into two
5
+ * screen-space primitives: a thin high-low wick and a body spanning open to
6
+ * close. Everything here is a pure function of points and geometry, which keeps
7
+ * the interactive chart testable without a browser.
8
+ *
9
+ * Colour is deliberately *not* decided here: the caller owns polarity, and the
10
+ * only rule this layer encodes is the arithmetic — a candle is up when the close
11
+ * is at or above the open.
12
+ *
13
+ * @module core/chart/candle
14
+ */
15
+
16
+ import { ohlcOf } from '../stats/series.js'
17
+ import { box } from './line.js'
18
+ import { clamp, extent, linearScale, niceTicks } from './scale.js'
19
+
20
+ /** Default share of one x-step a candle body may occupy. */
21
+ export const BODY_RATIO = 0.7
22
+
23
+ /**
24
+ * Widest a candle body may be drawn, in px.
25
+ *
26
+ * With few points the step is enormous, and a one-step-wide bar reads as a
27
+ * block rather than a bar; capping the width keeps three points looking like
28
+ * three candles.
29
+ */
30
+ export const MAX_BODY_PX = 16
31
+
32
+ /**
33
+ * The value domain a chart should cover.
34
+ *
35
+ * A candlestick view must span the wicks, not only the closes: using the close
36
+ * extent would clip highs and lows at the top and bottom of the plot.
37
+ *
38
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} points - ascending points.
39
+ * @param {{ useExtremes?: boolean }} [options] - options.
40
+ * @returns {{ min: number, max: number }|undefined} domain, or 'undefined' when nothing is finite.
41
+ */
42
+ export function valueDomain(points, options = {}) {
43
+ const values = []
44
+ for (const point of points ?? []) {
45
+ if (!Number.isFinite(point?.v)) continue
46
+ const bar = options.useExtremes === true ? ohlcOf(point) : undefined
47
+ values.push(bar === undefined ? point.v : bar.h, bar === undefined ? point.v : bar.l)
48
+ }
49
+ return extent(values)
50
+ }
51
+
52
+ /**
53
+ * Build candle geometry for a point list.
54
+ *
55
+ * Every entry carries the numbers the renderer needs and nothing else: `up` is
56
+ * the arithmetic fact (close >= open) that the caller maps to a colour, and
57
+ * `hollow` marks a bar whose body is too short to fill, which is how a flat
58
+ * session still shows as a visible tick rather than disappearing.
59
+ *
60
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} points - ascending points.
61
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, yDomain?: { min: number, max: number }, bodyRatio?: number, maxBody?: number, minBody?: number }} geometry - chart geometry.
62
+ * @returns {Array<{ t: string, x: number, yHigh: number, yLow: number, yOpen: number, yClose: number, bodyTop: number, bodyH: number, up: boolean, hollow: boolean, point: object }>} candles.
63
+ */
64
+ export function buildCandles(points, geometry) {
65
+ const list = points ?? []
66
+ const bars = list.filter((point) => ohlcOf(point) !== undefined)
67
+ if (bars.length === 0) return []
68
+ const { innerW, padX, padY, innerH } = box({ ...geometry, height: geometry.height })
69
+ const domain = geometry.yDomain ?? valueDomain(list, { useExtremes: true })
70
+ if (domain === undefined) return []
71
+ const y = linearScale({ domain: [domain.min, domain.max], range: [padY + innerH, padY] })
72
+ const step = list.length > 1 ? innerW / (list.length - 1) : innerW
73
+ const bodyW = Math.max(1, Math.min(geometry.maxBody ?? MAX_BODY_PX, step * (geometry.bodyRatio ?? BODY_RATIO)))
74
+ const minBody = geometry.minBody ?? 1
75
+ return list.map((point, index) => {
76
+ const bar = ohlcOf(point)
77
+ if (bar === undefined) return undefined
78
+ const x = padX + (list.length > 1 ? (index / (list.length - 1)) * innerW : innerW / 2)
79
+ const yOpen = y(bar.o)
80
+ const yClose = y(point.v)
81
+ const yHigh = y(bar.h)
82
+ const yLow = y(bar.l)
83
+ const bodyTop = Math.min(yOpen, yClose)
84
+ const bodyH = Math.abs(yClose - yOpen)
85
+ return {
86
+ t: point.t,
87
+ x: Number(x.toFixed(2)),
88
+ bodyW: Number(bodyW.toFixed(2)),
89
+ yHigh: Number(yHigh.toFixed(2)),
90
+ yLow: Number(yLow.toFixed(2)),
91
+ yOpen: Number(yOpen.toFixed(2)),
92
+ yClose: Number(yClose.toFixed(2)),
93
+ bodyTop: Number(bodyTop.toFixed(2)),
94
+ bodyH: Number(Math.max(minBody, bodyH).toFixed(2)),
95
+ up: point.v >= bar.o,
96
+ hollow: bodyH < minBody,
97
+ point,
98
+ }
99
+ }).filter(Boolean)
100
+ }
101
+
102
+ /**
103
+ * Snap a pointer position to the nearest data index.
104
+ *
105
+ * Charts are read by pointing at a shape, so the hover target is the nearest
106
+ * *point*, never the exact pixel: this is what makes a 250-bar chart usable with
107
+ * a mouse and what a touch device needs to hit anything at all.
108
+ *
109
+ * @param {number} pointerX - pointer x in svg coordinates.
110
+ * @param {number} count - number of points.
111
+ * @param {{ width: number, padding?: number, paddingX?: number }} geometry - chart geometry.
112
+ * @returns {number} nearest index, or -1 when there is nothing to snap to.
113
+ */
114
+ export function nearestIndex(pointerX, count, geometry) {
115
+ if (!Number.isFinite(pointerX) || !(count > 0)) return -1
116
+ const { padX, innerW } = box({ ...geometry, height: geometry.height ?? 1 })
117
+ if (count === 1) return 0
118
+ const ratio = clamp((pointerX - padX) / innerW, 0, 1)
119
+ return Math.round(ratio * (count - 1))
120
+ }
121
+
122
+ /**
123
+ * The x coordinate of one index inside the plotting box.
124
+ *
125
+ * @param {number} index - point index.
126
+ * @param {number} count - number of points.
127
+ * @param {{ width: number, padding?: number, paddingX?: number }} geometry - chart geometry.
128
+ * @returns {number} x in svg coordinates.
129
+ */
130
+ export function indexToX(index, count, geometry) {
131
+ const { padX, innerW } = box({ ...geometry, height: geometry.height ?? 1 })
132
+ if (count <= 1) return padX + innerW / 2
133
+ return Number((padX + (index / (count - 1)) * innerW).toFixed(2))
134
+ }
135
+
136
+ /**
137
+ * Build the crosshair and its labels for one hovered index.
138
+ *
139
+ * @param {object} input - input.
140
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} input.points - ascending points.
141
+ * @param {number} input.index - hovered index.
142
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, yDomain?: { min: number, max: number } }} input.geometry - chart geometry.
143
+ * @returns {{ x: number, y: number, index: number, point: object, bar: object|undefined, anchoredLeft: boolean }|undefined} crosshair state.
144
+ */
145
+ export function buildCrosshair({ points, index, geometry }) {
146
+ const list = points ?? []
147
+ if (index < 0 || index >= list.length) return undefined
148
+ const point = list[index]
149
+ const { innerW, padX, padY, innerH } = box({ ...geometry, height: geometry.height ?? 1 })
150
+ const bar = ohlcOf(point)
151
+ const domain = geometry.yDomain ?? valueDomain(list, { useExtremes: bar !== undefined })
152
+ const y = linearScale({ domain: domain === undefined ? [0, 1] : [domain.min, domain.max], range: [padY + innerH, padY] })
153
+ const x = indexToX(index, list.length, geometry)
154
+ return {
155
+ index,
156
+ x,
157
+ y: Number(y(point.v).toFixed(2)),
158
+ point,
159
+ bar,
160
+ // The tooltip flips side near the right edge so it never runs out of frame.
161
+ anchoredLeft: x > padX + innerW * 0.62,
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Horizontal reference lines worth drawing for a series.
167
+ *
168
+ * A reader compares the latest reading against the window, so the mean and the
169
+ * two extremes are the lines that carry information. Each one is emitted only
170
+ * when it is finite, and identical values collapse: a two-point series has a
171
+ * mean but a flat one has extremes equal to the last value, which would stack
172
+ * three labels on one pixel.
173
+ *
174
+ * @param {{ min?: number, max?: number, mean?: number }} [stats] - series statistics.
175
+ * @param {{ min: number, max: number }} domain - drawn domain.
176
+ * @param {{ plot: { x: number, y: number, w: number, h: number } }} layout - resolved plot box.
177
+ * @returns {Array<{ kind: string, label: string, value: number, y: number, x1: number, x2: number }>} reference lines.
178
+ */
179
+ export function buildReferenceLines(stats, domain, layout) {
180
+ if (stats === undefined || domain === undefined || layout?.plot === undefined) return []
181
+ const { x, w, y: top, h } = layout.plot
182
+ const y = linearScale({ domain: [domain.min, domain.max], range: [top + h, top] })
183
+ const candidates = [
184
+ { kind: 'max', value: stats.max, label: '高' },
185
+ { kind: 'mean', value: stats.mean, label: '均' },
186
+ { kind: 'min', value: stats.min, label: '低' },
187
+ ]
188
+ const out = []
189
+ const seen = new Set()
190
+ for (const candidate of candidates) {
191
+ if (!Number.isFinite(candidate.value)) continue
192
+ const key = candidate.value.toFixed(6)
193
+ if (seen.has(key)) continue
194
+ seen.add(key)
195
+ out.push({
196
+ kind: candidate.kind,
197
+ label: candidate.label,
198
+ value: candidate.value,
199
+ y: Number(y(candidate.value).toFixed(2)),
200
+ x1: x,
201
+ x2: x + w,
202
+ })
203
+ }
204
+ return out
205
+ }
206
+
207
+ /**
208
+ * A readable tick list for the value axis.
209
+ *
210
+ * @param {{ min: number, max: number }} domain - data domain.
211
+ * @param {number} [count] - desired ticks.
212
+ * @returns {{ min: number, max: number, ticks: number[] }} nice domain and ticks.
213
+ */
214
+ export function valueAxis(domain, count = 5) {
215
+ return niceTicks(domain.min, domain.max, count)
216
+ }
@@ -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
+ }