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,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
|
+
}
|