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.
- package/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- 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
|
+
}
|