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
package/lib/client.js ADDED
@@ -0,0 +1,4322 @@
1
+ // GENERATED FILE — do not edit. Source: src/client/** + shared core modules.
2
+ // Rebuild with: node scripts/build-client.mjs
3
+ window.__ModuleLoader__.load({
4
+ id: "dsh-plugin-show-me-data",
5
+ factory: (require) => {
6
+ const module = { exports: {} }
7
+ const exports = module.exports
8
+ const React = require('react')
9
+ // ── src/core/stats/series.js ─────────────────────────────────────────
10
+ /**
11
+ * Series statistics — pure functions over '{ t, v }[]' (docs/03 §2).
12
+ *
13
+ * Discipline (docs/07 M1): missing values are skipped, never interpolated and
14
+ * never treated as zero; small samples return 'undefined' rather than a
15
+ * fabricated σ or percentile; every returned number is finite or absent.
16
+ *
17
+ * @module core/stats/series
18
+ */
19
+
20
+ /** Markers every upstream uses for "no observation". */
21
+ const MISSING_MARKERS = new Set(['.', '', '-', 'null', 'none', 'n/a', 'na', 'nan', 'undefined'])
22
+
23
+ /** Minimum sample size before a sample standard deviation is meaningful. */
24
+ const MIN_STDDEV_N = 6
25
+
26
+ /** Minimum sample size before a percentile is meaningful. */
27
+ const MIN_PERCENTILE_N = 12
28
+
29
+ /**
30
+ * Parse an upstream scalar into a number, honouring every documented missing
31
+ * marker (docs/03 §2 rule 2, docs/04 §1).
32
+ *
33
+ * @param {unknown} raw - upstream value.
34
+ * @returns {number|undefined} finite number or 'undefined' when missing.
35
+ */
36
+ function parseNumber(raw) {
37
+ if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined
38
+ if (typeof raw !== 'string') return undefined
39
+ const trimmed = raw.trim()
40
+ if (MISSING_MARKERS.has(trimmed.toLowerCase())) return undefined
41
+ const cleaned = trimmed.replace(/,/g, '')
42
+ const value = Number(cleaned)
43
+ return Number.isFinite(value) ? value : undefined
44
+ }
45
+
46
+ /**
47
+ * Round to a fixed number of decimals, collapsing float noise
48
+ * ('115.004000000000005' → '115.004').
49
+ *
50
+ * @param {number|undefined} value - candidate value.
51
+ * @param {number} [decimals] - decimal places; omitted means unchanged.
52
+ * @returns {number|undefined} rounded value or 'undefined'.
53
+ */
54
+ function round(value, decimals) {
55
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
56
+ if (decimals === undefined || decimals === null) return value
57
+ const factor = 10 ** decimals
58
+ const scaled = Math.round((value + Number.EPSILON * Math.sign(value)) * factor) / factor
59
+ return Object.is(scaled, -0) ? 0 : scaled
60
+ }
61
+
62
+ /**
63
+ * Sort ascending by date, drop non-finite values, and keep the **last** entry
64
+ * for a repeated date (upstream revisions win, docs/03 §2 rule 1).
65
+ *
66
+ * 'v' is always the series' primary value. A price series may additionally carry
67
+ * an OHLC bar (`o`/`h`/`l`, with 'v' as the close), which the chart layer draws as
68
+ * a candle; those extras travel through every transform untouched, so a candle
69
+ * view never re-fetches or re-derives what the source already returned.
70
+ *
71
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} points - arbitrary points.
72
+ * @returns {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} normalized points.
73
+ */
74
+ function sortDedupe(points) {
75
+ const byDate = new Map()
76
+ for (const point of points) {
77
+ if (point === null || typeof point !== 'object') continue
78
+ if (typeof point.t !== 'string' || !Number.isFinite(point.v)) continue
79
+ const normalized = { t: point.t, v: point.v }
80
+ const bar = ohlcOf(point)
81
+ byDate.set(point.t, bar === undefined ? normalized : { ...normalized, ...bar })
82
+ }
83
+ return [...byDate.values()].sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
84
+ }
85
+
86
+ /**
87
+ * The OHLC extras of one point, when the bar is complete and coherent.
88
+ *
89
+ * A bar is only worth drawing if open, high and low are all finite and the
90
+ * high/low really do bound them: anything else would render an impossible
91
+ * candle, so the extras are dropped and the point stays a plain observation
92
+ * (the chart draws it as part of the line instead).
93
+ *
94
+ * @param {{ v?: number, o?: unknown, h?: unknown, l?: unknown }} [point] - candidate point.
95
+ * @returns {{ o: number, h: number, l: number }|undefined} the bar, or 'undefined'.
96
+ */
97
+ function ohlcOf(point) {
98
+ const { o, h, l } = point ?? {}
99
+ if (!Number.isFinite(o) || !Number.isFinite(h) || !Number.isFinite(l)) return undefined
100
+ const v = point.v
101
+ if (!Number.isFinite(v)) return undefined
102
+ if (h < Math.max(o, v) || l > Math.min(o, v)) return undefined
103
+ return { o, h, l }
104
+ }
105
+
106
+ /**
107
+ * Whether a series carries enough usable bars for a candlestick view.
108
+ *
109
+ * @param {Array<{ o?: number }>} [points] - points.
110
+ * @param {number} [minimum] - bars required before candles are offered.
111
+ * @returns {boolean} whether candles can be drawn.
112
+ */
113
+ function hasOhlc(points, minimum = 2) {
114
+ return (points ?? []).filter((point) => ohlcOf(point) !== undefined).length >= minimum
115
+ }
116
+
117
+ /**
118
+ * Period-over-period change against the previous existing observation
119
+ * (docs/03 §2 rule 4).
120
+ *
121
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
122
+ * @returns {{ abs: number, pct: number }|undefined} change, or 'undefined' when undefined/zero-based.
123
+ */
124
+ function mom(points) {
125
+ if (points.length < 2) return undefined
126
+ const latest = points[points.length - 1]
127
+ const previous = points[points.length - 2]
128
+ if (previous.v === 0) return undefined
129
+ return { abs: latest.v - previous.v, pct: ((latest.v - previous.v) / Math.abs(previous.v)) * 100 }
130
+ }
131
+
132
+ /**
133
+ * Year-over-year change with same-calendar-period alignment (docs/03 §2 rule 3).
134
+ *
135
+ * The base is the observation one year (12 months) earlier for monthly and
136
+ * coarser series, or one quarter earlier for series dated on quarter ends.
137
+ * A ±1 month drift is tolerated and reported so downstream code can flag an
138
+ * approximate comparison; anything further is treated as "no base".
139
+ *
140
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
141
+ * @param {{ toleranceMonths?: number }} [options] - alignment tolerance.
142
+ * @returns {{ value: number, base: number, baseAt: string, drift: number }|undefined} yoy result.
143
+ */
144
+ function yoy(points, { toleranceMonths = 1 } = {}) {
145
+ if (points.length < 2) return undefined
146
+ const latest = points[points.length - 1]
147
+ // Quarterly series are dated on quarter ends (03-31, 06-30, 09-30, 12-31).
148
+ // Their annual base is four quarters back — which is the same month one year
149
+ // earlier, so the target is a full 12 months for every frequency.
150
+ const targetMonths = 12
151
+
152
+ let best
153
+ for (const candidate of points) {
154
+ if (candidate.t >= latest.t) continue
155
+ const distance = monthsBetween(candidate.t, latest.t)
156
+ const drift = distance - targetMonths
157
+ if (Math.abs(drift) > toleranceMonths) continue
158
+ if (best === undefined || Math.abs(drift) < Math.abs(best.drift)) best = { candidate, drift }
159
+ }
160
+ if (best === undefined) return undefined
161
+ const base = best.candidate.v
162
+ if (base === 0) return undefined
163
+ return {
164
+ value: ((latest.v - base) / Math.abs(base)) * 100,
165
+ base,
166
+ baseAt: best.candidate.t,
167
+ drift: best.drift,
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Moving average over 'window' observations. The first 'window - 1' outputs are
173
+ * 'undefined' — a partial mean would misrepresent the window (docs/07 T1.2).
174
+ *
175
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
176
+ * @param {number} window - window size (>= 1).
177
+ * @returns {Array<{ t: string, v: number|undefined }>} moving average series.
178
+ */
179
+ function movingAverage(points, window) {
180
+ if (!Number.isInteger(window) || window < 1) {
181
+ throw new RangeError(`movingAverage: window must be a positive integer (got ${window})`)
182
+ }
183
+ return points.map((point, index) => {
184
+ if (index < window - 1) return { t: point.t, v: undefined }
185
+ let sum = 0
186
+ for (let i = index - window + 1; i <= index; i += 1) sum += points[i].v
187
+ return { t: point.t, v: sum / window }
188
+ })
189
+ }
190
+
191
+ /**
192
+ * Difference against the value 'window' observations back: 'v[i] - v[i-window]'.
193
+ *
194
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
195
+ * @param {number} [window] - lookback in observations (default 1).
196
+ * @returns {Array<{ t: string, v: number|undefined }>} differenced series.
197
+ */
198
+ function diff(points, window = 1) {
199
+ if (!Number.isInteger(window) || window < 1) {
200
+ throw new RangeError(`diff: window must be a positive integer (got ${window})`)
201
+ }
202
+ return points.map((point, index) =>
203
+ index < window ? { t: point.t, v: undefined } : { t: point.t, v: point.v - points[index - window].v },
204
+ )
205
+ }
206
+
207
+ /**
208
+ * Sample standard deviation (n−1). Below {@link MIN_STDDEV_N} observations this
209
+ * is 'undefined': the panel must not present "2σ" computed from three points.
210
+ *
211
+ * @param {number[]} values - observations.
212
+ * @returns {number|undefined} standard deviation.
213
+ */
214
+ function stdDev(values) {
215
+ if (values.length < MIN_STDDEV_N) return undefined
216
+ const mean = values.reduce((a, b) => a + b, 0) / values.length
217
+ const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / (values.length - 1)
218
+ return Math.sqrt(variance)
219
+ }
220
+
221
+ /**
222
+ * Linear-interpolated percentile. Below {@link MIN_PERCENTILE_N} observations
223
+ * this is 'undefined' (docs/07 T1.2).
224
+ *
225
+ * @param {number[]} values - observations.
226
+ * @param {number} p - quantile in '[0, 1]'.
227
+ * @returns {number|undefined} percentile value.
228
+ */
229
+ function percentile(values, p) {
230
+ if (values.length < MIN_PERCENTILE_N) return undefined
231
+ if (!(p >= 0 && p <= 1)) throw new RangeError(`percentile: p must be in [0,1] (got ${p})`)
232
+ const sorted = [...values].sort((a, b) => a - b)
233
+ const position = (sorted.length - 1) * p
234
+ const lower = Math.floor(position)
235
+ const upper = Math.ceil(position)
236
+ if (lower === upper) return sorted[lower]
237
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower)
238
+ }
239
+
240
+ /**
241
+ * Least-squares slope against the observation index, in units per period.
242
+ *
243
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
244
+ * @returns {number|undefined} slope per observation.
245
+ */
246
+ function slope(points) {
247
+ if (points.length < 2) return undefined
248
+ const n = points.length
249
+ const meanX = (n - 1) / 2
250
+ const meanY = points.reduce((acc, p) => acc + p.v, 0) / n
251
+ let numerator = 0
252
+ let denominator = 0
253
+ for (let i = 0; i < n; i += 1) {
254
+ numerator += (i - meanX) * (points[i].v - meanY)
255
+ denominator += (i - meanX) ** 2
256
+ }
257
+ if (denominator === 0) return undefined
258
+ return numerator / denominator
259
+ }
260
+
261
+ /**
262
+ * z-score of the latest change against the history of changes
263
+ * (docs/03 §2 rule 8). Zero variance yields 'undefined' rather than Infinity.
264
+ *
265
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
266
+ * @returns {number|undefined} z-score.
267
+ */
268
+ function zScoreLatestChange(points) {
269
+ const changes = []
270
+ for (let i = 1; i < points.length; i += 1) changes.push(points[i].v - points[i - 1].v)
271
+ if (changes.length < MIN_STDDEV_N - 1) return undefined
272
+ const latestChange = changes[changes.length - 1]
273
+ const history = changes.slice(0, -1)
274
+ const sigma = stdDev(history)
275
+ if (sigma === undefined || sigma === 0) return undefined
276
+ const mean = history.reduce((a, b) => a + b, 0) / history.length
277
+ return (latestChange - mean) / sigma
278
+ }
279
+
280
+ /**
281
+ * Count observations a fixed-cadence series should have had but does not
282
+ * (docs/07 T1.2).
283
+ *
284
+ * With an explicit 'cadenceDays' (the caller knows the indicator's frequency)
285
+ * the count is exact. Without one, cadence is inferred as the median gap of the
286
+ * observed series — which cannot see a series that published every other month
287
+ * consistently, and never invents a trading calendar (docs/03 §2 rule 12).
288
+ *
289
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
290
+ * @param {{ cadenceDays?: number }} [options] - expected cadence.
291
+ * @returns {number} number of missing slots.
292
+ */
293
+ function missingCount(points, { cadenceDays } = {}) {
294
+ if (points.length < 2) return 0
295
+ const gaps = []
296
+ for (let i = 1; i < points.length; i += 1) gaps.push(daysBetween(points[i - 1].t, points[i].t))
297
+ const sorted = [...gaps].sort((a, b) => a - b)
298
+ const inferred = sorted[Math.floor(sorted.length / 2)]
299
+ const cadence = cadenceDays ?? inferred
300
+ if (!(cadence > 0)) return 0
301
+ let missing = 0
302
+ for (const gap of gaps) missing += Math.max(0, Math.round(gap / cadence) - 1)
303
+ return missing
304
+ }
305
+
306
+ /** Nominal days between observations, per frequency (documentation-only cadence). */
307
+ const FREQ_CADENCE_DAYS = {
308
+ daily: 1,
309
+ weekly: 7,
310
+ monthly: 31,
311
+ quarterly: 92,
312
+ annual: 366,
313
+ }
314
+
315
+ /**
316
+ * Assemble the 'SeriesStats' contract (docs/03 §1.4).
317
+ *
318
+ * @param {Array<{ t: string, v: number }>} input - points, any order.
319
+ * @param {{ decimals?: number, freq?: string }} [options] - rounding and frequency (for gap counting).
320
+ * @returns {object|undefined} stats object, or 'undefined' for an empty series.
321
+ */
322
+ function stats(input, { decimals, freq } = {}) {
323
+ const points = sortDedupe(input)
324
+ if (points.length === 0) return undefined
325
+
326
+ const latest = points[points.length - 1]
327
+ const prevPoint = points.length > 1 ? points[points.length - 2] : undefined
328
+ const values = points.map((p) => p.v)
329
+ const yoyResult = yoy(points)
330
+ const momResult = mom(points)
331
+ const sigma = stdDev(values)
332
+ // Both change fields are derived from the SAME difference and rounded to one
333
+ // decimal more than the reading itself, so they cannot contradict each other.
334
+ // Rounding `abs(latest - prev)` at display precision made a 0.05pp move show as
335
+ // `changeAbs: 0.0` beside `changePct: 1.5`, and an agent reading the panel
336
+ // reported that contradiction as a panel bug.
337
+ const changeDecimals = decimals === undefined ? 4 : decimals + 1
338
+ const change = prevPoint === undefined ? undefined : latest.v - prevPoint.v
339
+
340
+ const result = {
341
+ latest: round(latest.v, decimals),
342
+ latestAt: latest.t,
343
+ ...(prevPoint === undefined
344
+ ? {}
345
+ : {
346
+ prev: round(prevPoint.v, decimals),
347
+ changeAbs: round(change, changeDecimals),
348
+ ...(prevPoint.v === 0
349
+ ? {}
350
+ : { changePct: round((change / Math.abs(prevPoint.v)) * 100, decimals) }),
351
+ }),
352
+ ...(yoyResult === undefined ? {} : { yoy: round(yoyResult.value, decimals), yoyAlignedDrift: yoyResult.drift }),
353
+ ...(momResult === undefined ? {} : { mom: round(momResult.pct, decimals) }),
354
+ mean: round(values.reduce((a, b) => a + b, 0) / values.length, decimals),
355
+ min: round(Math.min(...values), decimals),
356
+ max: round(Math.max(...values), decimals),
357
+ stdDev: sigma === undefined ? 0 : round(sigma, decimals),
358
+ count: points.length,
359
+ missingCount: missingCount(points, { cadenceDays: FREQ_CADENCE_DAYS[freq] }),
360
+ }
361
+
362
+ const z = zScoreLatestChange(points)
363
+ if (z !== undefined) result.zScoreLatestChange = round(z, decimals)
364
+ const fitted = slope(points)
365
+ if (fitted !== undefined) {
366
+ result.slope = round(fitted, decimals)
367
+ const mean = values.reduce((a, b) => a + b, 0) / values.length
368
+ if (mean !== 0) result.slopeRel = round(fitted / mean, decimals)
369
+ }
370
+ const latestPercentile = percentileRank(values, latest.v)
371
+ if (latestPercentile !== undefined) result.percentile = latestPercentile
372
+
373
+ return result
374
+ }
375
+
376
+ /**
377
+ * Fraction of observations at or below 'value', linearly interpolated between
378
+ * the two bracketing ranks. 'undefined' below the percentile sample floor.
379
+ *
380
+ * @param {number[]} values - observations.
381
+ * @param {number} value - the value to rank.
382
+ * @returns {number|undefined} percentile in '[0, 1]'.
383
+ */
384
+ function percentileRank(values, value) {
385
+ if (values.length < MIN_PERCENTILE_N) return undefined
386
+ const sorted = [...values].sort((a, b) => a - b)
387
+ if (value <= sorted[0]) return 0
388
+ if (value >= sorted[sorted.length - 1]) return 1
389
+ let below = 0
390
+ for (const v of sorted) if (v <= value) below += 1
391
+ const rank = (below - 1) / (sorted.length - 1)
392
+ return Math.min(1, Math.max(0, rank))
393
+ }
394
+
395
+ /**
396
+ * Apply a catalog display transform to a point series (docs/03 §2 rules 5–7).
397
+ *
398
+ * Transform chains are expressed as 'display.transform' plus optional
399
+ * 'display.window'/'display.movingAvg':
400
+ *
401
+ * - 'raw' — unchanged;
402
+ * - 'diff' — 'v[i] - v[i-window]' (levels to changes);
403
+ * - 'pctChange' / 'mom' — percentage change against the previous observation;
404
+ * - 'yoy' — same-calendar-period percentage change;
405
+ * - 'annualize' — monthly × 12, quarterly × 4 (levels only).
406
+ *
407
+ * A trailing moving average ('movingAvg') is applied **after** the transform and
408
+ * drops the first 'window - 1' points, so a 3-month average of monthly changes
409
+ * never shows a partial window.
410
+ *
411
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
412
+ * @param {object} display - catalog display spec.
413
+ * @returns {Array<{ t: string, v: number }>} transformed points (may be empty).
414
+ */
415
+ function applyTransform(points, display = {}) {
416
+ const base = sortDedupe(points)
417
+ const transform = display.transform ?? 'raw'
418
+ let out
419
+ switch (transform) {
420
+ case 'raw':
421
+ out = base
422
+ break
423
+ case 'diff':
424
+ out = diff(base, display.window ?? 1).filter((p) => p.v !== undefined)
425
+ break
426
+ case 'pctChange':
427
+ case 'mom':
428
+ out = base
429
+ .map((point, index) => {
430
+ if (index === 0) return undefined
431
+ const previous = base[index - 1]
432
+ if (previous.v === 0) return undefined
433
+ return { t: point.t, v: ((point.v - previous.v) / Math.abs(previous.v)) * 100 }
434
+ })
435
+ .filter(Boolean)
436
+ break
437
+ case 'yoy':
438
+ out = base
439
+ .map((point, index) => {
440
+ // Recompute yoy for every point, not only the last one.
441
+ const result = yoy(base.slice(0, index + 1))
442
+ return result === undefined ? undefined : { t: point.t, v: result.value }
443
+ })
444
+ .filter(Boolean)
445
+ break
446
+ case 'annualize':
447
+ out = base.map((point) => ({ t: point.t, v: point.v * (display.freq === 'quarterly' ? 4 : 12) }))
448
+ break
449
+ case 'ratio':
450
+ out = base
451
+ break
452
+ default:
453
+ throw new RangeError(`unknown display.transform: ${JSON.stringify(transform)}`)
454
+ }
455
+ if (display.movingAvg !== undefined) {
456
+ out = movingAverage(out, display.movingAvg).filter((p) => p.v !== undefined)
457
+ }
458
+ return out
459
+ }
460
+
461
+ /**
462
+ * Describe a transform's unit suffix, so the UI can label "(年化)" or a 3-month
463
+ * average without re-deriving it (docs/03 §2 rule 7).
464
+ *
465
+ * @param {object} display - catalog display spec.
466
+ * @returns {string} unit suffix ('' when none).
467
+ */
468
+ function transformSuffix(display = {}) {
469
+ const parts = []
470
+ if (display.transform === 'annualize') parts.push('年化')
471
+ if (display.movingAvg !== undefined) parts.push(`${display.movingAvg}期均值`)
472
+ return parts.length === 0 ? '' : ` (${parts.join('·')})`
473
+ }
474
+
475
+ // ── src/core/chart/scale.js ─────────────────────────────────────────
476
+ /**
477
+ * SVG path geometry for the panel's charts (docs/05 §4.1).
478
+ *
479
+ * Everything here is a pure function of numbers to strings/arrays, so the charts
480
+ * are unit-testable without a browser — that is the whole reason the panel draws
481
+ * its own SVG instead of pulling in a chart library (docs/02 §1.1).
482
+ *
483
+ * @module core/chart/scale
484
+ */
485
+
486
+ /**
487
+ * Clamp a number into a closed range.
488
+ *
489
+ * @param {number} value - candidate.
490
+ * @param {number} min - lower bound.
491
+ * @param {number} max - upper bound.
492
+ * @returns {number} clamped value.
493
+ */
494
+ function clamp(value, min, max) {
495
+ return Math.min(Math.max(value, min), max)
496
+ }
497
+
498
+ /**
499
+ * Build a linear scale from a data domain to a pixel range.
500
+ *
501
+ * A degenerate domain ('min === max', or any non-finite bound) maps every value
502
+ * to the middle of the range instead of dividing by zero — the branch that turns
503
+ * a flat series into 'NaN' paths (docs/08 §2 defect 5).
504
+ *
505
+ * @param {{ domain: [number, number], range: [number, number] }} spec - scale spec.
506
+ * @returns {(value: number) => number} scale function.
507
+ */
508
+ function linearScale({ domain, range }) {
509
+ const [d0, d1] = domain
510
+ const [r0, r1] = range
511
+ if (!Number.isFinite(d0) || !Number.isFinite(d1) || d0 === d1) {
512
+ const middle = (r0 + r1) / 2
513
+ return () => middle
514
+ }
515
+ const factor = (r1 - r0) / (d1 - d0)
516
+ return (value) => r0 + (value - d0) * factor
517
+ }
518
+
519
+ /**
520
+ * Extend a domain to the nearest "nice" bounds and pick evenly spaced ticks
521
+ * built from 1/2/5 × 10ⁿ steps (docs/05 §4.1).
522
+ *
523
+ * @param {number} min - data minimum.
524
+ * @param {number} max - data maximum.
525
+ * @param {number} [count] - desired tick count (approximate).
526
+ * @returns {{ min: number, max: number, ticks: number[] }} nice domain and ticks.
527
+ */
528
+ function niceTicks(min, max, count = 5) {
529
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
530
+ return { min: 0, max: 1, ticks: [0, 0.5, 1] }
531
+ }
532
+ if (min === max) {
533
+ const pad = Math.abs(min) > 0 ? Math.abs(min) * 0.1 : 1
534
+ const lo = min - pad
535
+ const hi = max + pad
536
+ return { min: lo, max: hi, ticks: [lo, (lo + hi) / 2, hi] }
537
+ }
538
+ const steps = Math.max(1, Math.floor(count))
539
+ const rawStep = (max - min) / steps
540
+ const magnitude = 10 ** Math.floor(Math.log10(rawStep))
541
+ const normalized = rawStep / magnitude
542
+ const niceStep = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude
543
+ const lo = Math.floor(min / niceStep) * niceStep
544
+ const hi = Math.ceil(max / niceStep) * niceStep
545
+ const ticks = []
546
+ for (let value = lo; value <= hi + niceStep / 2; value += niceStep) {
547
+ // Round away accumulated float noise (0.30000000000000004 → 0.3).
548
+ ticks.push(Number(value.toFixed(12)))
549
+ }
550
+ return { min: lo, max: hi, ticks }
551
+ }
552
+
553
+ /**
554
+ * Min/max of a numeric list, ignoring non-finite entries.
555
+ *
556
+ * @param {number[]} values - candidate values.
557
+ * @returns {{ min: number, max: number }|undefined} extent, or 'undefined' when nothing is finite.
558
+ */
559
+ function extent(values) {
560
+ let min = Number.POSITIVE_INFINITY
561
+ let max = Number.NEGATIVE_INFINITY
562
+ let seen = false
563
+ for (const value of values) {
564
+ if (!Number.isFinite(value)) continue
565
+ seen = true
566
+ if (value < min) min = value
567
+ if (value > max) max = value
568
+ }
569
+ return seen ? { min, max } : undefined
570
+ }
571
+
572
+ /**
573
+ * Reduce a series to at most two points per pixel column, preserving the first
574
+ * and last points plus each bucket's extremes so the shape is not flattened
575
+ * (docs/05 §4.1: 10k points must not produce 10k path segments).
576
+ *
577
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
578
+ * @param {number} maxPoints - budget.
579
+ * @returns {Array<{ t: string, v: number }>} thinned points.
580
+ */
581
+ function thin(points, maxPoints) {
582
+ if (points.length <= maxPoints) return points
583
+ const finite = points.filter((p) => Number.isFinite(p.v))
584
+ if (finite.length <= maxPoints) return points
585
+ const bucketCount = Math.max(1, Math.floor(maxPoints / 2))
586
+ const bucketSize = finite.length / bucketCount
587
+ const out = []
588
+ for (let bucket = 0; bucket < bucketCount; bucket += 1) {
589
+ const start = Math.floor(bucket * bucketSize)
590
+ const end = Math.min(finite.length, Math.floor((bucket + 1) * bucketSize))
591
+ if (end <= start) continue
592
+ let lowest = finite[start]
593
+ let highest = finite[start]
594
+ for (let i = start; i < end; i += 1) {
595
+ if (finite[i].v < lowest.v) lowest = finite[i]
596
+ if (finite[i].v > highest.v) highest = finite[i]
597
+ }
598
+ const pair = lowest.t <= highest.t ? [lowest, highest] : [highest, lowest]
599
+ for (const point of pair) {
600
+ if (out.length === 0 || out[out.length - 1].t !== point.t) out.push(point)
601
+ }
602
+ }
603
+ // Always keep the true endpoints so the line spans the full width.
604
+ const last = finite[finite.length - 1]
605
+ if (out.length === 0 || out[out.length - 1].t !== last.t) out.push(last)
606
+ return out
607
+ }
608
+
609
+ // ── src/core/chart/line.js ─────────────────────────────────────────
610
+ /**
611
+ * Line, area and sparkline path builders (docs/05 §4.1).
612
+ *
613
+ * @module core/chart/line
614
+ */
615
+
616
+ /**
617
+ * @typedef {Object} ChartGeometry
618
+ * @property {number} width - svg width in px.
619
+ * @property {number} height - svg height in px.
620
+ * @property {number} [padding] - uniform inset, or use 'paddingX'/'paddingY'.
621
+ * @property {number} [paddingX] - horizontal inset.
622
+ * @property {number} [paddingY] - vertical inset.
623
+ * @property {number} [padLeft] - left inset (wins over 'paddingX').
624
+ * @property {number} [padRight] - right inset (the value axis lives here).
625
+ * @property {number} [padTop] - top inset.
626
+ * @property {number} [padBottom] - bottom inset (the time axis lives here).
627
+ * @property {number} [innerW] - explicit inner width, for index-based builders.
628
+ * @property {number} [innerH] - explicit inner height.
629
+ * @property {number} [padX] - explicit left inset (already resolved).
630
+ * @property {{ min: number, max: number }} [yDomain] - explicit y domain.
631
+ * @property {number} [maxPoints] - point budget before thinning.
632
+ */
633
+
634
+ /**
635
+ * Resolve padding and plotting box from the geometry spec.
636
+ *
637
+ * @param {ChartGeometry} geometry - chart geometry.
638
+ * @returns {{ width: number, height: number, padX: number, padY: number, innerW: number, innerH: number }} box.
639
+ */
640
+ function box(geometry) {
641
+ const { width, height } = geometry
642
+ if (!(width > 0) || !(height > 0)) {
643
+ throw new Error(`chart geometry requires positive width/height (got ${width}x${height})`)
644
+ }
645
+ // Asymmetric gutters matter: the value axis is read on the right and the time
646
+ // axis along the bottom, so a uniform inset either clips labels or wastes the
647
+ // left edge. Explicit 'padX'/'padY'/'innerW'/'innerH' still win, because the
648
+ // chart component resolves the plot box once and passes it down.
649
+ const padX = geometry.padX ?? geometry.padLeft ?? geometry.paddingX ?? geometry.padding ?? 0
650
+ const padY = geometry.padY ?? geometry.padTop ?? geometry.paddingY ?? geometry.padding ?? 0
651
+ const padRight = geometry.padRight ?? geometry.paddingX ?? geometry.padding ?? padX
652
+ const padBottom = geometry.padBottom ?? geometry.paddingY ?? geometry.padding ?? padY
653
+ return {
654
+ width,
655
+ height,
656
+ padX,
657
+ padY,
658
+ padRight,
659
+ padBottom,
660
+ innerW: geometry.innerW ?? Math.max(1, width - padX - padRight),
661
+ innerH: geometry.innerH ?? Math.max(1, height - padY - padBottom),
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Format a path coordinate: two decimals is sub-pixel on any display and keeps
667
+ * the generated path strings small.
668
+ *
669
+ * @param {number} value - coordinate.
670
+ * @returns {string} formatted coordinate.
671
+ */
672
+ function coord(value) {
673
+ const rounded = Math.round(value * 100) / 100
674
+ return String(Object.is(rounded, -0) ? 0 : rounded)
675
+ }
676
+
677
+ /**
678
+ * Build the list of straight (non-NaN) runs in a point list. A 'NaN' value is a
679
+ * genuine break — the series is drawn as two sub-paths, never bridged
680
+ * (docs/05 §4.1).
681
+ *
682
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
683
+ * @returns {Array<Array<{ t: string, v: number }>>} runs.
684
+ */
685
+ function runs(points) {
686
+ const out = []
687
+ let current = []
688
+ for (const point of points) {
689
+ if (Number.isFinite(point?.v)) {
690
+ current.push(point)
691
+ } else if (current.length > 0) {
692
+ out.push(current)
693
+ current = []
694
+ }
695
+ }
696
+ if (current.length > 0) out.push(current)
697
+ return out
698
+ }
699
+
700
+ /**
701
+ * Build an SVG path for a line chart.
702
+ *
703
+ * - '[]' → ''''
704
+ * - one point → ''M x y'' (a move only; no 'L')
705
+ * - a flat series → a horizontal line at the vertical centre
706
+ *
707
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
708
+ * @param {ChartGeometry} geometry - chart geometry.
709
+ * @returns {string} SVG path data.
710
+ */
711
+ function buildLinePath(points, geometry) {
712
+ if (!Array.isArray(points) || points.length === 0) return ''
713
+ const { innerW, innerH, padX, padY } = box(geometry)
714
+ if (!points.some((p) => Number.isFinite(p?.v))) return ''
715
+
716
+ const budget = geometry.maxPoints ?? Number.POSITIVE_INFINITY
717
+ // The x axis is always proportional to the *declared* index span, so a `NaN`
718
+ // hole keeps its width instead of compressing the visible series. Thinning
719
+ // (only for huge series) collapses missing entries, which is acceptable at
720
+ // that scale.
721
+ const thinFirst = points.map((p, index) => ({ t: p.t, v: Number.isFinite(p?.v) ? p.v : Number.NaN, i: index }))
722
+ const drawn = Number.isFinite(budget) && points.length > budget
723
+ ? thin(thinFirst, budget)
724
+ : thinFirst
725
+ const finite = thinFirst.filter((p) => Number.isFinite(p.v))
726
+ if (finite.length === 0) return ''
727
+ const xSpan = Math.max(1, points.length - 1)
728
+ const x = (index) => padX + (index / xSpan) * innerW
729
+
730
+ const domain = extent(finite.map((p) => p.v))
731
+ const yDomain = geometry.yDomain ?? domain
732
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
733
+
734
+ const parts = []
735
+ for (const run of runs(drawn)) {
736
+ run.forEach((point, runIndex) => {
737
+ const command = runIndex === 0 ? 'M' : 'L'
738
+ parts.push(`${command}${coord(x(point.i))} ${coord(y(point.v))}`)
739
+ })
740
+ }
741
+ return parts.join('')
742
+ }
743
+
744
+ /**
745
+ * Build a closed area path: the line plus a drop to the baseline.
746
+ *
747
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
748
+ * @param {ChartGeometry} geometry - chart geometry.
749
+ * @returns {string} SVG path data ('''' when there is nothing to draw).
750
+ */
751
+ function buildAreaPath(points, geometry) {
752
+ const line = buildLinePath(points, geometry)
753
+ if (line === '') return ''
754
+ const { innerH, padY } = box(geometry)
755
+ const baseline = padY + innerH
756
+ const coordinates = line.match(/[ML]([\d.-]+) ([\d.-]+)/g) ?? []
757
+ if (coordinates.length === 0) return ''
758
+ const first = coordinates[0].slice(1).split(' ')[0]
759
+ const last = coordinates[coordinates.length - 1].slice(1).split(' ')[0]
760
+ return `${line}L${last} ${coord(baseline)}L${first} ${coord(baseline)}Z`
761
+ }
762
+
763
+ /**
764
+ * Build a sparkline path (no axes, no grid). A single point yields '''' so the
765
+ * card can draw a dot instead of a degenerate path — frozen by tests.
766
+ *
767
+ * @param {number[]} values - values, oldest first.
768
+ * @param {{ width: number, height: number, padding?: number }} geometry - sparkline geometry.
769
+ * @returns {string} SVG path data.
770
+ */
771
+ function buildSparkline(values, geometry) {
772
+ if (!Array.isArray(values) || values.length < 2) return ''
773
+ const points = values.map((v, i) => ({ t: String(i), v }))
774
+ return buildLinePath(points, { width: geometry.width, height: geometry.height, padding: geometry.padding ?? 1 })
775
+ }
776
+
777
+ /**
778
+ * Build the SVG markup for a sparkline, including the end-point dot. Returns
779
+ * '''' when there is nothing to draw, which the card renders as a placeholder.
780
+ *
781
+ * @param {number[]} values - values, oldest first.
782
+ * @param {{ width: number, height: number, padding?: number, dotRadius?: number }} geometry - sparkline geometry.
783
+ * @returns {{ path: string, dot: { cx: number, cy: number, r: number }|null }} path and end dot.
784
+ */
785
+ function buildSparklineShape(values, geometry) {
786
+ const path = buildSparkline(values, geometry)
787
+ if (path === '') return { path: '', dot: null }
788
+ const matches = [...path.matchAll(/[ML]([\d.-]+) ([\d.-]+)/g)]
789
+ const last = matches[matches.length - 1]
790
+ return {
791
+ path,
792
+ dot: { cx: Number(last[1]), cy: Number(last[2]), r: geometry.dotRadius ?? 1.5 },
793
+ }
794
+ }
795
+
796
+ // ── src/core/chart/candle.js ─────────────────────────────────────────
797
+ /**
798
+ * Candlestick geometry and hover snapping (docs/05 §4).
799
+ *
800
+ * The panel draws its own SVG, so a candle is four numbers turned into two
801
+ * screen-space primitives: a thin high-low wick and a body spanning open to
802
+ * close. Everything here is a pure function of points and geometry, which keeps
803
+ * the interactive chart testable without a browser.
804
+ *
805
+ * Colour is deliberately *not* decided here: the caller owns polarity, and the
806
+ * only rule this layer encodes is the arithmetic — a candle is up when the close
807
+ * is at or above the open.
808
+ *
809
+ * @module core/chart/candle
810
+ */
811
+
812
+
813
+
814
+ /** Default share of one x-step a candle body may occupy. */
815
+ const BODY_RATIO = 0.7
816
+
817
+ /**
818
+ * Widest a candle body may be drawn, in px.
819
+ *
820
+ * With few points the step is enormous, and a one-step-wide bar reads as a
821
+ * block rather than a bar; capping the width keeps three points looking like
822
+ * three candles.
823
+ */
824
+ const MAX_BODY_PX = 16
825
+
826
+ /**
827
+ * The value domain a chart should cover.
828
+ *
829
+ * A candlestick view must span the wicks, not only the closes: using the close
830
+ * extent would clip highs and lows at the top and bottom of the plot.
831
+ *
832
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} points - ascending points.
833
+ * @param {{ useExtremes?: boolean }} [options] - options.
834
+ * @returns {{ min: number, max: number }|undefined} domain, or 'undefined' when nothing is finite.
835
+ */
836
+ function valueDomain(points, options = {}) {
837
+ const values = []
838
+ for (const point of points ?? []) {
839
+ if (!Number.isFinite(point?.v)) continue
840
+ const bar = options.useExtremes === true ? ohlcOf(point) : undefined
841
+ values.push(bar === undefined ? point.v : bar.h, bar === undefined ? point.v : bar.l)
842
+ }
843
+ return extent(values)
844
+ }
845
+
846
+ /**
847
+ * Build candle geometry for a point list.
848
+ *
849
+ * Every entry carries the numbers the renderer needs and nothing else: `up` is
850
+ * the arithmetic fact (close >= open) that the caller maps to a colour, and
851
+ * `hollow` marks a bar whose body is too short to fill, which is how a flat
852
+ * session still shows as a visible tick rather than disappearing.
853
+ *
854
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} points - ascending points.
855
+ * @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.
856
+ * @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.
857
+ */
858
+ function buildCandles(points, geometry) {
859
+ const list = points ?? []
860
+ const bars = list.filter((point) => ohlcOf(point) !== undefined)
861
+ if (bars.length === 0) return []
862
+ const { innerW, padX, padY, innerH } = box({ ...geometry, height: geometry.height })
863
+ const domain = geometry.yDomain ?? valueDomain(list, { useExtremes: true })
864
+ if (domain === undefined) return []
865
+ const y = linearScale({ domain: [domain.min, domain.max], range: [padY + innerH, padY] })
866
+ const step = list.length > 1 ? innerW / (list.length - 1) : innerW
867
+ const bodyW = Math.max(1, Math.min(geometry.maxBody ?? MAX_BODY_PX, step * (geometry.bodyRatio ?? BODY_RATIO)))
868
+ const minBody = geometry.minBody ?? 1
869
+ return list.map((point, index) => {
870
+ const bar = ohlcOf(point)
871
+ if (bar === undefined) return undefined
872
+ const x = padX + (list.length > 1 ? (index / (list.length - 1)) * innerW : innerW / 2)
873
+ const yOpen = y(bar.o)
874
+ const yClose = y(point.v)
875
+ const yHigh = y(bar.h)
876
+ const yLow = y(bar.l)
877
+ const bodyTop = Math.min(yOpen, yClose)
878
+ const bodyH = Math.abs(yClose - yOpen)
879
+ return {
880
+ t: point.t,
881
+ x: Number(x.toFixed(2)),
882
+ bodyW: Number(bodyW.toFixed(2)),
883
+ yHigh: Number(yHigh.toFixed(2)),
884
+ yLow: Number(yLow.toFixed(2)),
885
+ yOpen: Number(yOpen.toFixed(2)),
886
+ yClose: Number(yClose.toFixed(2)),
887
+ bodyTop: Number(bodyTop.toFixed(2)),
888
+ bodyH: Number(Math.max(minBody, bodyH).toFixed(2)),
889
+ up: point.v >= bar.o,
890
+ hollow: bodyH < minBody,
891
+ point,
892
+ }
893
+ }).filter(Boolean)
894
+ }
895
+
896
+ /**
897
+ * Snap a pointer position to the nearest data index.
898
+ *
899
+ * Charts are read by pointing at a shape, so the hover target is the nearest
900
+ * *point*, never the exact pixel: this is what makes a 250-bar chart usable with
901
+ * a mouse and what a touch device needs to hit anything at all.
902
+ *
903
+ * @param {number} pointerX - pointer x in svg coordinates.
904
+ * @param {number} count - number of points.
905
+ * @param {{ width: number, padding?: number, paddingX?: number }} geometry - chart geometry.
906
+ * @returns {number} nearest index, or -1 when there is nothing to snap to.
907
+ */
908
+ function nearestIndex(pointerX, count, geometry) {
909
+ if (!Number.isFinite(pointerX) || !(count > 0)) return -1
910
+ const { padX, innerW } = box({ ...geometry, height: geometry.height ?? 1 })
911
+ if (count === 1) return 0
912
+ const ratio = clamp((pointerX - padX) / innerW, 0, 1)
913
+ return Math.round(ratio * (count - 1))
914
+ }
915
+
916
+ /**
917
+ * The x coordinate of one index inside the plotting box.
918
+ *
919
+ * @param {number} index - point index.
920
+ * @param {number} count - number of points.
921
+ * @param {{ width: number, padding?: number, paddingX?: number }} geometry - chart geometry.
922
+ * @returns {number} x in svg coordinates.
923
+ */
924
+ function indexToX(index, count, geometry) {
925
+ const { padX, innerW } = box({ ...geometry, height: geometry.height ?? 1 })
926
+ if (count <= 1) return padX + innerW / 2
927
+ return Number((padX + (index / (count - 1)) * innerW).toFixed(2))
928
+ }
929
+
930
+ /**
931
+ * Build the crosshair and its labels for one hovered index.
932
+ *
933
+ * @param {object} input - input.
934
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} input.points - ascending points.
935
+ * @param {number} input.index - hovered index.
936
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, yDomain?: { min: number, max: number } }} input.geometry - chart geometry.
937
+ * @returns {{ x: number, y: number, index: number, point: object, bar: object|undefined, anchoredLeft: boolean }|undefined} crosshair state.
938
+ */
939
+ function buildCrosshair({ points, index, geometry }) {
940
+ const list = points ?? []
941
+ if (index < 0 || index >= list.length) return undefined
942
+ const point = list[index]
943
+ const { innerW, padX, padY, innerH } = box({ ...geometry, height: geometry.height ?? 1 })
944
+ const bar = ohlcOf(point)
945
+ const domain = geometry.yDomain ?? valueDomain(list, { useExtremes: bar !== undefined })
946
+ const y = linearScale({ domain: domain === undefined ? [0, 1] : [domain.min, domain.max], range: [padY + innerH, padY] })
947
+ const x = indexToX(index, list.length, geometry)
948
+ return {
949
+ index,
950
+ x,
951
+ y: Number(y(point.v).toFixed(2)),
952
+ point,
953
+ bar,
954
+ // The tooltip flips side near the right edge so it never runs out of frame.
955
+ anchoredLeft: x > padX + innerW * 0.62,
956
+ }
957
+ }
958
+
959
+ /**
960
+ * Horizontal reference lines worth drawing for a series.
961
+ *
962
+ * A reader compares the latest reading against the window, so the mean and the
963
+ * two extremes are the lines that carry information. Each one is emitted only
964
+ * when it is finite, and identical values collapse: a two-point series has a
965
+ * mean but a flat one has extremes equal to the last value, which would stack
966
+ * three labels on one pixel.
967
+ *
968
+ * @param {{ min?: number, max?: number, mean?: number }} [stats] - series statistics.
969
+ * @param {{ min: number, max: number }} domain - drawn domain.
970
+ * @param {{ plot: { x: number, y: number, w: number, h: number } }} layout - resolved plot box.
971
+ * @returns {Array<{ kind: string, label: string, value: number, y: number, x1: number, x2: number }>} reference lines.
972
+ */
973
+ function buildReferenceLines(stats, domain, layout) {
974
+ if (stats === undefined || domain === undefined || layout?.plot === undefined) return []
975
+ const { x, w, y: top, h } = layout.plot
976
+ const y = linearScale({ domain: [domain.min, domain.max], range: [top + h, top] })
977
+ const candidates = [
978
+ { kind: 'max', value: stats.max, label: '高' },
979
+ { kind: 'mean', value: stats.mean, label: '均' },
980
+ { kind: 'min', value: stats.min, label: '低' },
981
+ ]
982
+ const out = []
983
+ const seen = new Set()
984
+ for (const candidate of candidates) {
985
+ if (!Number.isFinite(candidate.value)) continue
986
+ const key = candidate.value.toFixed(6)
987
+ if (seen.has(key)) continue
988
+ seen.add(key)
989
+ out.push({
990
+ kind: candidate.kind,
991
+ label: candidate.label,
992
+ value: candidate.value,
993
+ y: Number(y(candidate.value).toFixed(2)),
994
+ x1: x,
995
+ x2: x + w,
996
+ })
997
+ }
998
+ return out
999
+ }
1000
+
1001
+ /**
1002
+ * A readable tick list for the value axis.
1003
+ *
1004
+ * @param {{ min: number, max: number }} domain - data domain.
1005
+ * @param {number} [count] - desired ticks.
1006
+ * @returns {{ min: number, max: number, ticks: number[] }} nice domain and ticks.
1007
+ */
1008
+ function valueAxis(domain, count = 5) {
1009
+ return niceTicks(domain.min, domain.max, count)
1010
+ }
1011
+
1012
+ // ── src/core/chart/bar.js ─────────────────────────────────────────
1013
+ /**
1014
+ * Bar-chart rectangles and axis geometry (docs/05 §4.1).
1015
+ *
1016
+ * @module core/chart/bar
1017
+ */
1018
+
1019
+
1020
+ /**
1021
+ * Build bar rectangles for a column chart. Bars are anchored to the zero line
1022
+ * when the domain crosses zero, so positive and negative values read correctly.
1023
+ *
1024
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
1025
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, yDomain?: { min: number, max: number }, gap?: number }} geometry - chart geometry.
1026
+ * @returns {Array<{ x: number, y: number, w: number, h: number, t: string, v: number }>} rectangles.
1027
+ */
1028
+ function buildBarRects(points, geometry) {
1029
+ if (!Array.isArray(points) || points.length === 0) return []
1030
+ const { innerW, innerH, padX, padY } = box(geometry)
1031
+ const finite = points.filter((p) => Number.isFinite(p?.v))
1032
+ if (finite.length === 0) return []
1033
+
1034
+ const dataExtent = extent(finite.map((p) => p.v))
1035
+ const yDomain = geometry.yDomain ?? { min: Math.min(0, dataExtent.min), max: Math.max(0, dataExtent.max) }
1036
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
1037
+ const gap = geometry.gap ?? 1
1038
+ const slot = innerW / finite.length
1039
+ const width = Math.max(1, slot - gap)
1040
+ const zeroY = y(0)
1041
+
1042
+ return finite.map((point, index) => {
1043
+ const valueY = y(point.v)
1044
+ const top = Math.min(zeroY, valueY)
1045
+ const height = Math.max(1, Math.abs(valueY - zeroY))
1046
+ return {
1047
+ x: Number((padX + index * slot + gap / 2).toFixed(2)),
1048
+ y: Number(top.toFixed(2)),
1049
+ w: Number(width.toFixed(2)),
1050
+ h: Number(height.toFixed(2)),
1051
+ t: point.t,
1052
+ v: point.v,
1053
+ }
1054
+ })
1055
+ }
1056
+
1057
+ /**
1058
+ * The y coordinate of the zero line, or 'null' when zero is outside the domain.
1059
+ *
1060
+ * @param {{ min: number, max: number }} yDomain - y domain.
1061
+ * @param {{ height: number, padding?: number, paddingY?: number }} geometry - chart geometry.
1062
+ * @returns {number|null} zero-line y coordinate.
1063
+ */
1064
+ function buildZeroLine(yDomain, geometry) {
1065
+ const { innerH, padY } = box({ width: 1, height: geometry.height, padding: geometry.padding, paddingY: geometry.paddingY })
1066
+ if (!(yDomain.min <= 0 && yDomain.max >= 0)) return null
1067
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
1068
+ return Number(y(0).toFixed(2))
1069
+ }
1070
+
1071
+ // ── src/core/chart/axis.js ─────────────────────────────────────────
1072
+ /**
1073
+ * Axis label geometry (docs/05 §4.1).
1074
+ *
1075
+ * @module core/chart/axis
1076
+ */
1077
+
1078
+
1079
+ /**
1080
+ * Build y-axis tick labels.
1081
+ *
1082
+ * @param {{ min: number, max: number }} yDomain - y domain.
1083
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, ticks?: number, decimals?: number, suffix?: string }} geometry - chart geometry.
1084
+ * @returns {Array<{ y: number, value: number, label: string }>} tick entries.
1085
+ */
1086
+ function buildYAxis(yDomain, geometry) {
1087
+ const { innerH, padY } = box({ width: geometry.width ?? 1, height: geometry.height, padding: geometry.padding, paddingX: geometry.paddingX, paddingY: geometry.paddingY })
1088
+ const decimals = geometry.decimals ?? 2
1089
+ const suffix = geometry.suffix ?? ''
1090
+ // No explicit count → derive "nice" 1/2/5×10ⁿ ticks from the domain; an
1091
+ // explicit count → evenly spaced ticks inside the given domain.
1092
+ const values = geometry.ticks === undefined
1093
+ ? niceTicks(yDomain.min, yDomain.max, 5).ticks.filter((t) => t >= yDomain.min && t <= yDomain.max)
1094
+ : buildEvenTicks(yDomain, geometry.ticks)
1095
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
1096
+ return values.map((value) => ({
1097
+ y: Number(y(value).toFixed(2)),
1098
+ value,
1099
+ label: `${Number(value.toFixed(decimals))}${suffix}`,
1100
+ }))
1101
+ }
1102
+
1103
+ /**
1104
+ * Evenly spaced ticks across a domain (used when the caller fixes the count).
1105
+ *
1106
+ * @param {{ min: number, max: number }} domain - domain.
1107
+ * @param {number} count - tick count.
1108
+ * @returns {number[]} tick values.
1109
+ */
1110
+ function buildEvenTicks(domain, count) {
1111
+ if (count <= 1) return [domain.min]
1112
+ const step = (domain.max - domain.min) / (count - 1)
1113
+ return Array.from({ length: count }, (_, i) => Number((domain.min + step * i).toFixed(12)))
1114
+ }
1115
+
1116
+ /**
1117
+ * Build x-axis tick labels from date strings.
1118
+ *
1119
+ * @param {string[]} dates - ascending 'YYYY-MM-DD' values.
1120
+ * @param {{ width: number, padding?: number, paddingX?: number, count?: number }} geometry - chart geometry.
1121
+ * @returns {Array<{ x: number, label: string, t: string }>} tick entries.
1122
+ */
1123
+ function buildXAxis(dates, geometry) {
1124
+ if (!Array.isArray(dates) || dates.length === 0) return []
1125
+ const { innerW, padX } = box({ width: geometry.width, height: 1, padding: geometry.padding, paddingX: geometry.paddingX })
1126
+ const wanted = Math.min(geometry.count ?? 4, dates.length)
1127
+ const step = dates.length <= wanted ? 1 : (dates.length - 1) / (wanted - 1)
1128
+ const picked = []
1129
+ for (let i = 0; i < wanted; i += 1) picked.push(Math.round(i * step))
1130
+ const unique = [...new Set(picked)].filter((i) => i >= 0 && i < dates.length)
1131
+ const span = Math.max(1, dates.length - 1)
1132
+ return unique.map((index) => ({
1133
+ x: Number((padX + (index / span) * innerW).toFixed(2)),
1134
+ label: formatAxisDate(dates[index]),
1135
+ t: dates[index],
1136
+ }))
1137
+ }
1138
+
1139
+ /**
1140
+ * Short axis date: 'MM-DD' inside one year, 'YYYY-MM' across years.
1141
+ *
1142
+ * @param {string} date - 'YYYY-MM-DD'.
1143
+ * @returns {string} axis label.
1144
+ */
1145
+ function formatAxisDate(date) {
1146
+ return `${date.slice(5, 7)}-${date.slice(8, 10)}`
1147
+ }
1148
+
1149
+ /**
1150
+ * Axis date label that stays unambiguous across a multi-year window.
1151
+ *
1152
+ * A daily chart spanning years labelled only 'MM-DD' reads as repeating dates,
1153
+ * so the month is enough inside one year and the year is shown once the window
1154
+ * crosses a boundary (docs/05 §4.2).
1155
+ *
1156
+ * @param {string} date - 'YYYY-MM-DD'.
1157
+ * @param {{ crossesYears?: boolean }} [options] - options.
1158
+ * @returns {string} axis label.
1159
+ */
1160
+ function formatSpanDate(date, options = {}) {
1161
+ return options.crossesYears === true ? `${date.slice(0, 4)}-${date.slice(5, 7)}` : formatAxisDate(date)
1162
+ }
1163
+
1164
+ /**
1165
+ * X ticks for a time series drawn with the shared plot box.
1166
+ *
1167
+ * Placed by index rather than by value, because the chart is index-spaced (a
1168
+ * weekend must not open a gap), and it always keeps the first and last point so
1169
+ * the axis states the window it covers.
1170
+ *
1171
+ * @param {string[]} dates - ascending 'YYYY-MM-DD' values.
1172
+ * @param {{ width: number, height?: number, paddingX?: number, paddingY?: number, padding?: number, count?: number }} geometry - chart geometry.
1173
+ * @returns {Array<{ x: number, label: string, t: string, anchor: 'start'|'middle'|'end' }>} ticks.
1174
+ */
1175
+ function buildTimeAxis(dates, geometry) {
1176
+ if (!Array.isArray(dates) || dates.length === 0) return []
1177
+ const { padX, innerW } = box({
1178
+ width: geometry.width,
1179
+ height: geometry.height ?? 1,
1180
+ padding: geometry.padding,
1181
+ paddingX: geometry.paddingX,
1182
+ paddingY: geometry.paddingY,
1183
+ padLeft: geometry.padLeft,
1184
+ padRight: geometry.padRight,
1185
+ padX: geometry.padX,
1186
+ innerW: geometry.innerW,
1187
+ })
1188
+ const wanted = Math.min(Math.max(2, geometry.count ?? 5), dates.length)
1189
+ const crossesYears = dates[0].slice(0, 4) !== dates[dates.length - 1].slice(0, 4)
1190
+ const span = dates.length - 1
1191
+ const picked = new Set([0, span])
1192
+ for (let i = 1; i < wanted - 1; i += 1) picked.add(Math.round((i / (wanted - 1)) * span))
1193
+ return [...picked]
1194
+ .sort((a, b) => a - b)
1195
+ .map((index) => ({
1196
+ index,
1197
+ x: Number((padX + (span === 0 ? innerW / 2 : (index / span) * innerW)).toFixed(2)),
1198
+ t: dates[index],
1199
+ label: formatSpanDate(dates[index], { crossesYears }),
1200
+ // Edge labels are anchored inward so they never run off the plot.
1201
+ anchor: index === 0 ? 'start' : index === span ? 'end' : 'middle',
1202
+ }))
1203
+ }
1204
+
1205
+ // ── src/core/format.js ─────────────────────────────────────────
1206
+ /**
1207
+ * Formatting shared by the panel and (for the digest) the host — pure functions,
1208
+ * no DOM, no locale API (docs/05 §4.3, §6).
1209
+ *
1210
+ * @module core/format
1211
+ */
1212
+
1213
+ /** Semantic colour keys the panel maps to theme tokens. */
1214
+ const COLORS = ['up', 'down', 'neutral', 'warn', 'error']
1215
+
1216
+ /**
1217
+ * Format a number for display: fixed decimals, grouped thousands, explicit
1218
+ * sign where a sign carries meaning.
1219
+ *
1220
+ * @param {number|undefined} value - value.
1221
+ * @param {{ decimals?: number, signed?: boolean, compact?: boolean }} [options] - formatting options.
1222
+ * @returns {string} display text (''—'' when absent).
1223
+ */
1224
+ function formatValue(value, { decimals = 2, signed = false, compact = false } = {}) {
1225
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
1226
+ const fixed = value.toFixed(Math.max(0, Math.min(8, decimals)))
1227
+ const [intPart, fraction] = fixed.split('.')
1228
+ const negative = intPart.startsWith('-')
1229
+ const digits = negative ? intPart.slice(1) : intPart
1230
+ let grouped = digits
1231
+ if (compact && digits.length > 9) {
1232
+ const billions = Number(digits) / 1e9
1233
+ grouped = `${billions.toFixed(1)}B`
1234
+ return `${negative ? '-' : signed ? '+' : ''}${grouped}`
1235
+ }
1236
+ if (digits.length > 3) grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
1237
+ const sign = negative ? '-' : signed ? '+' : ''
1238
+ return fraction === undefined ? `${sign}${grouped}` : `${sign}${grouped}.${fraction}`
1239
+ }
1240
+
1241
+ /**
1242
+ * Format a change with a direction arrow, for card deltas.
1243
+ *
1244
+ * @param {number|undefined} changeAbs - absolute change.
1245
+ * @param {number|undefined} changePct - percentage change.
1246
+ * @param {string} unit - unit suffix.
1247
+ * @param {number} [decimals] - decimals for the absolute change.
1248
+ * @returns {string} display text.
1249
+ */
1250
+ function formatChange(changeAbs, changePct, unit, decimals = 2) {
1251
+ if (typeof changeAbs !== 'number' || !Number.isFinite(changeAbs)) return '—'
1252
+ const arrow = changeAbs > 0 ? '▲' : changeAbs < 0 ? '▼' : '·'
1253
+ const absolute = formatValue(changeAbs, { decimals, signed: true })
1254
+ const percent = typeof changePct === 'number' && Number.isFinite(changePct) ? ` (${formatValue(changePct, { decimals: 1, signed: true })}%)` : ''
1255
+ return `${arrow} ${absolute}${unit === '' ? '' : unit}${percent}`
1256
+ }
1257
+
1258
+ /**
1259
+ * Compact relative time for "last updated" labels.
1260
+ *
1261
+ * @param {number} minutes - age in minutes.
1262
+ * @returns {string} display text.
1263
+ */
1264
+ function formatAge(minutes) {
1265
+ if (!Number.isFinite(minutes) || minutes < 0) return '未知时间'
1266
+ if (minutes < 1) return '刚刚'
1267
+ if (minutes < 60) return `${Math.round(minutes)} 分钟前`
1268
+ if (minutes < 60 * 24) return `${Math.round(minutes / 60)} 小时前`
1269
+ const days = Math.round(minutes / (60 * 24))
1270
+ return days === 1 ? '昨天' : `${days} 天前`
1271
+ }
1272
+
1273
+ /**
1274
+ * Whether a change should read as good, bad or neutral — decided by the
1275
+ * indicator's 'polarity', never by the sign of the number alone
1276
+ * (docs/05 §4.3: rising CPI is not "red" the way rising unemployment is).
1277
+ *
1278
+ * @param {number|undefined} change - signed change.
1279
+ * @param {'up-is-good'|'down-is-good'|'neutral'} [polarity] - catalog polarity.
1280
+ * @returns {'up'|'down'|'neutral'} colour key.
1281
+ */
1282
+ function changeColor(change, polarity = 'neutral') {
1283
+ if (typeof change !== 'number' || !Number.isFinite(change) || change === 0) return 'neutral'
1284
+ if (polarity === 'neutral') return 'neutral'
1285
+ const good = polarity === 'up-is-good' ? change > 0 : change < 0
1286
+ return good ? 'up' : 'down'
1287
+ }
1288
+
1289
+ /**
1290
+ * Status dot semantics (docs/03 §1.7).
1291
+ *
1292
+ * @param {'fresh'|'stale'|'error'|'missing'} status - metric status.
1293
+ * @returns {{ color: 'up'|'warn'|'error'|'neutral', label: string }} dot description.
1294
+ */
1295
+ function statusDot(status) {
1296
+ switch (status) {
1297
+ case 'fresh':
1298
+ return { color: 'up', label: '数据最新' }
1299
+ case 'stale':
1300
+ return { color: 'warn', label: '数据可能陈旧' }
1301
+ case 'error':
1302
+ return { color: 'error', label: '数据源暂不可用' }
1303
+ default:
1304
+ return { color: 'neutral', label: '本范围内无数据' }
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Format a calendar date for display.
1310
+ *
1311
+ * @param {string|undefined} date - 'YYYY-MM-DD'.
1312
+ * @param {{ withYear?: boolean }} [options] - options.
1313
+ * @returns {string} display text.
1314
+ */
1315
+ function formatDate(date, { withYear = true } = {}) {
1316
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return '—'
1317
+ return withYear ? date : date.slice(5)
1318
+ }
1319
+
1320
+ /**
1321
+ * Build the tooltip text for one card, used by the 'title' attribute and by the
1322
+ * accessible label.
1323
+ *
1324
+ * @param {object} metric - metric card.
1325
+ * @returns {string} tooltip.
1326
+ */
1327
+ function metricTooltip(metric) {
1328
+ const parts = [
1329
+ `${metric.label?.zh ?? metric.indicatorId}(${metric.indicatorId})`,
1330
+ `最新:${formatValue(metric.latest, { decimals: metric.display?.decimals ?? 2 })}${metric.unit ?? ''} @ ${formatDate(metric.latestAt)}`,
1331
+ ]
1332
+ if (metric.notes?.zh) parts.push(metric.notes.zh)
1333
+ if (metric.sourceRef?.label) parts.push(`来源:${metric.sourceRef.label}`)
1334
+ parts.push(`状态:${statusDot(metric.status).label}`)
1335
+ return parts.join('\n')
1336
+ }
1337
+
1338
+ /**
1339
+ * Truncate text for a compact label, keeping the tail visible with an ellipsis.
1340
+ *
1341
+ * @param {string} text - input.
1342
+ * @param {number} max - maximum characters.
1343
+ * @returns {string} truncated text.
1344
+ */
1345
+ function truncate(text, max) {
1346
+ const value = String(text ?? '')
1347
+ return value.length <= max ? value : `${value.slice(0, Math.max(1, max - 1))}…`
1348
+ }
1349
+
1350
+ // ── src/client/copy.js ─────────────────────────────────────────
1351
+ /**
1352
+ * Every user-facing string in one place (docs/05 §6), so a test can assert that
1353
+ * no raw key or bare English leaks into the panel and so translation is a data
1354
+ * change rather than a code change.
1355
+ *
1356
+ * @module client/copy
1357
+ */
1358
+
1359
+ /** Chinese copy (the panel's primary language). */
1360
+ const COPY = {
1361
+ panelTitle: '数据雷达',
1362
+ panelSubtitle: '全球宏观 · 利率 · 股市 · 债市',
1363
+ open: '打开数据雷达',
1364
+ close: '收起面板',
1365
+ refresh: '刷新',
1366
+ refreshing: '正在刷新…',
1367
+ retry: '重试',
1368
+ loading: '正在获取数据…',
1369
+ empty: '这个范围里没有可用数据。',
1370
+ degradedBanner: '部分数据源不可用,卡片显示的是上次成功的快照。',
1371
+ unreachable: '插件未正确挂载(/api/show-me-data 不可达)。请检查挂载行与宿主日志。',
1372
+ sourceBadge: '来源',
1373
+ viewSource: '打开原始数据源',
1374
+ showDataTable: '查看数据表',
1375
+ hideDataTable: '收起数据表',
1376
+ date: '日期',
1377
+ value: '数值',
1378
+ stats: '统计',
1379
+ statsMean: '均值',
1380
+ statsMin: '最小',
1381
+ statsMax: '最大',
1382
+ statsStdDev: '标准差',
1383
+ statsPercentile: '分位',
1384
+ statsYoy: '同比',
1385
+ statsMom: '环比',
1386
+ statsMissing: '缺口',
1387
+ noteworthy: '今日值得关注',
1388
+ noteworthyEmpty: '今天没有触发关注规则。',
1389
+ tabToday: '今日',
1390
+ tabCore: '核心',
1391
+ tabMine: '我的',
1392
+ tabSettings: '设置',
1393
+ tabDetail: '详情',
1394
+ groupUS: '美国',
1395
+ groupCN: '中国',
1396
+ groupGlobal: '全球',
1397
+ groupCustom: '我的',
1398
+ ai: 'AI 解析',
1399
+ aiSummary: '时段总结',
1400
+ aiAsk: '数据问答',
1401
+ aiAskPlaceholder: '用面板内的数据提问,例如:美国 CPI 同比现在是多少?',
1402
+ aiAskSubmit: '提问',
1403
+ aiModeDeterministic: '确定性摘要模式',
1404
+ aiModeLlm: '模型模式',
1405
+ aiDegraded: 'AI 输出未通过溯源校验,已回退为确定性摘要。',
1406
+ aiInsufficient: '数据不足',
1407
+ aiCitations: '引用数据点',
1408
+ discuss: '开独立会话讨论',
1409
+ discussing: '正在开启会话…',
1410
+ discussFailed: '独立会话不可用',
1411
+ discussTurnFailed: '会话已创建,但本轮提问失败',
1412
+ discussHint: '会新建一个 DSH 会话,并把面板数据作为上下文;没输入问题时面板会代你提出开场问题。回答在会话里流式生成,可以继续追问、换模型。',
1413
+ discussOpened: '已在新会话中打开(回答正在会话里生成,稍后也会显示在这里)。',
1414
+ discussAnswer: '会话里的回答',
1415
+ discussOpening: '正在开启会话…',
1416
+ discussRunning: '会话进行中',
1417
+ discussReady: '会话已就绪',
1418
+ discussRunningHint: '回答在会话里生成,完成后也会显示在这里(通常 30–60 秒)。',
1419
+ discussOpenSession: '打开会话',
1420
+ discussSessionStarted: '会话 ID:',
1421
+ discussTimeout: '等待超时:会话仍在运行,请点击「打开会话」查看;或稍后重试。',
1422
+ dismiss: '收起',
1423
+ noteworthyDiscuss: '讨论',
1424
+ noteworthyDiscussHint: '开一个独立会话,把这条的关注原因和面板数据一起带过去',
1425
+ analyzeTab: '总体分析',
1426
+ groupUs: '美国',
1427
+ groupCn: '中国',
1428
+ analyzeScope: '范围',
1429
+ analyzeAll: '全部',
1430
+ analyzeRun: '生成总体分析',
1431
+ analyzeIdle: '选择范围后点「生成总体分析」:会综合该范围内所有指标与今日触发规则。',
1432
+ analyzeEmpty: '该范围内没有可用指标。',
1433
+ analyzeCount: '纳入指标',
1434
+ analyzeDiscuss: '深入讨论',
1435
+ analyzeDiscussHint: '带着这份总体数据开一个独立会话,可以继续追问',
1436
+ iterateHint: '面板只读取已登记的数据源,无法自己接入新源。可以开一个独立会话,在插件工作区里迭代代码来接入(会话已带上这条需求与失败原因)。',
1437
+ iterateAction: '开迭代会话接入数据源',
1438
+ iterateOpening: '正在开会话…',
1439
+ iterateTopic: '接入数据源',
1440
+ viewDetail: '查看',
1441
+ selectHint: '勾选后可跨板块统一分析',
1442
+ selectedCount: '已选',
1443
+ analyzeSelected: '分析选中',
1444
+ clearSelection: '清除选择',
1445
+ expand: '展开',
1446
+ collapse: '收起',
1447
+ barSize: '周期',
1448
+ barDay: '日K',
1449
+ barWeek: '周K',
1450
+ barMonth: '月K',
1451
+ chartCandle: 'K线',
1452
+ chartArea: '面积',
1453
+ chartLine: '折线',
1454
+ chartBar: '柱状',
1455
+ chartHint: '移动指针查看数值',
1456
+ pointsUnit: '个观测点',
1457
+ discussEmpty: '会话里这一轮没有产生文本(模型可能只给出了推理,或中途失败);请打开该会话查看原因。',
1458
+ discussIdle: '会话已就绪,但面板只注入了上下文、没有提问;请打开会话直接提问。',
1459
+ discussAutoAsked: '面板已代你提出开场问题:',
1460
+ discussQuestion: '本轮提问:',
1461
+ aiCached: '来自缓存',
1462
+ aiOffline: '未配置模型,本回答由本地规则生成。',
1463
+ aiEmptyAnswer: '模型未返回可用的文本,已回退为确定性摘要',
1464
+ addIndicator: '添加指标',
1465
+ addSearchPlaceholder: '搜索指标(中文或英文,例如 非农 / CPI)',
1466
+ addByText: '用自然语言添加',
1467
+ addByTextPlaceholder: '例如:加上美国 30 年期房贷利率',
1468
+ addConfirm: '加入我的',
1469
+ addPreview: '预览',
1470
+ addExists: '已存在,是否更新?',
1471
+ addUnsupported: '无法添加(没有可用数据源)',
1472
+ remove: '移除',
1473
+ disclaimer: '数据来自公开源,仅供研究参考,不构成投资建议;口径以原始来源为准。',
1474
+ lastUpdated: '更新于',
1475
+ fromCache: '正在显示缓存',
1476
+ settingsMount: '挂载检查清单',
1477
+ settingsWhatIsThis: '这个面板在做什么',
1478
+ settingsIntro: '下面是当前挂载生效的配置。多数项来自 profile 的行配置,改完需要重启 dsh 进程;面板内不可改的项已标注。',
1479
+ settingsPrefix: '接口前缀',
1480
+ settingsPrefixHint: '所有面板请求都走这个前缀',
1481
+ settingsRoutes: '路由数',
1482
+ settingsTools: '模型工具数',
1483
+ settingsReadOnly: '行配置,面板内不可改',
1484
+ settingsUnsupported: '暂不可用指标:',
1485
+ settingsStorageHint: '未配置时为内存存储,重启后缓存与自选清空',
1486
+ settingsAi: 'AI 设置',
1487
+ settingsCurrentModel: '当前模型',
1488
+ settingsAiEnabled: 'AI 开关',
1489
+ settingsAiEnabledHint: '关掉后只用确定性摘要(不调用模型)',
1490
+ settingsAiChars: '输出字数上限',
1491
+ settingsAiCharsHint: '太小时推理模型会把预算用完、只留下空回答',
1492
+ settingsAiCache: '结果缓存',
1493
+ settingsAiCacheHint: '同一指标+同一区间在缓存期内直接复用',
1494
+ settingsData: '数据与缓存',
1495
+ settingsRefresh: '后台刷新间隔',
1496
+ settingsRefreshHint: '0 表示不自动刷新,只在打开面板时取数',
1497
+ settingsGroups: '展示分组',
1498
+ settingsGroupsHint: '面板按这些分组归类卡片',
1499
+ settingsNoteworthy: '关注项数量',
1500
+ settingsNoteworthyHint: '「今日值得关注」最多显示几条',
1501
+ settingsDebug: '调试日志',
1502
+ settingsDebugHint: '开启后把 AI 诊断写到进程 stderr',
1503
+ settingsSourcesHint: '可用性来自最近一次抓取;失败通常是上游限流或封锁。作为备用上游、从未被请求过的源会显示"未检查"——那不代表它坏了。',
1504
+ settingsSourceOff: '已在配置中关闭',
1505
+ settingsLast: '最后成功',
1506
+ settingsHowToChange: '要修改以上任一项:编辑',
1507
+ reload: '重新读取',
1508
+ testSource: '测试连接',
1509
+ testSourceHint: '忽略缓存重新请求该数据源的指标,确认上游此刻是否可用',
1510
+ testAll: '全部测试',
1511
+ testing: '测试中…',
1512
+ testOk: '连接正常',
1513
+ testFailed: '连接失败',
1514
+ failedCount: '失败',
1515
+ probeIndicators: '本次测试指标数',
1516
+ notChecked: '未检查',
1517
+ available: '可用',
1518
+ unavailable: '失败',
1519
+ yes: '是',
1520
+ no: '否',
1521
+ minutes: '分钟',
1522
+ settingsStorage: '存储目录',
1523
+ settingsAiMode: 'AI 模式',
1524
+ settingsSources: '数据源开关',
1525
+ settingsIndicators: '指标数量',
1526
+ coldStart: '正在获取数据源数据…',
1527
+ staleHint: '按该指标的发布频率判断,数据可能尚未更新。',
1528
+ errorHint: '该数据源本次取数失败。点击重试,或查看健康状态。',
1529
+ missingHint: '上游成功返回,但本范围内没有观测。',
1530
+ hoverHint: '悬停查看数值,点击打开详情。',
1531
+ }
1532
+
1533
+ /** English mirror, used when the locale service reports a non-Chinese locale. */
1534
+ const COPY_EN = {
1535
+ panelTitle: 'Data Radar',
1536
+ panelSubtitle: 'Global macro · rates · equities · bonds',
1537
+ open: 'Open Data Radar',
1538
+ close: 'Collapse panel',
1539
+ refresh: 'Refresh',
1540
+ refreshing: 'Refreshing…',
1541
+ retry: 'Retry',
1542
+ loading: 'Fetching data…',
1543
+ empty: 'No data available in this range.',
1544
+ degradedBanner: 'Some sources are unavailable; cards show the last successful snapshot.',
1545
+ unreachable: 'The plugin is not mounted correctly (/api/show-me-data is unreachable).',
1546
+ sourceBadge: 'Source',
1547
+ viewSource: 'Open the original source',
1548
+ showDataTable: 'Show data table',
1549
+ hideDataTable: 'Hide data table',
1550
+ date: 'Date',
1551
+ value: 'Value',
1552
+ stats: 'Statistics',
1553
+ statsMean: 'Mean',
1554
+ statsMin: 'Min',
1555
+ statsMax: 'Max',
1556
+ statsStdDev: 'Std dev',
1557
+ statsPercentile: 'Percentile',
1558
+ statsYoy: 'YoY',
1559
+ statsMom: 'MoM',
1560
+ statsMissing: 'Gaps',
1561
+ noteworthy: 'Worth watching today',
1562
+ noteworthyEmpty: 'No rule fired today.',
1563
+ tabToday: 'Today',
1564
+ tabCore: 'Core',
1565
+ tabMine: 'Mine',
1566
+ tabSettings: 'Settings',
1567
+ tabDetail: 'Detail',
1568
+ groupUS: 'United States',
1569
+ groupCN: 'China',
1570
+ groupGlobal: 'Global',
1571
+ groupCustom: 'Mine',
1572
+ ai: 'AI analysis',
1573
+ aiSummary: 'Period summary',
1574
+ aiAsk: 'Ask the data',
1575
+ aiAskPlaceholder: 'Ask using panel data, e.g. what is US CPI YoY now?',
1576
+ aiAskSubmit: 'Ask',
1577
+ aiModeDeterministic: 'Deterministic summary mode',
1578
+ aiModeLlm: 'Model mode',
1579
+ aiDegraded: 'The model output failed provenance validation; fell back to the deterministic summary.',
1580
+ aiInsufficient: 'Insufficient data',
1581
+ aiCitations: 'Cited data points',
1582
+ discuss: 'Discuss in a session',
1583
+ discussing: 'Opening a session…',
1584
+ discussFailed: 'Independent sessions are unavailable',
1585
+ discussTurnFailed: 'Session created, this turn failed',
1586
+ discussHint: 'Creates a DSH session seeded with the panel data; with an empty question the panel asks an opening question for you. The answer streams in the session.',
1587
+ discussOpened: 'Opened in a new session (the answer is generating there and will also appear here).',
1588
+ discussAnswer: 'Answer from the session',
1589
+ discussOpening: 'Opening a session…',
1590
+ discussRunning: 'Session running',
1591
+ discussReady: 'Session ready',
1592
+ discussRunningHint: 'The answer is generating in the session and will appear here too (usually 30–60s).',
1593
+ discussOpenSession: 'Open session',
1594
+ discussSessionStarted: 'Session id: ',
1595
+ discussTimeout: 'Timed out waiting: the session is still running — open it, or try again later.',
1596
+ dismiss: 'Dismiss',
1597
+ noteworthyDiscuss: 'Discuss',
1598
+ noteworthyDiscussHint: 'Open a session seeded with this item\'s rule triggers and the panel data',
1599
+ analyzeTab: 'Analysis',
1600
+ groupUs: 'US',
1601
+ groupCn: 'China',
1602
+ analyzeScope: 'Scope',
1603
+ analyzeAll: 'All',
1604
+ analyzeRun: 'Run the analysis',
1605
+ analyzeIdle: 'Pick a scope and run it: every indicator in scope plus today\'s triggered rules go into one digest.',
1606
+ analyzeEmpty: 'No usable indicators in this scope.',
1607
+ analyzeCount: 'Indicators in scope',
1608
+ analyzeDiscuss: 'Discuss in a session',
1609
+ analyzeDiscussHint: 'Open a session seeded with this whole-panel digest',
1610
+ iterateHint: 'The panel only reads registered sources and cannot add one by itself. Open a session in the plugin workspace to add it (the request and the reasons travel with it).',
1611
+ iterateAction: 'Open an iteration session',
1612
+ iterateOpening: 'Opening a session…',
1613
+ iterateTopic: 'Add a data source',
1614
+ viewDetail: 'View',
1615
+ selectHint: 'pick indicators to analyse them together',
1616
+ selectedCount: 'Selected',
1617
+ analyzeSelected: 'Analyse selection',
1618
+ clearSelection: 'Clear',
1619
+ expand: 'Expand',
1620
+ collapse: 'Collapse',
1621
+ barSize: 'Bars',
1622
+ barDay: 'Daily',
1623
+ barWeek: 'Weekly',
1624
+ barMonth: 'Monthly',
1625
+ chartCandle: 'Candles',
1626
+ chartArea: 'Area',
1627
+ chartLine: 'Line',
1628
+ chartBar: 'Bars',
1629
+ chartHint: 'Move the pointer to read values',
1630
+ pointsUnit: 'observations',
1631
+ discussEmpty: 'This turn produced no text (the model may have returned only reasoning, or failed midway); open the session to see why.',
1632
+ discussIdle: 'The session is ready, but only context was seeded — no question was asked; open it and ask one.',
1633
+ discussAutoAsked: 'The panel asked this opening question for you:',
1634
+ discussQuestion: 'Question this turn:',
1635
+ aiCached: 'From cache',
1636
+ aiOffline: 'No model configured; this answer was generated by local rules.',
1637
+ aiEmptyAnswer: 'The model returned no usable text; fell back to the deterministic summary',
1638
+ addIndicator: 'Add indicator',
1639
+ addSearchPlaceholder: 'Search indicators (Chinese or English)',
1640
+ addByText: 'Add by description',
1641
+ addByTextPlaceholder: 'e.g. add the US 30-year mortgage rate',
1642
+ addConfirm: 'Add to mine',
1643
+ addPreview: 'Preview',
1644
+ addExists: 'Already added — update it?',
1645
+ addUnsupported: 'Cannot add (no available source)',
1646
+ remove: 'Remove',
1647
+ disclaimer: 'Data comes from public sources, for research only, not investment advice.',
1648
+ lastUpdated: 'Updated',
1649
+ fromCache: 'Showing cache',
1650
+ settingsMount: 'Mount checklist',
1651
+ settingsWhatIsThis: 'What this panel is doing',
1652
+ settingsIntro: 'Effective configuration of this mount. Most values come from the profile row and need a dsh restart; rows the panel cannot change say so.',
1653
+ settingsPrefix: 'API prefix',
1654
+ settingsPrefixHint: 'every panel request goes through this prefix',
1655
+ settingsRoutes: 'Routes',
1656
+ settingsTools: 'Model tools',
1657
+ settingsReadOnly: 'row config, not editable here',
1658
+ settingsUnsupported: 'Unavailable indicators:',
1659
+ settingsStorageHint: 'memory when unset: cache and watchlist are cleared on restart',
1660
+ settingsAi: 'AI',
1661
+ settingsCurrentModel: 'Current model',
1662
+ settingsAiEnabled: 'AI enabled',
1663
+ settingsAiEnabledHint: 'off means deterministic summaries only',
1664
+ settingsAiChars: 'Answer budget',
1665
+ settingsAiCharsHint: 'too small and a reasoning model spends it all thinking',
1666
+ settingsAiCache: 'Result cache',
1667
+ settingsAiCacheHint: 'same indicator and range reuses the cached answer',
1668
+ settingsData: 'Data and caching',
1669
+ settingsRefresh: 'Background refresh',
1670
+ settingsRefreshHint: '0 disables it: data is fetched when the panel opens',
1671
+ settingsGroups: 'Display groups',
1672
+ settingsGroupsHint: 'cards are grouped by these',
1673
+ settingsNoteworthy: 'Noteworthy limit',
1674
+ settingsNoteworthyHint: 'rows shown in "worth watching today"',
1675
+ settingsDebug: 'Debug logging',
1676
+ settingsDebugHint: 'writes AI diagnostics to the process stderr',
1677
+ settingsSourcesHint: 'Availability comes from the last fetch; failures are usually upstream rate limits. A source only ever used as a fallback shows "not checked" — that is not a failure.',
1678
+ settingsSourceOff: 'disabled in config',
1679
+ settingsLast: 'last success',
1680
+ settingsHowToChange: 'To change any of these, edit',
1681
+ reload: 'Reload',
1682
+ testSource: 'Test',
1683
+ testSourceHint: 'Re-fetch this source\'s indicators, ignoring the cache, to see whether the upstream answers now',
1684
+ testAll: 'Test all',
1685
+ testing: 'Testing…',
1686
+ testOk: 'reachable',
1687
+ testFailed: 'unreachable',
1688
+ failedCount: 'failed',
1689
+ probeIndicators: 'indicators probed',
1690
+ notChecked: 'not checked',
1691
+ available: 'ok',
1692
+ unavailable: 'failed',
1693
+ yes: 'yes',
1694
+ no: 'no',
1695
+ minutes: 'min',
1696
+ settingsStorage: 'Storage directory',
1697
+ settingsAiMode: 'AI mode',
1698
+ settingsSources: 'Source switches',
1699
+ settingsIndicators: 'Indicator count',
1700
+ coldStart: 'Fetching from data sources…',
1701
+ staleHint: 'This indicator may not have been published yet for its frequency.',
1702
+ errorHint: 'This source failed on this refresh. Retry, or check health.',
1703
+ missingHint: 'The upstream answered, but has no observation in this range.',
1704
+ hoverHint: 'Hover for values, click for detail.',
1705
+ }
1706
+
1707
+ /** Group key → copy key. */
1708
+ const GROUP_LABELS = { US: 'groupUS', CN: 'groupCN', GLOBAL: 'groupGlobal', CUSTOM: 'groupCustom' }
1709
+
1710
+ /**
1711
+ * Pick the copy table for a locale id.
1712
+ *
1713
+ * @param {string} [locale] - locale id such as 'zh-CN' or 'en'.
1714
+ * @returns {typeof COPY} copy table.
1715
+ */
1716
+ function copyFor(locale) {
1717
+ return typeof locale === 'string' && /^en/i.test(locale) ? COPY_EN : COPY
1718
+ }
1719
+
1720
+ // ── src/client/api.js ─────────────────────────────────────────
1721
+ /**
1722
+ * The panel's single transport layer (docs/05 §3).
1723
+ *
1724
+ * Every call goes to the host half under '/api/show-me-data/*'; the browser never
1725
+ * talks to an upstream data source (CORS and provenance both depend on that).
1726
+ * Each request has a timeout, is cancellable, and turns a structured error
1727
+ * payload into a value the UI can render instead of an exception the UI must
1728
+ * catch blindly.
1729
+ *
1730
+ * @module client/api
1731
+ */
1732
+
1733
+ /** Request timeout in milliseconds (docs/05 §3). */
1734
+ const REQUEST_TIMEOUT_MS = 8000
1735
+
1736
+ /** The API root. */
1737
+ const API_ROOT = '/api/show-me-data'
1738
+
1739
+ /**
1740
+ * Perform one JSON request.
1741
+ *
1742
+ * @param {string} path - path under {@link API_ROOT}, e.g. '/overview?range=1Y'.
1743
+ * @param {{ method?: string, body?: any, signal?: AbortSignal, timeoutMs?: number, fetchImpl?: Function }} [options] - request options.
1744
+ * @returns {Promise<{ ok: boolean, status: number, data?: any, error?: { kind: string, detail: string, retryable: boolean } }>} response.
1745
+ */
1746
+ async function request(path, { method = 'GET', body, signal, timeoutMs = REQUEST_TIMEOUT_MS, fetchImpl } = {}) {
1747
+ const doFetch = fetchImpl ?? (typeof fetch === 'function' ? fetch : undefined)
1748
+ if (doFetch === undefined) {
1749
+ return { ok: false, status: 0, error: { kind: 'network', detail: 'fetch is unavailable in this environment', retryable: false } }
1750
+ }
1751
+ const controller = new AbortController()
1752
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
1753
+ const composed = typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function' && signal !== undefined
1754
+ ? AbortSignal.any([signal, controller.signal])
1755
+ : controller.signal
1756
+ try {
1757
+ const response = await doFetch(`${API_ROOT}${path}`, {
1758
+ method,
1759
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
1760
+ body: body === undefined ? undefined : JSON.stringify(body),
1761
+ signal: composed,
1762
+ })
1763
+ const text = await response.text()
1764
+ let payload
1765
+ try {
1766
+ payload = text === '' ? undefined : JSON.parse(text)
1767
+ } catch {
1768
+ payload = undefined
1769
+ }
1770
+ if (!response.ok) {
1771
+ return {
1772
+ ok: false,
1773
+ status: response.status,
1774
+ // The payload rides along on failures too: a discussion whose session was
1775
+ // created but whose first turn failed still returns its `sessionId`, and
1776
+ // the panel needs it to offer 「打开会话」.
1777
+ ...(payload === undefined ? {} : { data: payload }),
1778
+ error: payload?.error ?? {
1779
+ kind: response.status === 404 ? 'not-found' : 'http',
1780
+ detail: `请求失败(HTTP ${response.status})`,
1781
+ retryable: response.status >= 500,
1782
+ },
1783
+ }
1784
+ }
1785
+ return { ok: true, status: response.status, data: payload }
1786
+ } catch (error) {
1787
+ const aborted = error?.name === 'AbortError'
1788
+ return {
1789
+ ok: false,
1790
+ status: 0,
1791
+ error: {
1792
+ kind: aborted ? 'timeout' : 'network',
1793
+ detail: aborted ? `请求超过 ${timeoutMs}ms 未返回` : `网络错误:${error?.message ?? error}`,
1794
+ retryable: true,
1795
+ },
1796
+ }
1797
+ } finally {
1798
+ clearTimeout(timer)
1799
+ }
1800
+ }
1801
+
1802
+ /**
1803
+ * Start an SSE request, invoking handlers as events arrive.
1804
+ *
1805
+ * Uses 'fetch' + a stream reader rather than 'EventSource' so the POST payload
1806
+ * and the caller's abort signal both work.
1807
+ *
1808
+ * @param {string} path - path under {@link API_ROOT}.
1809
+ * @param {{ body?: any, signal?: AbortSignal, onText?: (text: string) => void, onDone?: (result: any) => void, onError?: (error: object) => void, fetchImpl?: Function }} options - options.
1810
+ * @returns {Promise<{ ok: boolean, result?: any, error?: object }>} completion.
1811
+ */
1812
+ async function stream(path, { body, signal, onText, onDone, onError, fetchImpl } = {}) {
1813
+ const doFetch = fetchImpl ?? (typeof fetch === 'function' ? fetch : undefined)
1814
+ if (doFetch === undefined) {
1815
+ const error = { kind: 'network', detail: 'fetch is unavailable', retryable: false }
1816
+ onError?.(error)
1817
+ return { ok: false, error }
1818
+ }
1819
+ try {
1820
+ const response = await doFetch(`${API_ROOT}${path}`, {
1821
+ method: 'POST',
1822
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
1823
+ body: JSON.stringify(body ?? {}),
1824
+ signal,
1825
+ })
1826
+ if (!response.ok || response.body === undefined || response.body === null) {
1827
+ // The host answers with plain JSON when streaming is unsupported; accept it.
1828
+ const text = await response.text()
1829
+ try {
1830
+ const parsed = JSON.parse(text)
1831
+ onDone?.(parsed)
1832
+ return { ok: true, result: parsed }
1833
+ } catch {
1834
+ const error = { kind: 'http', detail: `流式请求失败(HTTP ${response.status})`, retryable: response.status >= 500 }
1835
+ onError?.(error)
1836
+ return { ok: false, error }
1837
+ }
1838
+ }
1839
+ const reader = response.body.getReader()
1840
+ const decoder = new TextDecoder()
1841
+ let buffer = ''
1842
+ let result
1843
+ for (;;) {
1844
+ const { value, done } = await reader.read()
1845
+ if (done) break
1846
+ buffer += decoder.decode(value, { stream: true })
1847
+ const frames = buffer.split('\n\n')
1848
+ buffer = frames.pop() ?? ''
1849
+ for (const frame of frames) {
1850
+ const parsed = parseSseFrame(frame)
1851
+ if (parsed === undefined) continue
1852
+ if (parsed.event === 'text') onText?.(parsed.data?.text ?? '')
1853
+ else if (parsed.event === 'done') {
1854
+ result = parsed.data?.result
1855
+ onDone?.(result)
1856
+ } else if (parsed.event === 'error') onError?.({ kind: parsed.data?.kind ?? 'internal', detail: parsed.data?.detail ?? '', retryable: true })
1857
+ }
1858
+ }
1859
+ return { ok: true, result }
1860
+ } catch (error) {
1861
+ const aborted = error?.name === 'AbortError'
1862
+ const payload = { kind: aborted ? 'aborted' : 'network', detail: `${error?.message ?? error}`, retryable: !aborted }
1863
+ onError?.(payload)
1864
+ return { ok: false, error: payload }
1865
+ }
1866
+ }
1867
+
1868
+ /**
1869
+ * Parse one SSE frame.
1870
+ *
1871
+ * @param {string} frame - frame text ('event: x\ndata: {...}').
1872
+ * @returns {{ event: string, data: any }|undefined} parsed frame.
1873
+ */
1874
+ function parseSseFrame(frame) {
1875
+ const trimmed = String(frame ?? '').trim()
1876
+ if (trimmed === '') return undefined
1877
+ let event = 'message'
1878
+ const dataLines = []
1879
+ for (const line of trimmed.split('\n')) {
1880
+ if (line.startsWith('event:')) event = line.slice(6).trim()
1881
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
1882
+ }
1883
+ if (dataLines.length === 0) return { event, data: undefined }
1884
+ const raw = dataLines.join('\n')
1885
+ try {
1886
+ return { event, data: JSON.parse(raw) }
1887
+ } catch {
1888
+ return { event, data: raw }
1889
+ }
1890
+ }
1891
+
1892
+ /** The endpoints the panel uses, so no string is built ad hoc in a component. */
1893
+ const api = {
1894
+ /**
1895
+ * @param {{ range?: string, groups?: string[], ids?: string[], limit?: number, force?: boolean, signal?: AbortSignal }} [options] - request.
1896
+ * @returns {Promise<object>} response.
1897
+ */
1898
+ overview(options = {}) {
1899
+ const query = []
1900
+ if (options.range !== undefined) query.push(`range=${encodeURIComponent(options.range)}`)
1901
+ if (options.groups !== undefined && options.groups.length > 0) query.push(`groups=${encodeURIComponent(options.groups.join(','))}`)
1902
+ if (options.ids !== undefined && options.ids.length > 0) query.push(`ids=${encodeURIComponent(options.ids.join(','))}`)
1903
+ if (options.limit !== undefined) query.push(`limit=${options.limit}`)
1904
+ if (options.force === true) query.push('force=1')
1905
+ return request(`/overview${query.length === 0 ? '' : `?${query.join('&')}`}`, { signal: options.signal })
1906
+ },
1907
+ /**
1908
+ * @param {object} options - request.
1909
+ * @returns {Promise<object>} response.
1910
+ */
1911
+ series(options) {
1912
+ const query = [`indicator=${encodeURIComponent(options.indicator)}`]
1913
+ if (options.range !== undefined) query.push(`range=${encodeURIComponent(options.range)}`)
1914
+ if (options.transform !== undefined) query.push(`transform=${encodeURIComponent(options.transform)}`)
1915
+ if (options.compareWith !== undefined) query.push(`compareWith=${encodeURIComponent(options.compareWith)}`)
1916
+ if (options.freq !== undefined) query.push(`freq=${encodeURIComponent(options.freq)}`)
1917
+ return request(`/series?${query.join('&')}`, { signal: options.signal })
1918
+ },
1919
+ /**
1920
+ * @param {AbortSignal} [signal] - cancellation.
1921
+ * @returns {Promise<object>} response.
1922
+ */
1923
+ watchlist(signal) {
1924
+ return request('/watchlist', { signal })
1925
+ },
1926
+ /**
1927
+ * @param {object} body - action payload.
1928
+ * @param {AbortSignal} [signal] - cancellation.
1929
+ * @returns {Promise<object>} response.
1930
+ */
1931
+ watchlistAction(body, signal) {
1932
+ return request('/watchlist', { method: 'POST', body, signal })
1933
+ },
1934
+ /**
1935
+ * @param {string} query - search text.
1936
+ * @param {AbortSignal} [signal] - cancellation.
1937
+ * @returns {Promise<object>} response.
1938
+ */
1939
+ search(query, signal) {
1940
+ return request(`/catalog/search?q=${encodeURIComponent(query)}`, { signal })
1941
+ },
1942
+ /**
1943
+ * @param {object} body - '{ indicator, range }'.
1944
+ * @param {object} handlers - SSE handlers.
1945
+ * @returns {Promise<object>} completion.
1946
+ */
1947
+ explain(body, handlers = {}) {
1948
+ return stream('/ai/explain', { body: { ...body, stream: true }, ...handlers })
1949
+ },
1950
+ /**
1951
+ * @param {object} body - '{ range, indicators }'.
1952
+ * @param {object} handlers - SSE handlers.
1953
+ * @returns {Promise<object>} completion.
1954
+ */
1955
+ summary(body, handlers = {}) {
1956
+ return stream('/ai/summary', { body: { ...body, stream: true }, ...handlers })
1957
+ },
1958
+ /**
1959
+ * @param {object} body - '{ question, range }'.
1960
+ * @param {object} handlers - SSE handlers.
1961
+ * @returns {Promise<object>} completion.
1962
+ */
1963
+ ask(body, handlers = {}) {
1964
+ return stream('/ai/ask', { body: { ...body, stream: true }, ...handlers })
1965
+ },
1966
+ /**
1967
+ * @param {object} body - '{ text }'.
1968
+ * @param {AbortSignal} [signal] - cancellation.
1969
+ * @returns {Promise<object>} response.
1970
+ */
1971
+ propose(body, signal) {
1972
+ return request('/ai/propose', { method: 'POST', body, signal })
1973
+ },
1974
+ /**
1975
+ * Open (or continue) an independent DSH session about one indicator.
1976
+ *
1977
+ * @param {{ indicator?: string, group?: string, noteworthy?: string[], question?: string, range?: string, mode?: 'new'|'topic', limit?: number }} body - request; exactly one of 'indicator'/'group' names the subject.
1978
+ * @param {AbortSignal} [signal] - cancellation.
1979
+ * @returns {Promise<object>} response with `{ sessionId, reply, pending, mode }`.
1980
+ */
1981
+ discuss(body, signal) {
1982
+ // `wait: false` returns as soon as the session exists and the question is
1983
+ // queued: the answer streams in the session the GUI switches to, so the
1984
+ // panel never blocks for the length of a model turn.
1985
+ return request('/discuss', { method: 'POST', body: { wait: false, ...body }, signal, timeoutMs: 30_000 })
1986
+ },
1987
+ /**
1988
+ * Analyse the panel as a whole, or one group of it.
1989
+ *
1990
+ * @param {{ range?: string, groups?: string[] }} body - request.
1991
+ * @param {AbortSignal} [signal] - cancellation.
1992
+ * @returns {Promise<object>} response with `{ markdown, mode, scope, indicators, noteworthy }`.
1993
+ */
1994
+ aiOverview(body, signal) {
1995
+ return request('/ai/overview', { method: 'POST', body, signal, timeoutMs: 120_000 })
1996
+ },
1997
+ /**
1998
+ * Open a session in the plugin workspace to add a data source.
1999
+ *
2000
+ * @param {{ request?: string, reasons?: string[], question?: string }} body - request.
2001
+ * @param {AbortSignal} [signal] - cancellation.
2002
+ * @returns {Promise<object>} response with `{ sessionId }`.
2003
+ */
2004
+ iterate(body, signal) {
2005
+ return request('/iterate', { method: 'POST', body, signal, timeoutMs: 30_000 })
2006
+ },
2007
+ /**
2008
+ * Collect the answer a discussion session produced.
2009
+ *
2010
+ * Opening a discussion returns before the model finishes, so the panel asks
2011
+ * for the text on a short poll instead of leaving its block empty while the
2012
+ * answer streams in a session the reader has to go and find.
2013
+ *
2014
+ * @param {string} sessionId - session id from `discuss()`.
2015
+ * @param {AbortSignal} [signal] - cancellation.
2016
+ * @returns {Promise<object>} response with `{ status, reply?, error? }`.
2017
+ */
2018
+ discussAnswer(sessionId, signal) {
2019
+ return request(`/discuss/answer?session=${encodeURIComponent(sessionId)}`, { signal, timeoutMs: 10_000 })
2020
+ },
2021
+ /**
2022
+ * The mount's effective configuration, for the settings screen.
2023
+ *
2024
+ * @param {AbortSignal} [signal] - cancellation.
2025
+ * @returns {Promise<object>} response.
2026
+ */
2027
+ settings(signal) {
2028
+ return request('/settings', { signal, timeoutMs: 10_000 })
2029
+ },
2030
+ /**
2031
+ * @param {AbortSignal} [signal] - cancellation.
2032
+ * @returns {Promise<object>} response.
2033
+ */
2034
+ health({ probe, ids, range } = {}, signal) {
2035
+ const query = []
2036
+ if (probe === true) query.push('probe=1')
2037
+ if (Array.isArray(ids) && ids.length > 0) query.push(`ids=${encodeURIComponent(ids.join(','))}`)
2038
+ if (range !== undefined) query.push(`range=${encodeURIComponent(range)}`)
2039
+ // A probe re-fetches upstream series, so it needs a longer budget than a
2040
+ // cached read.
2041
+ return request(`/health${query.length === 0 ? '' : `?${query.join('&')}`}`, { signal, timeoutMs: probe === true ? 60_000 : REQUEST_TIMEOUT_MS })
2042
+ },
2043
+ }
2044
+
2045
+ // ── src/client/store.js ─────────────────────────────────────────
2046
+ /**
2047
+ * The panel's module-level store (docs/05 §2).
2048
+ *
2049
+ * State lives here rather than in component state because the overlay entry can
2050
+ * be remounted at any time: facts must belong to whoever can also close them. The
2051
+ * store is a plain observable with a tiny subscription API, which makes it
2052
+ * testable without React.
2053
+ *
2054
+ * @module client/store
2055
+ */
2056
+
2057
+ /** The initial state. A single object keeps snapshots comparable by identity. */
2058
+ const INITIAL_STATE = {
2059
+ open: false,
2060
+ tab: 'today',
2061
+ range: '1Y',
2062
+ groups: ['US', 'CN', 'GLOBAL', 'CUSTOM'],
2063
+ status: 'idle',
2064
+ lastUpdatedAt: undefined,
2065
+ error: undefined,
2066
+ overview: undefined,
2067
+ detail: undefined,
2068
+ detailLoading: false,
2069
+ // Bar size for OHLC sources: 'day' | 'week' | 'month'.
2070
+ barSize: 'day',
2071
+ ai: { mode: 'deterministic', text: '', streaming: false, result: undefined, error: undefined },
2072
+ discussing: false,
2073
+ discussingId: undefined,
2074
+ discussError: undefined,
2075
+ discussSession: undefined,
2076
+ discussAnswer: undefined,
2077
+ discussPending: false,
2078
+ /** The question this discussion's turn asked, and whether the panel asked it. */
2079
+ discussQuestion: undefined,
2080
+ discussAutoQuestion: false,
2081
+ /** Multi-select: indicator ids picked for one combined analysis. */
2082
+ selected: [],
2083
+ selectionLoading: false,
2084
+ watchlist: [],
2085
+ // Filled from '/settings' + '/health' when the settings tab opens; the panel
2086
+ // shows a loading line until then rather than invented defaults.
2087
+ settings: { sources: [] },
2088
+ health: undefined,
2089
+ /** Connection-test state for the settings screen. */
2090
+ testing: false,
2091
+ testingId: undefined,
2092
+ testResult: undefined,
2093
+ unreachable: false,
2094
+ }
2095
+
2096
+ /**
2097
+ * Create a store.
2098
+ *
2099
+ * @param {object} [initial] - initial state overrides.
2100
+ * @returns {object} store handle.
2101
+ */
2102
+ function createStore(initial = {}) {
2103
+ let state = { ...INITIAL_STATE, ...initial }
2104
+ /** @type {Set<(state: object, prev: object) => void>} */
2105
+ const listeners = new Set()
2106
+
2107
+ /**
2108
+ * Subscribe to state changes.
2109
+ *
2110
+ * @param {(state: object, prev: object) => void} listener - listener.
2111
+ * @returns {() => void} unsubscribe.
2112
+ */
2113
+ function subscribe(listener) {
2114
+ listeners.add(listener)
2115
+ return () => listeners.delete(listener)
2116
+ }
2117
+
2118
+ /**
2119
+ * Read the current state (stable identity between changes).
2120
+ *
2121
+ * @returns {object} state.
2122
+ */
2123
+ function getState() {
2124
+ return state
2125
+ }
2126
+
2127
+ /**
2128
+ * Apply a patch (object or updater) and notify listeners.
2129
+ *
2130
+ * @param {object|((state: object) => object)} patch - patch.
2131
+ * @returns {object} the new state.
2132
+ */
2133
+ function setState(patch) {
2134
+ const prev = state
2135
+ const next = typeof patch === 'function' ? patch(state) : patch
2136
+ if (next === undefined || next === null) return state
2137
+ // A no-op patch must not notify: components would re-render for nothing.
2138
+ const changed = Object.keys(next).some((key) => next[key] !== state[key])
2139
+ if (!changed) return state
2140
+ state = { ...state, ...next }
2141
+ for (const listener of listeners) listener(state, prev)
2142
+ return state
2143
+ }
2144
+
2145
+ return { getState, setState, subscribe, reset: () => setState({ ...INITIAL_STATE }) }
2146
+ }
2147
+
2148
+ /**
2149
+ * Merge a fresh overview payload into the state.
2150
+ *
2151
+ * @param {object} state - current state.
2152
+ * @param {object} payload - '/overview' response.
2153
+ * @returns {object} state patch.
2154
+ */
2155
+ function applyOverview(state, payload) {
2156
+ return {
2157
+ status: 'ready',
2158
+ overview: payload,
2159
+ lastUpdatedAt: payload.generatedAt,
2160
+ error: undefined,
2161
+ unreachable: false,
2162
+ }
2163
+ }
2164
+
2165
+ /**
2166
+ * Merge a fresh detail payload into the state.
2167
+ *
2168
+ * @param {object} state - current state.
2169
+ * @param {object} payload - '/series' response.
2170
+ * @returns {object} state patch.
2171
+ */
2172
+ function applyDetail(state, payload) {
2173
+ return { detail: payload, detailLoading: false, error: undefined }
2174
+ }
2175
+
2176
+ /**
2177
+ * Forget everything that belonged to the *previous* indicator's answer.
2178
+ *
2179
+ * The AI block, the question box and a discussion are all about one indicator.
2180
+ * Keeping them when the reader selects another card showed one indicator's
2181
+ * answer (and its "open in a new session" note) under a different indicator's
2182
+ * numbers, which reads as the panel answering the wrong question.
2183
+ *
2184
+ * @param {object} state - current state.
2185
+ * @param {string|undefined} indicatorId - the indicator now selected.
2186
+ * @returns {object} state patch.
2187
+ */
2188
+ function applyIndicatorChange(state, indicatorId) {
2189
+ if (state.detail?.indicatorId === indicatorId) return {}
2190
+ return {
2191
+ indicatorId,
2192
+ ai: { ...INITIAL_STATE.ai },
2193
+ discussing: false,
2194
+ discussingId: undefined,
2195
+ discussError: undefined,
2196
+ discussSession: undefined,
2197
+ discussAnswer: undefined,
2198
+ discussPending: false,
2199
+ discussQuestion: undefined,
2200
+ discussAutoQuestion: false,
2201
+ }
2202
+ }
2203
+
2204
+ /**
2205
+ * Merge an SSE text chunk into the streaming answer.
2206
+ *
2207
+ * @param {object} state - current state.
2208
+ * @param {string} chunk - text chunk.
2209
+ * @returns {object} state patch.
2210
+ */
2211
+ function applyAiText(state, chunk) {
2212
+ return { ai: { ...state.ai, streaming: true, text: `${state.ai.text}${chunk}` } }
2213
+ }
2214
+
2215
+ /**
2216
+ * Merge a finished AI result.
2217
+ *
2218
+ * @param {object} state - current state.
2219
+ * @param {object} result - validated 'AiResult'.
2220
+ * @returns {object} state patch.
2221
+ */
2222
+ function applyAiResult(state, result) {
2223
+ return {
2224
+ ai: {
2225
+ ...state.ai,
2226
+ streaming: false,
2227
+ result,
2228
+ text: result?.markdown ?? state.ai.text,
2229
+ mode: result?.mode ?? state.ai.mode,
2230
+ error: undefined,
2231
+ },
2232
+ }
2233
+ }
2234
+
2235
+ /**
2236
+ * Record a failed request.
2237
+ *
2238
+ * @param {object} state - current state.
2239
+ * @param {object} error - structured error.
2240
+ * @param {{ unreachable?: boolean }} [options] - options.
2241
+ * @returns {object} state patch.
2242
+ */
2243
+ function applyError(state, error, { unreachable = false } = {}) {
2244
+ return {
2245
+ status: unreachable ? 'unreachable' : 'error',
2246
+ error,
2247
+ unreachable,
2248
+ detailLoading: false,
2249
+ // Bar size for OHLC sources: 'day' | 'week' | 'month'.
2250
+ barSize: 'day',
2251
+ ai: { ...state.ai, streaming: false, error: undefined },
2252
+ }
2253
+ }
2254
+
2255
+ /**
2256
+ * Whether the panel should show its "some sources failed" banner.
2257
+ *
2258
+ * @param {object} state - state.
2259
+ * @returns {boolean} banner visibility.
2260
+ */
2261
+ function shouldShowDegradedBanner(state) {
2262
+ if (state.overview === undefined) return false
2263
+ return state.overview.degraded === true || (state.overview.errors ?? []).length > 0
2264
+ }
2265
+
2266
+ // ── src/client/components.js ─────────────────────────────────────────
2267
+ /**
2268
+ * Panel components, written with 'React.createElement' (docs/05 §2).
2269
+ *
2270
+ * The browser half is a classic script with no transform step, so JSX and hooks
2271
+ * beyond 'useState'/'useEffect'/'useMemo'/'useRef' are off the table; the tests
2272
+ * assert structure ("label, value, unit and source badge all reach the DOM")
2273
+ * rather than pixels.
2274
+ *
2275
+ * @module client/components
2276
+ */
2277
+
2278
+ /** How often the panel asks a discussion session for its answer. */
2279
+ const DISCUSSION_POLL_INTERVAL_MS = 1500
2280
+ /** How long it keeps asking before giving up (a model turn can be slow). */
2281
+ const DISCUSSION_POLL_MS = 180_000
2282
+
2283
+ /** CSS scoped under the panel root, using only theme tokens with fallbacks. */
2284
+ const PANEL_CSS = `
2285
+ .smd-root{position:fixed;right:18px;bottom:18px;z-index:60;pointer-events:none;font-size:13px}
2286
+ .smd-root *{box-sizing:border-box}
2287
+ .smd-trigger{pointer-events:auto;display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:10px;
2288
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));background:var(--dsw-alias-bg-overlay, #fff);
2289
+ color:var(--dsw-alias-label-primary, #111);cursor:pointer;box-shadow:var(--dsw-shadow-lv3, 0 6px 24px rgba(0,0,0,.18))}
2290
+ .smd-trigger:hover{color:var(--dsw-alias-brand-primary, #3b6cff)}
2291
+ .smd-badge{background:var(--dsw-alias-button-primary-fill, #3b6cff);color:var(--dsw-alias-label-primary-foreground, #fff);border-radius:9px;padding:0 6px;font-size:11px;line-height:16px}
2292
+ .smd-panel{pointer-events:auto;position:fixed;right:18px;bottom:66px;width:min(960px, calc(100vw - 36px));
2293
+ max-height:min(78vh, 900px);display:flex;flex-direction:column;border-radius:14px;overflow:hidden;
2294
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));background:var(--dsw-alias-bg-overlay, #fff);
2295
+ color:var(--dsw-alias-label-primary, #111);box-shadow:var(--dsw-shadow-lv3, 0 10px 40px rgba(0,0,0,.22))}
2296
+ .smd-header{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.2))}
2297
+ .smd-title{font-weight:600}
2298
+ .smd-sub{color:var(--dsw-alias-label-secondary, #666);font-size:11px}
2299
+ .smd-spacer{flex:1}
2300
+ .smd-btn{background:transparent;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.25));color:inherit;
2301
+ border-radius:8px;padding:3px 8px;cursor:pointer;font-size:12px}
2302
+ .smd-btn:hover{border-color:var(--dsw-alias-brand-primary, #3b6cff);color:var(--dsw-alias-brand-primary, #3b6cff)}
2303
+ .smd-btn[aria-pressed="true"]{background:var(--dsw-alias-button-primary-fill, #3b6cff);color:var(--dsw-alias-label-primary-foreground, #fff);border-color:transparent;font-weight:600}
2304
+ .smd-btn[aria-pressed="true"]:hover{background:var(--dsw-alias-button-primary-hover, #2f5ae0);color:var(--dsw-alias-label-primary-foreground, #fff)}
2305
+ .smd-tabs{display:flex;gap:6px;flex-wrap:wrap}
2306
+ .smd-body{overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
2307
+ .smd-banner{border-radius:10px;padding:8px 10px;background:var(--dsw-alias-state-warn-primary, #b8860b1a);border:1px solid var(--dsw-alias-state-warn-primary, #b8860b)}
2308
+ .smd-bannerError{background:var(--dsw-alias-state-error-primary, #b000201a);border-color:var(--dsw-alias-state-error-primary, #b00020)}
2309
+ .smd-sectionTitle{font-weight:600;margin:2px 0 6px}
2310
+ .smd-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:8px}
2311
+ .smd-iterate{display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.22));padding-top:8px}
2312
+ .smd-selectBar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 8px;border-radius:10px;
2313
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06));font-size:12px}
2314
+ .smd-section{display:flex;flex-direction:column;gap:8px}
2315
+ .smd-groupTitle{font-weight:600;font-size:12px;letter-spacing:.02em}
2316
+ .smd-cardOn{border-color:var(--dsw-alias-button-primary-fill, #3b6cff)}
2317
+ .smd-pick{display:flex;align-items:center;padding:0 2px 0 0}
2318
+ .smd-cardBody{display:flex;flex-direction:column;gap:4px;background:transparent;border:0;color:inherit;text-align:left;cursor:pointer;width:100%;padding:0}
2319
+ .smd-card{border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.22));border-radius:10px;padding:8px 10px;
2320
+ background:var(--dsw-alias-bg-layer-1, #fafafa);display:flex;flex-direction:row;gap:6px;align-items:flex-start;text-align:left}
2321
+ .smd-card:has(.smd-pick:hover){border-color:var(--dsw-alias-brand-primary, #3b6cff)}
2322
+ .smd-card:hover{border-color:var(--dsw-alias-brand-primary, #3b6cff)}
2323
+ .smd-cardHead{display:flex;align-items:center;gap:6px}
2324
+ .smd-label{font-weight:600;font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2325
+ .smd-dot{width:7px;height:7px;border-radius:50%;flex:none}
2326
+ .smd-dotUp{background:var(--dsw-alias-state-success-primary, #12805c)}
2327
+ .smd-dotWarn{background:var(--dsw-alias-state-warn-primary, #b8860b)}
2328
+ .smd-dotError{background:var(--dsw-alias-state-error-primary, #b00020)}
2329
+ .smd-dotNeutral{background:var(--dsw-alias-label-secondary, #888)}
2330
+ .smd-value{font-size:18px;font-variant-numeric:tabular-nums}
2331
+ .smd-unit{font-size:11px;color:var(--dsw-alias-label-secondary, #666);margin-left:3px}
2332
+ .smd-change{font-size:11px;font-variant-numeric:tabular-nums}
2333
+ .smd-up{color:var(--dsw-alias-state-success-primary, #12805c)}
2334
+ .smd-down{color:var(--dsw-alias-state-error-primary, #b00020)}
2335
+ .smd-neutral{color:var(--dsw-alias-label-secondary, #666)}
2336
+ .smd-footer{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
2337
+ .smd-src{font-size:10px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.25));border-radius:6px;
2338
+ padding:0 5px;color:var(--dsw-alias-label-secondary, #666);text-decoration:none}
2339
+ .smd-src:hover{color:var(--dsw-alias-brand-primary, #3b6cff)}
2340
+ .smd-spark{display:block}
2341
+ .smd-note{font-size:11px;color:var(--dsw-alias-label-secondary, #666)}
2342
+ .smd-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
2343
+ .smd-noteItem{border-left:3px solid var(--dsw-alias-brand-primary, #3b6cff);padding-left:8px}
2344
+ .smd-detail{display:flex;flex-direction:column;gap:10px}
2345
+ .smd-chart{display:flex;flex-direction:column;gap:6px}
2346
+ .smd-chartBar{display:flex;align-items:center;gap:8px;min-height:22px}
2347
+ .smd-chartHint{font-size:11px;color:var(--dsw-alias-label-secondary, #888)}
2348
+ .smd-chartReadout{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary, #111);font-weight:600}
2349
+ .smd-chipBtn{font-size:10px;line-height:16px;padding:1px 7px;border-radius:999px;cursor:pointer;color:inherit;
2350
+ border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));background:transparent}
2351
+ .smd-chipBtnOn{background:var(--dsw-alias-interactive-bg-active, #eef1f6);border-color:var(--dsw-alias-border-l2, rgba(127,127,127,.45));font-weight:600}
2352
+ .smd-btnInline{margin-left:6px;font-size:11px;padding:1px 6px}
2353
+ .smd-chartPlot{position:relative;width:100%}
2354
+ .smd-svg{display:block;width:100%;height:auto;color:var(--dsw-alias-brand-primary, #3b6cff);touch-action:none}
2355
+ .smd-gridLine{stroke:currentColor;stroke-opacity:.10}
2356
+ .smd-zero{stroke:currentColor;stroke-opacity:.35}
2357
+ .smd-ref{stroke:currentColor;stroke-opacity:.28}
2358
+ .smd-refLabel,.smd-axis{font-size:9px;fill:var(--dsw-alias-label-secondary, #777)}
2359
+ .smd-hitArea{cursor:crosshair}
2360
+ .smd-series{color:var(--dsw-alias-brand-primary, #3b6cff)}
2361
+ .smd-line{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round}
2362
+ .smd-area{fill:url(#smd-area);stroke:none}
2363
+ .smd-bar{fill:currentColor;fill-opacity:.75}
2364
+ .smd-candle line{stroke:currentColor;stroke-width:1}
2365
+ .smd-candle rect{fill:currentColor}
2366
+ .smd-candleHollow rect{fill:var(--dsw-alias-bg-base, #fff);stroke:currentColor;stroke-width:1}
2367
+ .smd-cross line{stroke:currentColor;stroke-opacity:.45;stroke-dasharray:3 3}
2368
+ .smd-cross circle{fill:currentColor;stroke:var(--dsw-alias-bg-base, #fff);stroke-width:1.4}
2369
+ .smd-tip{position:absolute;top:4px;transform:translateX(8px);pointer-events:none;background:var(--dsw-alias-bg-overlay, #fff);
2370
+ border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px;padding:4px 7px;font-size:11px;
2371
+ box-shadow:var(--dsw-shadow-lv2, 0 2px 10px rgba(0,0,0,.12));color:var(--dsw-alias-label-primary, #111);min-width:96px}
2372
+ .smd-tipLeft{transform:translateX(calc(-100% - 8px))}
2373
+ .smd-tipDate{color:var(--dsw-alias-label-secondary, #666);font-size:10px}
2374
+ .smd-tipValue{font-weight:600;font-variant-numeric:tabular-nums}
2375
+ .smd-tipRow{display:grid;grid-template-columns:auto auto auto auto auto auto;gap:0 5px;color:var(--dsw-alias-label-secondary, #666);font-variant-numeric:tabular-nums}
2376
+ .smd-stats{display:grid;grid-template-columns:repeat(auto-fill, minmax(120px, 1fr));gap:6px;font-size:12px}
2377
+ .smd-statKey{color:var(--dsw-alias-label-secondary, #666);font-size:11px}
2378
+ .smd-table{width:100%;border-collapse:collapse;font-size:12px}
2379
+ .smd-table th,.smd-table td{text-align:left;padding:3px 6px;border-bottom:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.18))}
2380
+ .smd-ai{border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.22));border-radius:10px;padding:8px 10px;display:flex;flex-direction:column;gap:6px}
2381
+ .smd-aiText{white-space:pre-wrap;font-size:12px;line-height:1.6}
2382
+ .smd-discussAnswer{display:flex;flex-direction:column;gap:4px;border-top:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.22));padding-top:6px}
2383
+ .smd-setRow{display:grid;grid-template-columns:minmax(96px, 130px) minmax(80px, 1fr) minmax(120px, 1.4fr);gap:4px 10px;align-items:baseline;
2384
+ padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.14));font-size:12px}
2385
+ .smd-setLabel{color:var(--dsw-alias-label-secondary, #666)}
2386
+ .smd-setValue{font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
2387
+ .smd-setHint{color:var(--dsw-alias-label-tertiary, #888);font-size:11px}
2388
+ .smd-discuss{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
2389
+ border-radius:10px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))}
2390
+ .smd-chips{display:flex;gap:6px;flex-wrap:wrap}
2391
+ .smd-chip{font-size:10px;border-radius:6px;padding:1px 5px;background:var(--dsw-alias-bg-layer-2, #eee);color:var(--dsw-alias-label-secondary, #555)}
2392
+ .smd-input{width:100%;padding:5px 7px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
2393
+ background:var(--dsw-alias-bg-base, #fff);color:inherit;font-size:12px}
2394
+ .smd-foot{font-size:11px;color:var(--dsw-alias-label-secondary, #666);border-top:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.2));padding:8px 12px}
2395
+ .smd-row{display:flex;align-items:center;gap:8px}
2396
+ `
2397
+
2398
+ /**
2399
+ * Inject the panel's stylesheet once.
2400
+ *
2401
+ * @param {Document} doc - host document.
2402
+ * @param {string} [css] - stylesheet text.
2403
+ * @returns {() => void} disposer removing the tag.
2404
+ */
2405
+ function insertStyles(doc, css = PANEL_CSS) {
2406
+ const tagId = 'show-me-data/panel.css'
2407
+ if (doc.querySelector(`style[data-plugin-css="${tagId}"]`) !== null) return () => {}
2408
+ const tag = doc.createElement('style')
2409
+ tag.dataset.plugin = 'show-me-data'
2410
+ tag.dataset.pluginCss = tagId
2411
+ tag.textContent = css
2412
+ doc.head.appendChild(tag)
2413
+ return () => {
2414
+ if (tag.parentNode !== null) tag.parentNode.removeChild(tag)
2415
+ }
2416
+ }
2417
+
2418
+ /**
2419
+ * Create the component set for one React instance.
2420
+ *
2421
+ * @param {object} React - React (or the test stub).
2422
+ * @param {object} deps - dependencies.
2423
+ * @param {object} deps.store - panel store.
2424
+ * @param {object} deps.api - API client.
2425
+ * @param {object} deps.copy - copy table.
2426
+ * @param {object} deps.format - formatting helpers ('core/format' plus the chart builders).
2427
+ * @returns {Record<string, Function>} components.
2428
+ */
2429
+ function createComponents(React, { store, api, copy, format, sessions, applyIndicatorChange = () => ({}), pollIntervalMs, pollTimeoutMs }) {
2430
+ const { createElement: h, useState, useEffect, useMemo, useRef } = React
2431
+ const {
2432
+ buildLinePath,
2433
+ buildBarRects,
2434
+ buildSparklineShape,
2435
+ buildXAxis,
2436
+ buildYAxis,
2437
+ buildZeroLine,
2438
+ buildTimeAxis,
2439
+ buildCandles,
2440
+ buildCrosshair,
2441
+ buildReferenceLines,
2442
+ nearestIndex,
2443
+ valueDomain,
2444
+ hasOhlc,
2445
+ linearScale,
2446
+ niceTicks,
2447
+ formatValue,
2448
+ formatChange,
2449
+ formatAge,
2450
+ changeColor,
2451
+ statusDot,
2452
+ metricTooltip,
2453
+ truncate,
2454
+ } = format
2455
+
2456
+ /**
2457
+ * A small inline sparkline.
2458
+ *
2459
+ * @param {{ values: number[], width?: number, height?: number }} props - props.
2460
+ * @returns {any} element.
2461
+ */
2462
+ function Sparkline({ values, width = 88, height = 22 }) {
2463
+ const { path, dot } = buildSparklineShape(values ?? [], { width, height, padding: 1 })
2464
+ if (path === '') return h('span', { className: 'smd-note' }, '—')
2465
+ return h(
2466
+ 'svg',
2467
+ { className: 'smd-spark', width, height, viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': '走势' },
2468
+ h('path', { d: path, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2 }),
2469
+ dot === null ? null : h('circle', { cx: dot.cx, cy: dot.cy, r: dot.r, fill: 'currentColor' }),
2470
+ )
2471
+ }
2472
+
2473
+ /**
2474
+ * One metric card.
2475
+ *
2476
+ * @param {{ metric: object, onOpen: Function }} props - props.
2477
+ * @returns {any} element.
2478
+ */
2479
+ function MetricCard({ metric, onOpen, selected, onToggleSelect }) {
2480
+ const dot = statusDot(metric.status)
2481
+ const decimals = metric.display?.decimals ?? 2
2482
+ const color = changeColor(metric.changeAbs, metric.display?.polarity)
2483
+ const dotClass = { up: 'smd-dotUp', warn: 'smd-dotWarn', error: 'smd-dotError', neutral: 'smd-dotNeutral' }[dot.color]
2484
+ return h(
2485
+ 'div',
2486
+ {
2487
+ className: `smd-card${selected === true ? ' smd-cardOn' : ''}`,
2488
+ },
2489
+ // A nested button cannot hold the checkbox, so selection lives beside the
2490
+ // card's own clickable surface rather than inside it.
2491
+ onToggleSelect === undefined
2492
+ ? null
2493
+ : h(
2494
+ 'label',
2495
+ { className: 'smd-pick', title: copy.selectHint },
2496
+ h('input', { type: 'checkbox', checked: selected === true, onChange: () => onToggleSelect(metric.indicatorId) }),
2497
+ ),
2498
+ h(
2499
+ 'button',
2500
+ {
2501
+ type: 'button',
2502
+ className: 'smd-cardBody',
2503
+ title: metricTooltip(metric),
2504
+ onClick: () => onOpen?.(metric.indicatorId),
2505
+ },
2506
+ h(
2507
+ 'div',
2508
+ { className: 'smd-cardHead' },
2509
+ h('span', { className: 'smd-label' }, metric.label?.zh ?? metric.indicatorId),
2510
+ metric.seasonal !== undefined && metric.seasonal !== 'NA' ? h('span', { className: 'smd-chip' }, metric.seasonal) : null,
2511
+ h('span', { className: `smd-dot ${dotClass}`, 'aria-label': dot.label }),
2512
+ ),
2513
+ metric.status === 'error'
2514
+ ? h('div', { className: 'smd-note' }, `${copy.errorHint}(${metric.errorKind ?? 'unknown'})`)
2515
+ : metric.status === 'missing'
2516
+ ? h('div', { className: 'smd-note' }, copy.missingHint)
2517
+ : h(
2518
+ 'div',
2519
+ { className: 'smd-value' },
2520
+ formatValue(metric.latest, { decimals }),
2521
+ h('span', { className: 'smd-unit' }, metric.unit),
2522
+ ),
2523
+ metric.status === 'fresh' || metric.status === 'stale'
2524
+ ? h('div', { className: `smd-change smd-${color}` }, formatChange(metric.changeAbs, metric.changePct, '', decimals))
2525
+ : null,
2526
+ h('div', { className: 'smd-footer' }, h(Sparkline, { values: metric.sparkline })),
2527
+ h(
2528
+ 'div',
2529
+ { className: 'smd-footer' },
2530
+ metric.sourceRef?.url === undefined
2531
+ ? null
2532
+ : h(
2533
+ 'a',
2534
+ {
2535
+ className: 'smd-src',
2536
+ href: metric.sourceRef.url,
2537
+ target: '_blank',
2538
+ rel: 'noreferrer noopener',
2539
+ onClick: (event) => event.stopPropagation(),
2540
+ title: copy.viewSource,
2541
+ },
2542
+ `${copy.sourceBadge}: ${metric.sourceRef.label ?? metric.sourceRef.adapterId}`,
2543
+ ),
2544
+ h('span', { className: 'smd-note' }, metric.latestAt ?? ''),
2545
+ metric.status === 'stale' ? h('span', { className: 'smd-chip' }, copy.fromCache) : null,
2546
+ ),
2547
+ ),
2548
+ )
2549
+ }
2550
+
2551
+ /**
2552
+ * The noteworthy list.
2553
+ *
2554
+ * @param {{ items: object[], onOpen: Function }} props - props.
2555
+ * @returns {any} element.
2556
+ */
2557
+ function NoteworthyList({ items, onOpen, onDiscuss, discussingId }) {
2558
+ if ((items ?? []).length === 0) return h('div', { className: 'smd-note' }, copy.noteworthyEmpty)
2559
+ return h(
2560
+ 'ul',
2561
+ { className: 'smd-list' },
2562
+ items.map((item) =>
2563
+ h(
2564
+ 'li',
2565
+ { className: 'smd-noteItem', key: item.indicatorId },
2566
+ h(
2567
+ 'button',
2568
+ { type: 'button', className: 'smd-btn', onClick: () => onOpen?.(item.indicatorId) },
2569
+ `${copy.viewDetail} ${item.label?.zh ?? item.indicatorId}`,
2570
+ ),
2571
+ h('span', { className: 'smd-note' }, ` 关注分 ${item.score}`),
2572
+ // The whole point of this list is "what moved and why", so the row
2573
+ // carries the action that answers it: a session seeded with this
2574
+ // indicator, the rule that fired, and the panel numbers behind it.
2575
+ onDiscuss === undefined
2576
+ ? null
2577
+ : h(
2578
+ 'button',
2579
+ {
2580
+ type: 'button',
2581
+ className: 'smd-btn smd-btnInline',
2582
+ title: copy.noteworthyDiscussHint,
2583
+ disabled: discussingId === item.indicatorId,
2584
+ onClick: () => onDiscuss(item.indicatorId),
2585
+ },
2586
+ discussingId === item.indicatorId ? copy.discussing : copy.noteworthyDiscuss,
2587
+ ),
2588
+ h(
2589
+ 'div',
2590
+ { className: 'smd-note' },
2591
+ (item.reasons ?? []).map((reason) => reason.reason?.zh ?? reason.ruleId).join(';'),
2592
+ ),
2593
+ item.sourceRef?.url === undefined
2594
+ ? null
2595
+ : h(
2596
+ 'a',
2597
+ { className: 'smd-src', href: item.sourceRef.url, target: '_blank', rel: 'noreferrer noopener' },
2598
+ item.sourceRef.label ?? copy.sourceBadge,
2599
+ ),
2600
+ ),
2601
+ ),
2602
+ )
2603
+ }
2604
+
2605
+ /**
2606
+ * The main chart: value axis, time axis, candles or a line, crosshair readout.
2607
+ *
2608
+ * Everything it draws comes from the pure builders in 'core/chart', so the
2609
+ * interactive parts are only React state: which index the pointer is over and
2610
+ * which view mode is selected. Hovering is index-snapped — the pointer never
2611
+ * has to land on a 1px line to read a value — and the readout states OHLC per
2612
+ * bar when the source supplies it.
2613
+ *
2614
+ * @param {{ points: object[], unit: string, decimals?: number, kind?: string, stats?: object, polarity?: string, onDiscuss?: Function, discussing?: boolean, height?: number, mode?: string }} props - props.
2615
+ * @returns {any} element.
2616
+ */
2617
+ function Chart({ points, unit, decimals = 2, kind = 'line', stats, polarity, height = 280, mode }) {
2618
+ const [showTable, setShowTable] = useState(false)
2619
+ const [hover, setHover] = useState(-1)
2620
+ const candlesAvailable = hasOhlc(points)
2621
+ const [view, setView] = useState(mode ?? (candlesAvailable && kind !== 'bar' ? 'candle' : kind === 'bar' ? 'bar' : 'area'))
2622
+ const svgRef = useRef(null)
2623
+ const width = 720
2624
+ const geometry = useMemo(() => ({ width, height, padLeft: 14, padRight: 62, padTop: 16, padBottom: 30 }), [height])
2625
+
2626
+ const chart = useMemo(() => {
2627
+ const list = points ?? []
2628
+ if (list.length === 0) return undefined
2629
+ const useExtremes = view === 'candle'
2630
+ const raw = valueDomain(list, { useExtremes })
2631
+ if (raw === undefined) return undefined
2632
+ const axis = niceTicks(raw.min, raw.max, 5)
2633
+ const yDomain = { min: axis.min, max: axis.max }
2634
+ const plot = {
2635
+ x: geometry.padLeft,
2636
+ y: geometry.padTop,
2637
+ w: width - geometry.padLeft - geometry.padRight,
2638
+ h: height - geometry.padTop - geometry.padBottom,
2639
+ }
2640
+ const yScale = linearScale({ domain: [yDomain.min, yDomain.max], range: [plot.y + plot.h, plot.y] })
2641
+ const span = Math.max(1, list.length - 1)
2642
+ const xAt = (index) => plot.x + (index / span) * plot.w
2643
+ const pathFor = (entries, close) => {
2644
+ let d = ''
2645
+ let open = false
2646
+ entries.forEach((point, index) => {
2647
+ if (!Number.isFinite(point.v)) {
2648
+ open = false
2649
+ return
2650
+ }
2651
+ d += `${open ? 'L' : 'M'}${Number(xAt(index).toFixed(2))} ${Number(yScale(point.v).toFixed(2))} `
2652
+ open = true
2653
+ })
2654
+ return close && entries.length > 0 ? `${d}Z` : d.trim()
2655
+ }
2656
+ return {
2657
+ list,
2658
+ plot,
2659
+ yDomain,
2660
+ yTicks: axis.ticks.map((tick) => ({ value: tick, y: Number(yScale(tick).toFixed(2)), label: formatValue(tick, { decimals }) })),
2661
+ xTicks: buildTimeAxis(list.map((point) => point.t), { width, height, padX: plot.x, padY: plot.y, padRight: geometry.padRight, padBottom: geometry.padBottom, innerW: plot.w, innerH: plot.h }),
2662
+ candles: view === 'candle' ? buildCandles(list, { width, height, padX: plot.x, padY: plot.y, innerW: plot.w, innerH: plot.h, yDomain }) : [],
2663
+ line: pathFor(list, false),
2664
+ area: `${pathFor(list, false)}L${Number(xAt(list.length - 1).toFixed(2))} ${plot.y + plot.h} L${Number(xAt(0).toFixed(2))} ${plot.y + plot.h} Z`,
2665
+ bars: view === 'bar' ? buildBarRects(list, { width, height, padX: plot.x, padY: plot.y, innerW: plot.w, innerH: plot.h, yDomain }) : [],
2666
+ refs: buildReferenceLines(stats, yDomain, { plot }),
2667
+ zero: yDomain.min < 0 && yDomain.max > 0 ? Number(yScale(0).toFixed(2)) : null,
2668
+ }
2669
+ }, [points, decimals, view, stats, geometry, height, width])
2670
+
2671
+ if (chart === undefined) return h('div', { className: 'smd-note' }, copy.empty)
2672
+
2673
+ const toIndex = (event) => {
2674
+ const node = svgRef.current
2675
+ if (node === null || typeof node.getBoundingClientRect !== 'function') return -1
2676
+ const rect = node.getBoundingClientRect()
2677
+ if (rect.width <= 0) return -1
2678
+ const x = ((event.clientX - rect.left) / rect.width) * width
2679
+ return nearestIndex(x, chart.list.length, { width, padLeft: geometry.padLeft, padRight: geometry.padRight, padX: chart.plot.x, innerW: chart.plot.w })
2680
+ }
2681
+ const cross = hover < 0 ? undefined : buildCrosshair({
2682
+ points: chart.list,
2683
+ index: hover,
2684
+ geometry: { width, height, yDomain: chart.yDomain, padLeft: geometry.padLeft, padRight: geometry.padRight, padTop: geometry.padTop, padBottom: geometry.padBottom, padX: chart.plot.x, padY: chart.plot.y, innerW: chart.plot.w, innerH: chart.plot.h },
2685
+ })
2686
+
2687
+ const modes = [
2688
+ ...(candlesAvailable ? [['candle', copy.chartCandle]] : []),
2689
+ ['area', copy.chartArea],
2690
+ ['line', copy.chartLine],
2691
+ ['bar', copy.chartBar],
2692
+ ]
2693
+
2694
+ return h(
2695
+ 'div',
2696
+ { className: 'smd-chart' },
2697
+ h(
2698
+ 'div',
2699
+ { className: 'smd-chartBar' },
2700
+ h(
2701
+ 'span',
2702
+ { className: 'smd-chips' },
2703
+ modes.map(([id, label]) =>
2704
+ h(
2705
+ 'button',
2706
+ {
2707
+ key: id,
2708
+ type: 'button',
2709
+ className: `smd-chipBtn${view === id ? ' smd-chipBtnOn' : ''}`,
2710
+ 'aria-pressed': view === id,
2711
+ onClick: () => setView(id),
2712
+ },
2713
+ label,
2714
+ ),
2715
+ ),
2716
+ ),
2717
+ h('span', { className: 'smd-spacer' }),
2718
+ cross === undefined
2719
+ ? h('span', { className: 'smd-chartHint' }, copy.chartHint)
2720
+ : h('span', { className: 'smd-chartReadout' }, readout(cross, { unit, decimals, polarity })),
2721
+ ),
2722
+ h(
2723
+ 'div',
2724
+ { className: 'smd-chartPlot' },
2725
+ h(
2726
+ 'svg',
2727
+ {
2728
+ ref: svgRef,
2729
+ className: 'smd-svg',
2730
+ viewBox: `0 0 ${width} ${height}`,
2731
+ preserveAspectRatio: 'none',
2732
+ role: 'img',
2733
+ 'aria-label': `${copy.tabDetail} ${unit}`,
2734
+ onPointerMove: (event) => setHover(toIndex(event)),
2735
+ onPointerLeave: () => setHover(-1),
2736
+ onPointerDown: (event) => setHover(toIndex(event)),
2737
+ },
2738
+ h(
2739
+ 'defs',
2740
+ null,
2741
+ h(
2742
+ 'linearGradient',
2743
+ { id: 'smd-area', x1: '0', y1: '0', x2: '0', y2: '1' },
2744
+ h('stop', { offset: '0%', stopColor: 'currentColor', stopOpacity: 0.28 }),
2745
+ h('stop', { offset: '100%', stopColor: 'currentColor', stopOpacity: 0.02 }),
2746
+ ),
2747
+ ),
2748
+ chart.yTicks.map((tick) =>
2749
+ h('line', { key: `g${tick.value}`, className: 'smd-gridLine', x1: chart.plot.x, x2: chart.plot.x + chart.plot.w, y1: tick.y, y2: tick.y }),
2750
+ ),
2751
+ chart.zero === null ? null : h('line', { className: 'smd-zero', x1: chart.plot.x, x2: chart.plot.x + chart.plot.w, y1: chart.zero, y2: chart.zero }),
2752
+ chart.refs.map((ref) =>
2753
+ h(
2754
+ 'g',
2755
+ { key: `r${ref.kind}` },
2756
+ h('line', { className: 'smd-ref', x1: chart.plot.x, x2: chart.plot.x + chart.plot.w, y1: ref.y, y2: ref.y, strokeDasharray: '4 4' }),
2757
+ h('text', { className: 'smd-refLabel', x: chart.plot.x + chart.plot.w + 4, y: ref.y + 3 }, `${ref.label}${formatValue(ref.value, { decimals })}`),
2758
+ ),
2759
+ ),
2760
+ chart.yTicks.map((tick) => h('text', { key: `y${tick.value}`, className: 'smd-axis', x: chart.plot.x + chart.plot.w + 4, y: tick.y + 3 }, tick.label)),
2761
+ chart.xTicks.map((tick) => h('text', { key: `x${tick.t}`, className: 'smd-axis', x: tick.x, y: height - 10, textAnchor: tick.anchor }, tick.label)),
2762
+ view === 'candle'
2763
+ ? chart.candles.map((candle) =>
2764
+ h(
2765
+ 'g',
2766
+ { key: candle.t, className: `smd-candle ${changeColor(candle.point.v - candle.point.o, polarity)}` },
2767
+ h('line', { x1: candle.x, x2: candle.x, y1: candle.yHigh, y2: candle.yLow }),
2768
+ h('rect', { x: Number((candle.x - candle.bodyW / 2).toFixed(2)), y: candle.bodyTop, width: candle.bodyW, height: candle.bodyH, className: candle.hollow ? 'smd-candleHollow' : undefined }),
2769
+ ),
2770
+ )
2771
+ : view === 'bar'
2772
+ ? chart.bars.map((rect) => h('rect', { key: rect.t, className: 'smd-bar', x: rect.x, y: rect.y, width: Math.max(1, rect.w), height: rect.h }))
2773
+ : h(
2774
+ 'g',
2775
+ { className: 'smd-series' },
2776
+ view === 'area' ? h('path', { className: 'smd-area', d: chart.area }) : null,
2777
+ h('path', { className: 'smd-line', d: chart.line }),
2778
+ ),
2779
+ cross === undefined
2780
+ ? null
2781
+ : h(
2782
+ 'g',
2783
+ { className: 'smd-cross' },
2784
+ h('line', { x1: cross.x, x2: cross.x, y1: chart.plot.y, y2: chart.plot.y + chart.plot.h }),
2785
+ h('circle', { cx: cross.x, cy: cross.y, r: 3.2 }),
2786
+ chart.yTicks.map((tick) => h('line', { key: `h${tick.value}`, className: 'smd-crossH', x1: chart.plot.x, x2: chart.plot.x + chart.plot.w, y1: tick.y, y2: tick.y, strokeOpacity: 0 })),
2787
+ ),
2788
+ h('rect', { className: 'smd-hitArea', x: chart.plot.x, y: chart.plot.y, width: chart.plot.w, height: chart.plot.h, fill: 'transparent' }),
2789
+ ),
2790
+ cross === undefined
2791
+ ? null
2792
+ : h(
2793
+ 'div',
2794
+ { className: `smd-tip${cross.anchoredLeft ? ' smd-tipLeft' : ''}`, style: { left: `${(cross.x / width) * 100}%` } },
2795
+ h('div', { className: 'smd-tipDate' }, cross.point.t),
2796
+ cross.bar === undefined
2797
+ ? null
2798
+ : h(
2799
+ 'div',
2800
+ { className: 'smd-tipRow' },
2801
+ h('span', null, '开'), h('span', null, formatValue(cross.bar.o, { decimals })),
2802
+ h('span', null, '高'), h('span', null, formatValue(cross.bar.h, { decimals })),
2803
+ h('span', null, '低'), h('span', null, formatValue(cross.bar.l, { decimals })),
2804
+ ),
2805
+ h('div', { className: 'smd-tipValue' }, `${formatValue(cross.point.v, { decimals })}${unit ?? ''}`),
2806
+ ),
2807
+ ),
2808
+ h(
2809
+ 'div',
2810
+ { className: 'smd-chartBar' },
2811
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => setShowTable((value) => !value) }, showTable ? copy.hideDataTable : copy.showDataTable),
2812
+ h('span', { className: 'smd-spacer' }),
2813
+ h('span', { className: 'smd-note' }, `${(points ?? []).length} ${copy.pointsUnit}`),
2814
+ ),
2815
+ showTable
2816
+ ? h(
2817
+ 'table',
2818
+ { className: 'smd-table' },
2819
+ h('thead', null, h('tr', null, h('th', null, copy.date), h('th', null, copy.value))),
2820
+ h(
2821
+ 'tbody',
2822
+ null,
2823
+ (points ?? []).slice(-40).reverse().map((point) =>
2824
+ h('tr', { key: point.t }, h('td', null, point.t), h('td', null, formatValue(point.v, { decimals }))),
2825
+ ),
2826
+ ),
2827
+ )
2828
+ : null,
2829
+ )
2830
+ }
2831
+
2832
+ /**
2833
+ * The hover readout shown in the chart's header bar.
2834
+ *
2835
+ * @param {object} cross - crosshair state.
2836
+ * @param {{ unit?: string, decimals?: number, polarity?: string }} options - formatting.
2837
+ * @returns {string} readout text.
2838
+ */
2839
+ function readout(cross, { unit, decimals, polarity }) {
2840
+ const change = cross.bar === undefined ? undefined : cross.point.v - cross.bar.o
2841
+ const parts = [cross.point.t, `${formatValue(cross.point.v, { decimals })}${unit ?? ''}`]
2842
+ if (change !== undefined) parts.push(`${change >= 0 ? '+' : ''}${formatValue(change, { decimals })}`)
2843
+ return parts.join(' ')
2844
+ }
2845
+
2846
+ /**
2847
+ * The statistics strip.
2848
+ *
2849
+ * @param {{ stats: object, unit: string, decimals?: number }} props - props.
2850
+ * @returns {any} element.
2851
+ */
2852
+ function StatsStrip({ stats, unit, decimals = 2 }) {
2853
+ if (stats === undefined || stats === null) return h('div', { className: 'smd-note' }, copy.empty)
2854
+ const entries = [
2855
+ [copy.statsMean, formatValue(stats.mean, { decimals })],
2856
+ [copy.statsMin, formatValue(stats.min, { decimals })],
2857
+ [copy.statsMax, formatValue(stats.max, { decimals })],
2858
+ [copy.statsStdDev, formatValue(stats.stdDev, { decimals })],
2859
+ [copy.statsYoy, stats.yoy === undefined ? '—' : `${formatValue(stats.yoy, { decimals: 1 })}%`],
2860
+ [copy.statsMom, stats.mom === undefined ? '—' : `${formatValue(stats.mom, { decimals: 1 })}%`],
2861
+ [copy.statsPercentile, stats.percentile === undefined ? '—' : `${formatValue(stats.percentile * 100, { decimals: 0 })}%`],
2862
+ [copy.statsMissing, formatValue(stats.missingCount, { decimals: 0 })],
2863
+ ]
2864
+ return h(
2865
+ 'div',
2866
+ { className: 'smd-stats' },
2867
+ entries.map(([key, value]) =>
2868
+ h('div', { key, className: 'smd-stat' }, h('div', { className: 'smd-statKey' }, key), h('div', null, `${value}${unit === '' ? '' : ''}`)),
2869
+ ),
2870
+ )
2871
+ }
2872
+
2873
+ /**
2874
+ * The AI block: streamed text, citations and the mode chip.
2875
+ *
2876
+ * @param {{ ai: object, onAsk: Function, onSubmitQuestion: Function, question: string, onQuestionChange: Function }} props - props.
2877
+ * @returns {any} element.
2878
+ */
2879
+ function AiPanel({ ai, onAsk, question, onQuestionChange, onSubmitQuestion, onDiscuss, discussing, discussAnswer, discussPending, discussError, discussSession, onOpenSession }) {
2880
+ return h(
2881
+ 'div',
2882
+ { className: 'smd-ai' },
2883
+ h(
2884
+ 'div',
2885
+ { className: 'smd-row' },
2886
+ h('span', { className: 'smd-chips' }, h('span', { className: 'smd-chip' }, ai.mode === 'llm' ? copy.aiModeLlm : copy.aiModeDeterministic)),
2887
+ ai.result?.cached === true ? h('span', { className: 'smd-chip' }, copy.aiCached) : null,
2888
+ h('span', { className: 'smd-spacer' }),
2889
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onAsk?.() }, copy.ai),
2890
+ onDiscuss === undefined
2891
+ ? null
2892
+ : h(
2893
+ 'button',
2894
+ {
2895
+ type: 'button',
2896
+ className: 'smd-btn',
2897
+ title: copy.discussHint,
2898
+ disabled: discussing === true,
2899
+ onClick: () => onDiscuss?.(),
2900
+ },
2901
+ discussing === true ? copy.discussing : copy.discuss,
2902
+ ),
2903
+ ),
2904
+ ai.error === undefined ? null : h('div', { className: 'smd-banner smd-bannerError' }, ai.error.detail ?? String(ai.error)),
2905
+ discussError === undefined
2906
+ ? null
2907
+ : h(
2908
+ 'div',
2909
+ { className: 'smd-banner smd-bannerError smd-row' },
2910
+ h(
2911
+ 'span',
2912
+ null,
2913
+ // A session that exists but whose first turn failed is not "the
2914
+ // session is unavailable": the reader can still open it and read the
2915
+ // model's own error there.
2916
+ typeof discussSession === 'string' && discussSession !== ''
2917
+ ? `${copy.discussTurnFailed}:${discussError}`
2918
+ : `${copy.discussFailed}:${discussError}`,
2919
+ ),
2920
+ typeof discussSession === 'string' && discussSession !== ''
2921
+ ? h('button', { type: 'button', className: 'smd-btn', onClick: () => onOpenSession?.(discussSession) }, copy.discussOpenSession)
2922
+ : null,
2923
+ ),
2924
+ discussPending === true ? h('div', { className: 'smd-note' }, copy.discussOpened) : null,
2925
+ typeof discussAnswer === 'string' && discussAnswer !== ''
2926
+ ? h(
2927
+ 'div',
2928
+ { className: 'smd-discussAnswer' },
2929
+ h('div', { className: 'smd-note' }, copy.discussAnswer),
2930
+ h('div', { className: 'smd-aiBody' }, discussAnswer),
2931
+ )
2932
+ : null,
2933
+ ai.result?.violations !== undefined && ai.result.violations.length > 0
2934
+ ? h(
2935
+ 'div',
2936
+ { className: 'smd-banner' },
2937
+ `${copy.aiDegraded}${ai.result.degradedReason === undefined ? `(${ai.result.violations.length} 项)` : `:${ai.result.degradedReason}`}`,
2938
+ )
2939
+ : null,
2940
+ h(
2941
+ 'div',
2942
+ { className: 'smd-aiText' },
2943
+ ai.text === ''
2944
+ ? h(
2945
+ 'span',
2946
+ { className: 'smd-note' },
2947
+ // Only claim "no model configured" when that is actually the case:
2948
+ // an empty answer from a configured model is a different problem.
2949
+ ai.streaming ? copy.loading : ai.result?.degradedFrom === undefined ? copy.aiOffline : `${copy.aiEmptyAnswer}(${ai.result?.degradedReason ?? '模型未返回文本'})`,
2950
+ )
2951
+ : ai.text,
2952
+ ),
2953
+ ai.result?.insufficient === undefined
2954
+ ? null
2955
+ : h('div', { className: 'smd-note' }, `${copy.aiInsufficient}:${ai.result.insufficient}`),
2956
+ (ai.result?.usedPoints ?? []).length === 0
2957
+ ? null
2958
+ : h(
2959
+ 'div',
2960
+ { className: 'smd-note' },
2961
+ `${copy.aiCitations}(${ai.result.usedPoints.length}):`,
2962
+ ai.result.usedPoints
2963
+ .slice(0, 8)
2964
+ .map((point) => `${point.indicatorId}@${point.t}=${point.value ?? point.v}`)
2965
+ .join(';'),
2966
+ ),
2967
+ h(
2968
+ 'div',
2969
+ { className: 'smd-row' },
2970
+ h('input', {
2971
+ className: 'smd-input',
2972
+ value: question,
2973
+ placeholder: copy.aiAskPlaceholder,
2974
+ onChange: (event) => onQuestionChange?.(event.target.value),
2975
+ onKeyDown: (event) => {
2976
+ if (event.key === 'Enter') onSubmitQuestion?.()
2977
+ },
2978
+ }),
2979
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onSubmitQuestion?.() }, copy.aiAskSubmit),
2980
+ ),
2981
+ )
2982
+ }
2983
+
2984
+ /**
2985
+ * The detail drawer.
2986
+ *
2987
+ * @param {{ detail: object, loading: boolean, onClose: Function, ai: object, onAsk: Function }} props - props.
2988
+ * @returns {any} element.
2989
+ */
2990
+ function DetailDrawer({ detail, loading, onClose, ai, onAsk, question, onQuestionChange, onSubmitQuestion, onDiscuss, discussing, discussError, discussAnswer, discussPending, discussSession, onOpenSession, barSize, onBarSize }) {
2991
+ if (loading) return h('div', { className: 'smd-note' }, copy.loading)
2992
+ if (detail === undefined) return null
2993
+ if (detail.error !== undefined) {
2994
+ return h('div', { className: 'smd-banner smd-bannerError' }, `${copy.errorHint}(${detail.error.code ?? detail.error.kind})`)
2995
+ }
2996
+ const decimals = detail.display?.decimals ?? 2
2997
+ return h(
2998
+ 'div',
2999
+ { className: 'smd-detail' },
3000
+ h(
3001
+ 'div',
3002
+ { className: 'smd-row' },
3003
+ h('span', { className: 'smd-title' }, `${detail.label?.zh ?? detail.indicatorId}(${detail.indicatorId})`),
3004
+ h('span', { className: 'smd-spacer' }),
3005
+ h('span', { className: 'smd-chip' }, detail.unitLabel ?? detail.unit),
3006
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onClose?.() }, copy.close),
3007
+ ),
3008
+ // Bar size only matters for sources that publish OHLC; a macro series has
3009
+ // nothing to re-sample, so the control is hidden rather than inert.
3010
+ detail.barSizes === true
3011
+ ? h(
3012
+ 'div',
3013
+ { className: 'smd-row' },
3014
+ h('span', { className: 'smd-note' }, copy.barSize),
3015
+ h(
3016
+ 'span',
3017
+ { className: 'smd-chips' },
3018
+ [['day', copy.barDay], ['week', copy.barWeek], ['month', copy.barMonth]].map(([id, label]) =>
3019
+ h(
3020
+ 'button',
3021
+ {
3022
+ key: id,
3023
+ type: 'button',
3024
+ className: `smd-chipBtn${(barSize ?? 'day') === id ? ' smd-chipBtnOn' : ''}`,
3025
+ 'aria-pressed': (barSize ?? 'day') === id,
3026
+ onClick: () => onBarSize?.(id),
3027
+ },
3028
+ label,
3029
+ ),
3030
+ ),
3031
+ ),
3032
+ )
3033
+ : null,
3034
+ h(Chart, {
3035
+ points: detail.points ?? [],
3036
+ unit: detail.unit,
3037
+ decimals,
3038
+ stats: detail.stats,
3039
+ polarity: detail.display?.polarity,
3040
+ kind: detail.display?.transform === 'diff' ? 'bar' : 'line',
3041
+ }),
3042
+ h(StatsStrip, { stats: detail.stats, unit: detail.unit, decimals }),
3043
+ discussing === true ? h('div', { className: 'smd-note' }, copy.discussing) : null,
3044
+ // Every prop AiPanel reads must be forwarded: a missing callback makes the
3045
+ // input throw on its first keystroke and React aborts the render.
3046
+ h(AiPanel, { ai, onAsk, question, onQuestionChange, onSubmitQuestion, onDiscuss, discussing, discussAnswer, discussPending, discussError, discussSession, onOpenSession }),
3047
+ detail.sourceRef?.url === undefined
3048
+ ? null
3049
+ : h(
3050
+ 'a',
3051
+ { className: 'smd-src', href: detail.sourceRef.url, target: '_blank', rel: 'noreferrer noopener' },
3052
+ `${copy.viewSource}:${detail.sourceRef.label ?? detail.sourceRef.adapterId ?? ''} ${detail.sourceRef.url}`,
3053
+ ),
3054
+ )
3055
+ }
3056
+
3057
+ /**
3058
+ * Settings tab: mount checklist, AI mode, source switches.
3059
+ *
3060
+ * @param {{ settings: object, health: object }} props - props.
3061
+ * @returns {any} element.
3062
+ */
3063
+ /**
3064
+ * The selection toolbar: how many indicators are picked and what to do with them.
3065
+ *
3066
+ * Only rendered when something is selected, so the panel stays quiet until the
3067
+ * reader actually opts in.
3068
+ *
3069
+ * @param {{ ids: string[], onClear: Function, onAnalyze: Function, busy: boolean }} props - props.
3070
+ * @returns {any} element.
3071
+ */
3072
+ function SelectionBar({ ids, onClear, onAnalyze, busy }) {
3073
+ if ((ids ?? []).length === 0) return null
3074
+ return h(
3075
+ 'div',
3076
+ { className: 'smd-selectBar' },
3077
+ h('span', { className: 'smd-chip' }, `${copy.selectedCount} ${ids.length}`),
3078
+ h('span', { className: 'smd-note' }, ids.map((id) => id).join('、')),
3079
+ h('span', { className: 'smd-spacer' }),
3080
+ h('button', { type: 'button', className: 'smd-btn', disabled: busy === true, onClick: () => onAnalyze?.() }, busy === true ? copy.loading : copy.analyzeSelected),
3081
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onClear?.() }, copy.clearSelection),
3082
+ )
3083
+ }
3084
+
3085
+ /**
3086
+ * Split metrics into labelled sections, keeping the catalog's group order.
3087
+ *
3088
+ * A flat wall of 60 cards is the panel's main legibility problem; grouping by
3089
+ * the same taxonomy the catalog already uses (US / CN / global / custom) costs
3090
+ * nothing and makes the panel scannable.
3091
+ *
3092
+ * @param {object[]} metrics - cards.
3093
+ * @param {string[]} [order] - group ids in display order.
3094
+ * @returns {Array<{ id: string, title: string, metrics: object[] }>} sections.
3095
+ */
3096
+ function groupBySection(metrics, order) {
3097
+ const ids = [...new Set([...(Array.isArray(order) && order.length > 0 ? order : ['US', 'CN', 'GLOBAL', 'CUSTOM']), 'CUSTOM'])]
3098
+ const titles = { US: copy.groupUS, CN: copy.groupCN, GLOBAL: copy.groupGlobal, CUSTOM: copy.groupCustom }
3099
+ const buckets = new Map(ids.map((id) => [id, []]))
3100
+ for (const metric of metrics) {
3101
+ // An unknown group has to land somewhere rather than vanish from the panel.
3102
+ const key = buckets.has(metric.group) ? metric.group : 'CUSTOM'
3103
+ buckets.get(key).push(metric)
3104
+ }
3105
+ return ids
3106
+ .map((id) => ({ id, title: titles[id] ?? id, metrics: buckets.get(id) ?? [] }))
3107
+ .filter((section) => section.metrics.length > 0)
3108
+ }
3109
+
3110
+ /**
3111
+ * What the last discussion is doing, shown wherever one can be started.
3112
+ *
3113
+ * A discussion is opened asynchronously and its answer lands in a new session,
3114
+ * so without this the button's only effect was a session appearing somewhere
3115
+ * else — which reads as "the button does nothing". This states the phase, the
3116
+ * answer once it exists, and the cause when it fails.
3117
+ *
3118
+ * @param {{ state: object, onOpenSession: Function, onDismiss: Function }} props - props.
3119
+ * @returns {any} element.
3120
+ */
3121
+ /**
3122
+ * One phrase for what the last discussion is doing.
3123
+ *
3124
+ * A failure after the session was created is not "会话已就绪": the reader has to
3125
+ * be able to tell "the session is there and this turn failed" from "nothing has
3126
+ * happened yet", because only the first one is worth opening.
3127
+ *
3128
+ * @param {object} state - panel state.
3129
+ * @param {boolean} running - whether a turn is in flight.
3130
+ * @returns {string} phrase.
3131
+ */
3132
+ function discussPhase(state, running) {
3133
+ if (state.discussing === true) return copy.discussOpening
3134
+ if (running) return copy.discussRunning
3135
+ if (state.discussError !== undefined && typeof state.discussSession === 'string' && state.discussSession !== '') return copy.discussTurnFailed
3136
+ return copy.discussReady
3137
+ }
3138
+
3139
+ function DiscussionStatus({ state, onOpenSession, onDismiss }) {
3140
+ if (state.discussing !== true && state.discussPending !== true && state.discussError === undefined && typeof state.discussAnswer !== 'string') {
3141
+ return null
3142
+ }
3143
+ const running = state.discussing === true || state.discussPending === true
3144
+ return h(
3145
+ 'div',
3146
+ { className: 'smd-discuss' },
3147
+ h(
3148
+ 'div',
3149
+ { className: 'smd-row' },
3150
+ h('span', { className: 'smd-chip' }, discussPhase(state, running)),
3151
+ typeof state.discussAnswer === 'string' && state.discussAnswer !== '' ? h('span', { className: 'smd-chip' }, copy.discussAnswer) : null,
3152
+ h('span', { className: 'smd-spacer' }),
3153
+ state.discussSession === undefined
3154
+ ? null
3155
+ : h('button', { type: 'button', className: 'smd-btn', onClick: () => onOpenSession?.(state.discussSession) }, copy.discussOpenSession),
3156
+ onDismiss === undefined
3157
+ ? null
3158
+ : h('button', { type: 'button', className: 'smd-btn', onClick: () => onDismiss() }, copy.dismiss),
3159
+ ),
3160
+ state.discussError === undefined ? null : h('div', { className: 'smd-banner smd-bannerError' }, state.discussError),
3161
+ typeof state.discussQuestion === 'string' && state.discussQuestion !== ''
3162
+ ? h(
3163
+ 'div',
3164
+ { className: 'smd-note' },
3165
+ `${state.discussAutoQuestion === true ? copy.discussAutoAsked : copy.discussQuestion}${state.discussQuestion}`,
3166
+ )
3167
+ : null,
3168
+ state.discussSession === undefined
3169
+ ? null
3170
+ : h('div', { className: 'smd-note' }, `${copy.discussSessionStarted} ${state.discussSession}`),
3171
+ typeof state.discussAnswer === 'string' && state.discussAnswer !== ''
3172
+ ? h('div', { className: 'smd-aiText' }, state.discussAnswer)
3173
+ : running
3174
+ ? h('div', { className: 'smd-note' }, copy.discussRunningHint)
3175
+ : null,
3176
+ )
3177
+ }
3178
+
3179
+ /**
3180
+ * Whole-panel analysis: one digest over a scope, plus a session to continue in.
3181
+ *
3182
+ * The scope chips mirror the panel's own grouping, so "美国" here means exactly
3183
+ * the cards the US filter shows. Its result is deliberately kept out of the
3184
+ * per-indicator AI state: it is about the panel, not about whatever card the
3185
+ * reader opened last.
3186
+ *
3187
+ * @param {{ range: string, onDiscuss: Function, discussing: boolean }} props - props.
3188
+ * @returns {any} element.
3189
+ */
3190
+ function AnalysisTab({ range, onDiscuss, discussing, discussion, onOpenSession, onDismiss }) {
3191
+ const [scope, setScope] = useState('ALL')
3192
+ const [state, setState] = useState({ loading: false, result: undefined, error: undefined })
3193
+ const scopes = [
3194
+ ['ALL', copy.analyzeAll],
3195
+ ['US', copy.groupUs],
3196
+ ['CN', copy.groupCn],
3197
+ ['GLOBAL', copy.groupGlobal],
3198
+ ['CUSTOM', copy.tabMine],
3199
+ ]
3200
+ const run = () => {
3201
+ setState({ loading: true, result: undefined, error: undefined })
3202
+ api.aiOverview({ range, ...(scope === 'ALL' ? {} : { groups: [scope] }) }).then((response) => {
3203
+ if (!response.ok) {
3204
+ setState({ loading: false, result: undefined, error: response.error?.detail ?? `请求失败(HTTP ${response.status})` })
3205
+ return
3206
+ }
3207
+ setState({ loading: false, result: response.data, error: undefined })
3208
+ })
3209
+ }
3210
+ const result = state.result
3211
+ return h(
3212
+ 'div',
3213
+ { className: 'smd-detail' },
3214
+ h(
3215
+ 'div',
3216
+ { className: 'smd-row' },
3217
+ h('span', { className: 'smd-sectionTitle' }, copy.analyzeScope),
3218
+ h(
3219
+ 'span',
3220
+ { className: 'smd-chips' },
3221
+ scopes.map(([id, label]) =>
3222
+ h(
3223
+ 'button',
3224
+ {
3225
+ key: id,
3226
+ type: 'button',
3227
+ className: `smd-chipBtn${scope === id ? ' smd-chipBtnOn' : ''}`,
3228
+ 'aria-pressed': scope === id,
3229
+ onClick: () => setScope(id),
3230
+ },
3231
+ label,
3232
+ ),
3233
+ ),
3234
+ ),
3235
+ h('span', { className: 'smd-spacer' }),
3236
+ h('button', { type: 'button', className: 'smd-btn', disabled: state.loading, onClick: run }, state.loading ? copy.loading : copy.analyzeRun),
3237
+ ),
3238
+ state.error === undefined ? null : h('div', { className: 'smd-banner smd-bannerError' }, state.error),
3239
+ state.loading ? h('div', { className: 'smd-note' }, copy.loading) : null,
3240
+ h(DiscussionStatus, { state, onOpenSession, onDismiss }),
3241
+ result === undefined
3242
+ ? h('div', { className: 'smd-note' }, copy.analyzeIdle)
3243
+ : h(
3244
+ 'div',
3245
+ { className: 'smd-detail' },
3246
+ h(
3247
+ 'div',
3248
+ { className: 'smd-row' },
3249
+ h('span', { className: 'smd-chip' }, result.mode === 'llm' ? copy.aiModeLlm : copy.aiModeDeterministic),
3250
+ h('span', { className: 'smd-chip' }, `${copy.analyzeCount} ${(result.indicators ?? []).length}`),
3251
+ (result.noteworthy ?? []).length === 0 ? null : h('span', { className: 'smd-chip' }, `${copy.noteworthy} ${result.noteworthy.length}`),
3252
+ h('span', { className: 'smd-spacer' }),
3253
+ onDiscuss === undefined
3254
+ ? null
3255
+ : h(
3256
+ 'button',
3257
+ {
3258
+ type: 'button',
3259
+ className: 'smd-btn',
3260
+ title: copy.analyzeDiscussHint,
3261
+ disabled: discussing === true,
3262
+ onClick: () => onDiscuss(result.scope),
3263
+ },
3264
+ discussing === true ? copy.discussing : copy.analyzeDiscuss,
3265
+ ),
3266
+ ),
3267
+ (result.violations ?? []).length > 0
3268
+ ? h('div', { className: 'smd-banner' }, `${copy.aiDegraded}${result.degradedReason === undefined ? `(${result.violations.length} 项)` : `:${result.degradedReason}`}`)
3269
+ : null,
3270
+ h('div', { className: 'smd-aiText' }, result.markdown ?? ''),
3271
+ ),
3272
+ )
3273
+ }
3274
+
3275
+ /**
3276
+ * Settings: what this mount is doing right now, and where each value comes from.
3277
+ *
3278
+ * The screen used to print four labels with no values, so "what can I set here"
3279
+ * had no answer. Every row now states its effective value, the config path that
3280
+ * produced it, and whether it can be changed from the browser at all — most
3281
+ * cannot, because they are row config read once at profile boot.
3282
+ *
3283
+ * @param {{ settings: object, health: object, onReload: Function }} props - props.
3284
+ * @returns {any} element.
3285
+ */
3286
+ function SettingsTab({ settings, health, onReload, onTest, testing, testingId, testResult }) {
3287
+ const row = (label, value, hint, key) =>
3288
+ h(
3289
+ 'div',
3290
+ { className: 'smd-setRow', key: key ?? label },
3291
+ h('span', { className: 'smd-setLabel' }, label),
3292
+ h('span', { className: 'smd-setValue' }, value === undefined || value === '' ? '—' : String(value)),
3293
+ hint === undefined ? null : h('span', { className: 'smd-setHint' }, hint),
3294
+ )
3295
+ const sources = settings?.sources ?? []
3296
+ const offline = (settings?.sourcesOff ?? [])
3297
+ return h(
3298
+ 'div',
3299
+ { className: 'smd-detail' },
3300
+ h(
3301
+ 'div',
3302
+ { className: 'smd-row' },
3303
+ h('span', { className: 'smd-sectionTitle' }, copy.settingsWhatIsThis),
3304
+ h('span', { className: 'smd-spacer' }),
3305
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onReload?.() }, copy.reload),
3306
+ ),
3307
+ h('div', { className: 'smd-note' }, copy.settingsIntro),
3308
+ row(copy.settingsPrefix, settings?.runtime?.prefix, copy.settingsPrefixHint),
3309
+ row(copy.settingsRoutes, settings?.runtime?.routes, copy.settingsReadOnly),
3310
+ row(copy.settingsTools, settings?.runtime?.tools, copy.settingsReadOnly),
3311
+ row(copy.settingsIndicators, settings?.runtime?.indicators, `${copy.settingsUnsupported} ${settings?.runtime?.unsupported ?? 0}`),
3312
+ row(copy.settingsStorage, settings?.storageDir, copy.settingsStorageHint),
3313
+
3314
+ h('div', { className: 'smd-sectionTitle' }, copy.settingsAi),
3315
+ row(copy.settingsAiMode, settings?.ai?.mode, `${copy.settingsCurrentModel}: ${settings?.ai?.provider ?? '—'} / ${settings?.ai?.model ?? '—'}`),
3316
+ row(copy.settingsAiEnabled, settings?.ai?.enabled === true ? copy.yes : copy.no, copy.settingsAiEnabledHint),
3317
+ row(copy.settingsAiChars, settings?.ai?.maxChars, copy.settingsAiCharsHint),
3318
+ row(copy.settingsAiCache, `${settings?.ai?.cacheMinutes ?? '—'} ${copy.minutes}`, copy.settingsAiCacheHint),
3319
+
3320
+ h('div', { className: 'smd-sectionTitle' }, copy.settingsData),
3321
+ row(copy.settingsRefresh, `${settings?.refreshMinutes ?? '—'} ${copy.minutes}`, copy.settingsRefreshHint),
3322
+ row(copy.settingsGroups, (settings?.groups ?? []).join(' / '), copy.settingsGroupsHint),
3323
+ row(copy.settingsNoteworthy, settings?.noteworthyLimit, copy.settingsNoteworthyHint),
3324
+ row(copy.settingsDebug, settings?.debug === true ? copy.yes : copy.no, copy.settingsDebugHint),
3325
+
3326
+ h(
3327
+ 'div',
3328
+ { className: 'smd-row' },
3329
+ h('span', { className: 'smd-sectionTitle' }, copy.settingsSources),
3330
+ h('span', { className: 'smd-spacer' }),
3331
+ h(
3332
+ 'button',
3333
+ { type: 'button', className: 'smd-btn', disabled: testing === true, onClick: () => onTest?.(undefined) },
3334
+ testing === true ? copy.testing : copy.testAll,
3335
+ ),
3336
+ ),
3337
+ h('div', { className: 'smd-note' }, copy.settingsSourcesHint),
3338
+ testResult === undefined ? null : h('div', { className: 'smd-banner' }, testResult),
3339
+ h(
3340
+ 'ul',
3341
+ { className: 'smd-list' },
3342
+ sources.length === 0
3343
+ ? h('li', { className: 'smd-note' }, copy.loading)
3344
+ : sources.map((source) =>
3345
+ h(
3346
+ 'li',
3347
+ { key: source.adapterId, className: 'smd-setRow' },
3348
+ h('span', { className: 'smd-setLabel' }, source.label ?? source.adapterId),
3349
+ h('span', { className: 'smd-setValue' }, source.available === null || source.available === undefined ? copy.notChecked : source.available ? copy.available : copy.unavailable),
3350
+ h(
3351
+ 'span',
3352
+ { className: 'smd-setHint' },
3353
+ `${source.adapterId}${offline.includes(source.adapterId) ? ` · ${copy.settingsSourceOff}` : ''}${source.failed > 0 ? ` · ${copy.failedCount} ${source.failed}` : ''}${source.lastSuccessAt === undefined ? '' : ` · ${copy.settingsLast} ${formatAge(ageMinutes(source.lastSuccessAt))}`}`,
3354
+ ),
3355
+ h(
3356
+ 'button',
3357
+ {
3358
+ type: 'button',
3359
+ className: 'smd-btn smd-btnInline',
3360
+ disabled: testing === true,
3361
+ title: copy.testSourceHint,
3362
+ onClick: () => onTest?.(source.adapterId),
3363
+ },
3364
+ testingId === source.adapterId ? copy.testing : copy.testSource,
3365
+ ),
3366
+ ),
3367
+ ),
3368
+ ),
3369
+ settings?.configPath === undefined
3370
+ ? null
3371
+ : h('div', { className: 'smd-note' }, `${copy.settingsHowToChange} ${settings.configPath}`),
3372
+ )
3373
+ }
3374
+
3375
+ /**
3376
+ * Add-indicator tab: catalog search plus natural-language propose.
3377
+ *
3378
+ * @param {{ onAdd: Function, onPropose: Function, results: object[], proposal: object }} props - props.
3379
+ * @returns {any} element.
3380
+ */
3381
+ function AddIndicatorTab({ onAdd, onPropose, results, proposal, query, onQueryChange, onIterate, iterating }) {
3382
+ const unsupported = proposal?.unsupported ?? []
3383
+ const candidates = proposal?.candidates ?? []
3384
+ return h(
3385
+ 'div',
3386
+ { className: 'smd-detail' },
3387
+ h('div', { className: 'smd-sectionTitle' }, copy.addIndicator),
3388
+ h('input', {
3389
+ className: 'smd-input',
3390
+ value: query,
3391
+ placeholder: copy.addSearchPlaceholder,
3392
+ onChange: (event) => onQueryChange?.(event.target.value),
3393
+ }),
3394
+ h(
3395
+ 'ul',
3396
+ { className: 'smd-list' },
3397
+ (results ?? []).slice(0, 8).map((entry) =>
3398
+ h(
3399
+ 'li',
3400
+ { key: entry.id, className: 'smd-row' },
3401
+ h('span', null, `${entry.label?.zh ?? entry.id}`),
3402
+ h('span', { className: 'smd-note' }, entry.id),
3403
+ h('span', { className: 'smd-spacer' }),
3404
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onAdd?.(entry.id) }, copy.addConfirm),
3405
+ ),
3406
+ ),
3407
+ ),
3408
+ h('div', { className: 'smd-sectionTitle' }, copy.addByText),
3409
+ h('input', {
3410
+ className: 'smd-input',
3411
+ placeholder: copy.addByTextPlaceholder,
3412
+ onKeyDown: (event) => {
3413
+ if (event.key === 'Enter') onPropose?.(event.target.value)
3414
+ },
3415
+ }),
3416
+ proposal === undefined
3417
+ ? null
3418
+ : h(
3419
+ 'div',
3420
+ { className: 'smd-detail' },
3421
+ candidates.length > 0
3422
+ ? h('div', { className: 'smd-note' }, `候选:${candidates.map((entry) => `${entry.label?.zh ?? entry.id}${entry.conflict ? `(${copy.addExists})` : ''}`).join('、')}`)
3423
+ : h('div', { className: 'smd-note' }, `${copy.addUnsupported}:${unsupported.map((entry) => entry.reason).join(';')}`),
3424
+ // "No data source" is not a dead end: the honest next step is a
3425
+ // session that can actually add the source, and the panel can open it
3426
+ // with the request, the reasons and the plugin's own layout attached.
3427
+ unsupported.length === 0 || onIterate === undefined
3428
+ ? null
3429
+ : h(
3430
+ 'div',
3431
+ { className: 'smd-iterate' },
3432
+ h('div', { className: 'smd-note' }, copy.iterateHint),
3433
+ h(
3434
+ 'button',
3435
+ {
3436
+ type: 'button',
3437
+ className: 'smd-btn',
3438
+ disabled: iterating === true,
3439
+ onClick: () => onIterate(unsupported, query),
3440
+ },
3441
+ iterating === true ? copy.iterateOpening : copy.iterateAction,
3442
+ ),
3443
+ ),
3444
+ ),
3445
+ )
3446
+ }
3447
+
3448
+ /**
3449
+ * The overlay root: trigger badge plus the panel, self-managing open state.
3450
+ *
3451
+ * @param {object} props - slot props (the overlay passes no panel props).
3452
+ * @returns {any} element.
3453
+ */
3454
+ function DataRadarOverlay() {
3455
+ const [state, setLocal] = useState(store.getState())
3456
+ const [query, setQuery] = useState('')
3457
+ const [searchResults, setSearchResults] = useState([])
3458
+ const [proposal, setProposal] = useState(undefined)
3459
+ const [question, setQuestion] = useState('')
3460
+ const [collapsed, setCollapsed] = useState({})
3461
+ const [selectionResult, setSelectionResult] = useState(undefined)
3462
+ const rootRef = useRef(null)
3463
+ const alive = useRef(true)
3464
+ // One pending poll for a discussion answer, cancelled on unmount.
3465
+ const timer = useRef(undefined)
3466
+
3467
+ useEffect(() => {
3468
+ const unsubscribe = store.subscribe((next) => {
3469
+ if (alive.current) setLocal(next)
3470
+ })
3471
+ return () => {
3472
+ alive.current = false
3473
+ if (timer.current !== undefined) clearTimeout(timer.current)
3474
+ unsubscribe()
3475
+ }
3476
+ }, [])
3477
+
3478
+ useEffect(() => {
3479
+ if (!state.open) return undefined
3480
+ const controller = new AbortController()
3481
+ api.overview({ range: state.range, signal: controller.signal }).then((response) => {
3482
+ if (!alive.current) return
3483
+ if (response.ok) store.setState((current) => ({ ...current, ...applyOverviewShape(response.data) }))
3484
+ else if (response.status === 404) store.setState((current) => ({ ...current, unreachable: true, status: 'unreachable' }))
3485
+ else store.setState((current) => ({ ...current, error: response.error, status: 'error' }))
3486
+ })
3487
+ return () => controller.abort()
3488
+ }, [state.open, state.range])
3489
+
3490
+ useEffect(() => {
3491
+ if (!state.open || state.tab !== 'settings') return undefined
3492
+ loadSettings()
3493
+ return undefined
3494
+ }, [state.open, state.tab])
3495
+
3496
+ /**
3497
+ * Load the mount's effective settings (and source health) for the settings tab.
3498
+ *
3499
+ * @returns {void}
3500
+ */
3501
+ const loadSettings = () => {
3502
+ api.settings().then((response) => {
3503
+ if (!alive.current || !response.ok) return
3504
+ store.setState((current) => ({ ...current, settings: { ...current.settings, ...response.data } }))
3505
+ })
3506
+ api.health().then((response) => {
3507
+ if (!alive.current || !response.ok) return
3508
+ store.setState((current) => ({ ...current, health: response.data, settings: { ...current.settings, sources: response.data.sources ?? current.settings.sources } }))
3509
+ })
3510
+ }
3511
+
3512
+ /**
3513
+ * Add or remove one indicator from the multi-select set.
3514
+ *
3515
+ * @param {string} indicatorId - indicator id.
3516
+ * @returns {void}
3517
+ */
3518
+ const toggleSelection = (indicatorId) => {
3519
+ store.setState((current) => {
3520
+ const selected = current.selected ?? []
3521
+ return {
3522
+ ...current,
3523
+ selected: selected.includes(indicatorId) ? selected.filter((id) => id !== indicatorId) : [...selected, indicatorId],
3524
+ }
3525
+ })
3526
+ }
3527
+
3528
+ /**
3529
+ * Analyse every selected indicator in one digest.
3530
+ *
3531
+ * The selection is a *reader's* question ("how do these relate"), so the
3532
+ * request carries exactly the picked ids and the digest is built from them —
3533
+ * no group inference, no silent extra indicators.
3534
+ *
3535
+ * @returns {void}
3536
+ */
3537
+ const runSelectionAnalysis = () => {
3538
+ const ids = store.getState().selected ?? []
3539
+ if (ids.length === 0) return
3540
+ store.setState((current) => ({ ...current, selectionLoading: true }))
3541
+ setSelectionResult(undefined)
3542
+ api.summary({ range: state.range, indicators: ids }).then((response) => {
3543
+ if (!alive.current) return
3544
+ store.setState((current) => ({ ...current, selectionLoading: false }))
3545
+ if (!response.ok) {
3546
+ setSelectionResult({ markdown: `${copy.discussFailed}:${response.error?.detail ?? response.status}`, mode: 'deterministic', violations: [] })
3547
+ return
3548
+ }
3549
+ setSelectionResult({ ...response.data, indicators: response.data?.indicators ?? ids })
3550
+ })
3551
+ }
3552
+
3553
+ /**
3554
+ * Re-fetch the open indicator's bars at another size (day/week/month).
3555
+ *
3556
+ * @param {'day'|'week'|'month'} size - bar size.
3557
+ * @returns {void}
3558
+ */
3559
+ const setBarSize = (size) => {
3560
+ const indicatorId = store.getState().detail?.indicatorId
3561
+ store.setState((current) => ({ ...current, barSize: size }))
3562
+ if (indicatorId === undefined) return
3563
+ api.series({ indicator: indicatorId, range: state.range, freq: size }).then((response) => {
3564
+ if (alive.current && response.ok) store.setState((current) => ({ ...current, detail: response.data }))
3565
+ })
3566
+ }
3567
+
3568
+ /**
3569
+ * Test one data source (or all of them) by re-fetching, ignoring the cache.
3570
+ *
3571
+ * A cached "ok" says nothing about right now: these upstreams rate-limit and
3572
+ * block, so the only honest test is a fresh request. It is narrowed to the
3573
+ * indicators behind the chosen adapter so one source does not cost a full
3574
+ * panel refresh.
3575
+ *
3576
+ * @param {string|undefined} adapterId - source to test; all sources when omitted.
3577
+ * @returns {void}
3578
+ */
3579
+ const testSources = (adapterId) => {
3580
+ const ids = adapterId === undefined
3581
+ ? undefined
3582
+ : (store.getState().overview?.metrics ?? []).filter((metric) => metric.sourceRef?.adapterId === adapterId).map((metric) => metric.indicatorId)
3583
+ store.setState((current) => ({ ...current, testing: true, testingId: adapterId, testResult: undefined }))
3584
+ api.health({ probe: true, ids, range: adapterId === undefined ? 'MAX' : undefined }).then((response) => {
3585
+ if (!alive.current) return
3586
+ if (!response.ok) {
3587
+ store.setState((current) => ({ ...current, testing: false, testingId: undefined, testResult: `${copy.testFailed}:${response.error?.detail ?? response.status}` }))
3588
+ return
3589
+ }
3590
+ const sources = response.data?.sources ?? []
3591
+ const scoped = adapterId === undefined ? sources : sources.filter((source) => source.adapterId === adapterId)
3592
+ const failed = scoped.filter((source) => source.available === false)
3593
+ const probed = ids === undefined ? copy.allSources : ids.length
3594
+ store.setState((current) => ({
3595
+ ...current,
3596
+ testing: false,
3597
+ testingId: undefined,
3598
+ health: response.data,
3599
+ settings: { ...current.settings, sources },
3600
+ testResult: failed.length === 0
3601
+ ? `${copy.testOk}(${copy.probeIndicators} ${probed})`
3602
+ : `${copy.testFailed}:${failed.map((source) => `${source.label ?? source.adapterId}(${source.failed})`).join('、')}`,
3603
+ }))
3604
+ })
3605
+ }
3606
+
3607
+ const openDetail = (indicatorId) => {
3608
+ // Selecting another card starts a new subject: the previous answer, its
3609
+ // question and any discussion belong to the indicator being left behind.
3610
+ store.setState((current) => ({ ...current, ...applyIndicatorChange(current, indicatorId), detailLoading: true, tab: 'detail', detail: undefined }))
3611
+ api.series({ indicator: indicatorId, range: state.range }).then((response) => {
3612
+ if (!alive.current) return
3613
+ if (response.ok) store.setState((current) => ({ ...current, detail: response.data, detailLoading: false }))
3614
+ else store.setState((current) => ({ ...current, detailLoading: false, detail: { error: response.error } }))
3615
+ })
3616
+ }
3617
+
3618
+ const runExplain = () => {
3619
+ const indicatorId = state.detail?.indicatorId
3620
+ if (indicatorId === undefined) return
3621
+ store.setState((current) => ({ ...current, ai: { ...current.ai, text: '', streaming: true, error: undefined } }))
3622
+ api.explain(
3623
+ { indicator: indicatorId, range: state.range },
3624
+ {
3625
+ onText: (chunk) => store.setState((current) => ({ ...current, ai: { ...current.ai, text: `${current.ai.text}${chunk}`, streaming: true } })),
3626
+ onDone: (result) => store.setState((current) => ({ ...current, ai: { ...current.ai, streaming: false, result, text: result?.markdown ?? current.ai.text, mode: result?.mode ?? current.ai.mode } })),
3627
+ onError: (error) => store.setState((current) => ({ ...current, ai: { ...current.ai, streaming: false, error } })),
3628
+ },
3629
+ )
3630
+ }
3631
+
3632
+ const submitQuestion = () => {
3633
+ if (question.trim() === '') return
3634
+ store.setState((current) => ({ ...current, ai: { ...current.ai, text: '', streaming: true, error: undefined } }))
3635
+ api.ask(
3636
+ { question, range: state.range },
3637
+ {
3638
+ onText: (chunk) => store.setState((current) => ({ ...current, ai: { ...current.ai, text: `${current.ai.text}${chunk}`, streaming: true } })),
3639
+ onDone: (result) => store.setState((current) => ({ ...current, ai: { ...current.ai, streaming: false, result, text: result?.markdown ?? current.ai.text, mode: result?.mode ?? current.ai.mode } })),
3640
+ onError: (error) => store.setState((current) => ({ ...current, ai: { ...current.ai, streaming: false, error } })),
3641
+ },
3642
+ )
3643
+ }
3644
+
3645
+ const runSearch = (text) => {
3646
+ setQuery(text)
3647
+ if (text.trim() === '') {
3648
+ setSearchResults([])
3649
+ return
3650
+ }
3651
+ api.search(text).then((response) => {
3652
+ if (alive.current && response.ok) setSearchResults(response.data?.matches ?? [])
3653
+ })
3654
+ }
3655
+
3656
+ const runPropose = (text) => {
3657
+ if (text.trim() === '') return
3658
+ api.propose({ text }).then((response) => {
3659
+ if (alive.current && response.ok) setProposal(response.data)
3660
+ })
3661
+ }
3662
+
3663
+ const addIndicator = (indicatorId) => {
3664
+ api.watchlistAction({ action: 'add', item: { indicatorId }, createdBy: 'user' }).then((response) => {
3665
+ if (alive.current && response.ok) store.setState((current) => ({ ...current, watchlist: response.data.items ?? [] }))
3666
+ })
3667
+ }
3668
+
3669
+ /**
3670
+ * Poll a discussion session until its answer exists, then show it here.
3671
+ *
3672
+ * The panel opens the session without waiting, so this is how the answer also
3673
+ * lands beside the data the question was about. Polling stops on the first
3674
+ * answer or failure, and on unmount, so a closed panel leaves nothing behind.
3675
+ *
3676
+ * @param {string} sessionId - the session to collect from.
3677
+ * @returns {void}
3678
+ */
3679
+ const collectDiscussionAnswer = (sessionId) => {
3680
+ const deadline = Date.now() + (pollTimeoutMs ?? DISCUSSION_POLL_MS)
3681
+ let polls = 0
3682
+ const tick = () => {
3683
+ if (!alive.current) return
3684
+ polls += 1
3685
+ // A session we no longer track (a newer discussion replaced it) must not
3686
+ // keep writing its answer into the panel.
3687
+ if (store.getState().discussSession !== sessionId) return
3688
+ api.discussAnswer(sessionId).then((response) => {
3689
+ if (!alive.current) return
3690
+ const status = response.data?.status
3691
+ if (status === 'idle') {
3692
+ // The session holds context but was never asked anything, so there is
3693
+ // no answer to wait for. Saying "the model failed" here is wrong.
3694
+ store.setState((current) => ({ ...current, discussPending: false, discussAnswer: undefined, discussError: copy.discussIdle }))
3695
+ return
3696
+ }
3697
+ if (status === 'done') {
3698
+ const reply = typeof response.data?.reply === 'string' ? response.data.reply : ''
3699
+ store.setState((current) => ({
3700
+ ...current,
3701
+ discussPending: false,
3702
+ discussAnswer: reply === '' ? undefined : reply,
3703
+ discussError: reply === '' ? copy.discussEmpty : undefined,
3704
+ }))
3705
+ return
3706
+ }
3707
+ if (status === 'failed') {
3708
+ store.setState((current) => ({
3709
+ ...current,
3710
+ discussPending: false,
3711
+ discussError: response.data?.error?.detail ?? copy.discussFailed,
3712
+ }))
3713
+ return
3714
+ }
3715
+ if (Date.now() > deadline) {
3716
+ store.setState((current) => ({ ...current, discussPending: false, discussError: copy.discussTimeout }))
3717
+ return
3718
+ }
3719
+ if (polls === 1) {
3720
+ // First miss: say a turn is running, so the panel is visibly working
3721
+ // rather than inert while the model thinks.
3722
+ store.setState((current) => ({ ...current, discussPending: true }))
3723
+ }
3724
+ timer.current = setTimeout(tick, pollIntervalMs ?? DISCUSSION_POLL_INTERVAL_MS)
3725
+ })
3726
+ }
3727
+ timer.current = setTimeout(tick, pollIntervalMs ?? DISCUSSION_POLL_INTERVAL_MS)
3728
+ }
3729
+
3730
+ /**
3731
+ * Open an independent DSH session about the indicator currently shown.
3732
+ *
3733
+ * The host creates the session and seeds it with the panel digest; we then
3734
+ * ask the GUI to switch to it, so the conversation continues with the full
3735
+ * composer instead of inside the panel.
3736
+ */
3737
+ /**
3738
+ * The session the reader is looking at, when the shell exposes one.
3739
+ *
3740
+ * The host cannot see which session a web request came from, so the panel
3741
+ * names it: a discussion created without it joins no composition and is born
3742
+ * with only this plugin's tools — no filesystem, no shell, no prompt sections.
3743
+ *
3744
+ * @returns {string|undefined} current session id.
3745
+ */
3746
+ const currentSessionId = () => {
3747
+ try {
3748
+ const current = sessions?.list?.getSnapshot?.()?.current
3749
+ return typeof current === 'string' && current !== '' ? current : undefined
3750
+ } catch {
3751
+ return undefined
3752
+ }
3753
+ }
3754
+
3755
+ /**
3756
+ * Open an independent session about one subject.
3757
+ *
3758
+ * Three callers share it: a card's "讨论" button (subject: that indicator), a
3759
+ * noteworthy row (subject: that indicator *and* the rule that fired), and the
3760
+ * analysis tab (subject: the whole panel or one group). The host builds the
3761
+ * context in each case, so the panel never assembles a digest itself.
3762
+ *
3763
+ * @param {{ noteworthy?: string, scope?: string, question?: string }} [options] - which subject.
3764
+ * @returns {void}
3765
+ */
3766
+ const startDiscussion = (options = {}) => {
3767
+ const noteworthy = options.noteworthy
3768
+ const scope = options.scope
3769
+ const indicatorId = noteworthy === undefined && scope === undefined ? state.detail?.indicatorId : undefined
3770
+ if (noteworthy === undefined && scope === undefined && indicatorId === undefined) return
3771
+ const body = noteworthy !== undefined
3772
+ ? { group: 'ALL', noteworthy: [noteworthy], range: state.range }
3773
+ : scope !== undefined
3774
+ ? { group: scope === 'ALL' ? 'ALL' : scope, range: state.range, limit: 8 }
3775
+ : { indicator: indicatorId, range: state.range }
3776
+ // NOT `const question = … question …`: shadowing the state variable makes
3777
+ // the initializer read the binding being declared, so the whole handler
3778
+ // threw `Cannot access 'question' before initialization` on every click and
3779
+ // the reader saw a button that did nothing at all.
3780
+ const asked = options.question ?? (question === '' ? undefined : question.trim())
3781
+ store.setState((current) => ({
3782
+ ...current,
3783
+ discussing: true,
3784
+ discussingId: noteworthy,
3785
+ discussError: undefined,
3786
+ discussAnswer: undefined,
3787
+ discussPending: false,
3788
+ discussQuestion: asked,
3789
+ discussAutoQuestion: asked === undefined,
3790
+ }))
3791
+ api.discuss({ ...body, question: asked, sessionId: currentSessionId() }).then((response) => {
3792
+ if (!alive.current) return
3793
+ if (!response.ok) {
3794
+ const detail = response.error?.detail ?? `请求失败(HTTP ${response.status})`
3795
+ // A failed first turn still created the session: keep its id so the
3796
+ // reader can open it and see the model's own error, which is the only
3797
+ // place the real cause is written down.
3798
+ const failedSession = response.data?.sessionId
3799
+ store.setState((current) => ({
3800
+ ...current,
3801
+ discussing: false,
3802
+ discussingId: undefined,
3803
+ discussError: detail,
3804
+ discussSession: typeof failedSession === 'string' && failedSession !== '' ? failedSession : current.discussSession,
3805
+ discussQuestion: response.data?.question ?? current.discussQuestion,
3806
+ discussAutoQuestion: response.data?.autoQuestion === true,
3807
+ }))
3808
+ return
3809
+ }
3810
+ const sessionId = response.data?.sessionId
3811
+ store.setState((current) => ({
3812
+ ...current,
3813
+ discussing: false,
3814
+ discussingId: undefined,
3815
+ discussSession: sessionId,
3816
+ discussPending: true,
3817
+ discussQuestion: response.data?.question ?? current.discussQuestion,
3818
+ discussAutoQuestion: response.data?.autoQuestion === true,
3819
+ }))
3820
+ if (typeof sessionId === 'string' && sessionId !== '') collectDiscussionAnswer(sessionId)
3821
+ else store.setState((current) => ({ ...current, discussPending: false }))
3822
+ // Switch the GUI to the new session, so the conversation continues with
3823
+ // the full composer; the panel keeps the answer beside the numbers too.
3824
+ if (sessions !== undefined && typeof sessions.open === 'function') sessions.open(sessionId)
3825
+ })
3826
+ }
3827
+
3828
+ /**
3829
+ * Switch the GUI to a discussion session.
3830
+ *
3831
+ * The client `sessions` service is not present in every shell, so the panel
3832
+ * keeps the id and shows the answer itself rather than depending on this.
3833
+ *
3834
+ * @param {string} sessionId - session to open.
3835
+ * @returns {void}
3836
+ */
3837
+ const openSession = (sessionId) => {
3838
+ if (sessions !== undefined && typeof sessions.open === 'function' && typeof sessionId === 'string') sessions.open(sessionId)
3839
+ }
3840
+
3841
+ /** Clear the discussion card without touching the session itself. */
3842
+ const dismissDiscussion = () => {
3843
+ if (timer.current !== undefined) clearTimeout(timer.current)
3844
+ store.setState((current) => ({
3845
+ ...current,
3846
+ discussing: false,
3847
+ discussingId: undefined,
3848
+ discussPending: false,
3849
+ discussError: undefined,
3850
+ discussAnswer: undefined,
3851
+ discussSession: undefined,
3852
+ }))
3853
+ }
3854
+
3855
+ /**
3856
+ * Open a session that can actually add the missing data source.
3857
+ *
3858
+ * The panel is read-only by design: it cannot register a source at runtime,
3859
+ * so "cannot add" is the correct answer — and an iteration session in the
3860
+ * plugin workspace is the honest next step, carrying the request and the
3861
+ * per-indicator reasons with it.
3862
+ *
3863
+ * @param {object[]} unsupported - rejected proposals with reasons.
3864
+ * @param {string} request - what the reader typed.
3865
+ * @returns {void}
3866
+ */
3867
+ const startIteration = (unsupported, request) => {
3868
+ store.setState((current) => ({ ...current, discussing: true, discussError: undefined, discussAnswer: undefined, discussPending: false }))
3869
+ api.iterate({
3870
+ request: typeof request === 'string' ? request : '',
3871
+ reasons: (unsupported ?? []).map((entry) => `${entry.label?.zh ?? entry.id ?? ''}:${entry.reason ?? ''}`.trim()),
3872
+ sessionId: currentSessionId(),
3873
+ }).then((response) => {
3874
+ if (!alive.current) return
3875
+ if (!response.ok) {
3876
+ store.setState((current) => ({ ...current, discussing: false, discussError: response.error?.detail ?? `请求失败(HTTP ${response.status})` }))
3877
+ return
3878
+ }
3879
+ const sessionId = response.data?.sessionId
3880
+ store.setState((current) => ({ ...current, discussing: false, discussSession: sessionId, discussPending: true }))
3881
+ if (typeof sessionId === 'string' && sessionId !== '') collectDiscussionAnswer(sessionId)
3882
+ if (sessions !== undefined && typeof sessions.open === 'function') sessions.open(sessionId)
3883
+ })
3884
+ }
3885
+
3886
+ /** The analysis tab's "deep dive": a session over the whole scope. */
3887
+ const runScopeDiscussion = (scope) => startDiscussion({ scope })
3888
+
3889
+ const toggle = () => store.setState((current) => ({ ...current, open: !current.open }))
3890
+ const overview = state.overview
3891
+ const metrics = overview?.metrics ?? []
3892
+ const visible = state.tab === 'today'
3893
+ ? metrics.filter((metric) => metric.importance >= 5 || metric.score > 0)
3894
+ : state.tab === 'mine'
3895
+ ? metrics.filter((metric) => metric.group === 'CUSTOM')
3896
+ : metrics
3897
+
3898
+ const trigger = h(
3899
+ 'button',
3900
+ {
3901
+ type: 'button',
3902
+ className: 'smd-trigger',
3903
+ onClick: toggle,
3904
+ 'aria-expanded': state.open,
3905
+ title: copy.open,
3906
+ },
3907
+ h('span', null, copy.panelTitle),
3908
+ overview !== undefined && (overview.noteworthy ?? []).length > 0
3909
+ ? h('span', { className: 'smd-badge' }, String((overview.noteworthy ?? []).length))
3910
+ : null,
3911
+ )
3912
+
3913
+ if (!state.open) return h('div', { className: 'smd-root', ref: rootRef }, trigger)
3914
+
3915
+ return h(
3916
+ 'div',
3917
+ { className: 'smd-root', ref: rootRef },
3918
+ h(
3919
+ 'div',
3920
+ { className: 'smd-panel', role: 'dialog', 'aria-label': copy.panelTitle },
3921
+ h(
3922
+ 'div',
3923
+ { className: 'smd-header' },
3924
+ h('span', { className: 'smd-title' }, copy.panelTitle),
3925
+ h('span', { className: 'smd-sub' }, copy.panelSubtitle),
3926
+ h('span', { className: 'smd-spacer' }),
3927
+ h(
3928
+ 'span',
3929
+ { className: 'smd-tabs' },
3930
+ ['today', 'analysis', 'core', 'mine', 'settings'].map((tab) =>
3931
+ h(
3932
+ 'button',
3933
+ {
3934
+ key: tab,
3935
+ type: 'button',
3936
+ className: 'smd-btn',
3937
+ 'aria-pressed': state.tab === tab,
3938
+ onClick: () => store.setState((current) => ({ ...current, tab })),
3939
+ },
3940
+ { today: copy.tabToday, analysis: copy.analyzeTab, core: copy.tabCore, mine: copy.tabMine, settings: copy.tabSettings }[tab],
3941
+ ),
3942
+ ),
3943
+ ['1M', '3M', '6M', 'YTD', '1Y'].map((range) =>
3944
+ h(
3945
+ 'button',
3946
+ {
3947
+ key: range,
3948
+ type: 'button',
3949
+ className: 'smd-btn',
3950
+ 'aria-pressed': state.range === range,
3951
+ onClick: () => store.setState((current) => ({ ...current, range })),
3952
+ },
3953
+ range,
3954
+ ),
3955
+ ),
3956
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => store.setState((current) => ({ ...current, range: current.range })) }, copy.refresh),
3957
+ h('button', { type: 'button', className: 'smd-btn', onClick: toggle }, copy.close),
3958
+ ),
3959
+ ),
3960
+ h(
3961
+ 'div',
3962
+ { className: 'smd-body' },
3963
+ state.unreachable ? h('div', { className: 'smd-banner smd-bannerError' }, copy.unreachable) : null,
3964
+ state.error !== undefined ? h('div', { className: 'smd-banner smd-bannerError' }, state.error.detail ?? String(state.error)) : null,
3965
+ shouldShowDegraded(state) ? h('div', { className: 'smd-banner' }, copy.degradedBanner) : null,
3966
+ state.status === 'idle' || state.status === 'loading'
3967
+ ? h('div', { className: 'smd-note' }, copy.loading)
3968
+ : null,
3969
+ state.tab === 'settings'
3970
+ ? h(SettingsTab, {
3971
+ settings: state.settings,
3972
+ health: state.health,
3973
+ onReload: loadSettings,
3974
+ onTest: testSources,
3975
+ testing: state.testing === true,
3976
+ testingId: state.testingId,
3977
+ testResult: state.testResult,
3978
+ })
3979
+ : state.tab === 'analysis'
3980
+ ? h(AnalysisTab, {
3981
+ range: state.range,
3982
+ onDiscuss: runScopeDiscussion,
3983
+ discussing: state.discussing,
3984
+ discussion: state,
3985
+ onOpenSession: openSession,
3986
+ onDismiss: dismissDiscussion,
3987
+ })
3988
+ : state.tab === 'mine'
3989
+ ? h(AddIndicatorTab, {
3990
+ onAdd: addIndicator,
3991
+ onPropose: runPropose,
3992
+ results: searchResults,
3993
+ proposal,
3994
+ query,
3995
+ onQueryChange: runSearch,
3996
+ onIterate: startIteration,
3997
+ iterating: state.discussing === true,
3998
+ })
3999
+ : h(
4000
+ 'div',
4001
+ { className: 'smd-detail' },
4002
+ h('div', { className: 'smd-sectionTitle' }, copy.noteworthy),
4003
+ h(NoteworthyList, {
4004
+ items: overview?.noteworthy ?? [],
4005
+ onOpen: openDetail,
4006
+ onDiscuss: (indicatorId) => startDiscussion({ noteworthy: indicatorId }),
4007
+ discussingId: state.discussingId,
4008
+ onOpenSession: openSession,
4009
+ }),
4010
+ h(DiscussionStatus, { state, onOpenSession: openSession, onDismiss: dismissDiscussion }),
4011
+ h(
4012
+ 'div',
4013
+ { className: 'smd-row' },
4014
+ h('span', { className: 'smd-sectionTitle' }, copy.tabCore),
4015
+ h('span', { className: 'smd-spacer' }),
4016
+ h('span', { className: 'smd-note' }, `${copy.lastUpdated} ${formatAge(ageMinutes(state.lastUpdatedAt))}`),
4017
+ ),
4018
+ h(SelectionBar, {
4019
+ ids: state.selected ?? [],
4020
+ onClear: () => store.setState((current) => ({ ...current, selected: [] })),
4021
+ onAnalyze: runSelectionAnalysis,
4022
+ busy: state.selectionLoading === true,
4023
+ }),
4024
+ selectionResult === undefined
4025
+ ? null
4026
+ : h(
4027
+ 'div',
4028
+ { className: 'smd-ai' },
4029
+ h(
4030
+ 'div',
4031
+ { className: 'smd-row' },
4032
+ h('span', { className: 'smd-chip' }, selectionResult.mode === 'llm' ? copy.aiModeLlm : copy.aiModeDeterministic),
4033
+ h('span', { className: 'smd-chip' }, `${copy.selectedCount} ${(selectionResult.indicators ?? state.selected ?? []).length}`),
4034
+ h('span', { className: 'smd-spacer' }),
4035
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => setSelectionResult(undefined) }, copy.dismiss),
4036
+ ),
4037
+ (selectionResult.violations ?? []).length > 0
4038
+ ? h('div', { className: 'smd-banner' }, `${copy.aiDegraded}${selectionResult.degradedReason === undefined ? `(${selectionResult.violations.length} 项)` : `:${selectionResult.degradedReason}`}`)
4039
+ : null,
4040
+ h('div', { className: 'smd-aiText' }, selectionResult.markdown ?? ''),
4041
+ ),
4042
+ visible.length === 0
4043
+ ? h('div', { className: 'smd-note' }, copy.empty)
4044
+ : groupBySection(visible, state.settings?.groups).map((section) =>
4045
+ h(
4046
+ 'div',
4047
+ { className: 'smd-section', key: section.id },
4048
+ h(
4049
+ 'div',
4050
+ { className: 'smd-row' },
4051
+ h('span', { className: 'smd-groupTitle' }, section.title),
4052
+ h('span', { className: 'smd-note' }, `${section.metrics.length}`),
4053
+ h('span', { className: 'smd-spacer' }),
4054
+ h(
4055
+ 'button',
4056
+ {
4057
+ type: 'button',
4058
+ className: 'smd-btn smd-btnInline',
4059
+ onClick: () => setCollapsed((current) => ({ ...current, [section.id]: !current[section.id] })),
4060
+ },
4061
+ collapsed[section.id] === true ? copy.expand : copy.collapse,
4062
+ ),
4063
+ ),
4064
+ collapsed[section.id] === true
4065
+ ? null
4066
+ : h(
4067
+ 'div',
4068
+ { className: 'smd-grid' },
4069
+ section.metrics.map((metric) =>
4070
+ h(MetricCard, {
4071
+ key: metric.indicatorId,
4072
+ metric,
4073
+ onOpen: openDetail,
4074
+ onToggleSelect: toggleSelection,
4075
+ selected: (state.selected ?? []).includes(metric.indicatorId),
4076
+ }),
4077
+ ),
4078
+ ),
4079
+ ),
4080
+ ),
4081
+ ),
4082
+ state.tab === 'detail'
4083
+ ? h(DetailDrawer, {
4084
+ detail: state.detail,
4085
+ barSize: state.barSize,
4086
+ onBarSize: setBarSize,
4087
+ loading: state.detailLoading,
4088
+ onClose: () => store.setState((current) => ({ ...current, tab: 'core' })),
4089
+ ai: state.ai,
4090
+ onAsk: runExplain,
4091
+ question,
4092
+ onQuestionChange: setQuestion,
4093
+ onSubmitQuestion: submitQuestion,
4094
+ onDiscuss: startDiscussion,
4095
+ discussing: state.discussing,
4096
+ discussError: state.discussError,
4097
+ discussAnswer: state.discussAnswer,
4098
+ discussPending: state.discussPending,
4099
+ discussSession: state.discussSession,
4100
+ onOpenSession: openSession,
4101
+ onDiscussNoteworthy: startDiscussion,
4102
+ })
4103
+ : null,
4104
+ ),
4105
+ h('div', { className: 'smd-foot' }, copy.disclaimer),
4106
+ ),
4107
+ )
4108
+ }
4109
+
4110
+ /**
4111
+ * Shape an '/overview' payload into a state patch.
4112
+ *
4113
+ * @param {object} data - payload.
4114
+ * @returns {object} patch.
4115
+ */
4116
+ function applyOverviewShape(data) {
4117
+ return { status: 'ready', overview: data, lastUpdatedAt: data?.generatedAt, error: undefined, unreachable: false }
4118
+ }
4119
+
4120
+ /**
4121
+ * Minutes since an ISO timestamp, for the "updated N minutes ago" label.
4122
+ *
4123
+ * @param {string} iso - timestamp.
4124
+ * @returns {number} minutes.
4125
+ */
4126
+ function ageMinutes(iso) {
4127
+ if (typeof iso !== 'string') return Number.NaN
4128
+ const at = Date.parse(iso)
4129
+ if (!Number.isFinite(at)) return Number.NaN
4130
+ return (Date.now() - at) / 60000
4131
+ }
4132
+
4133
+ /**
4134
+ * @param {object} state - panel state.
4135
+ * @returns {boolean} whether the degraded banner should render.
4136
+ */
4137
+ function shouldShowDegraded(state) {
4138
+ if (state.overview === undefined) return false
4139
+ return state.overview.degraded === true || (state.overview.errors ?? []).length > 0
4140
+ }
4141
+
4142
+ return { DataRadarOverlay, MetricCard, NoteworthyList, Chart, StatsStrip, AiPanel, DetailDrawer, AnalysisTab, SettingsTab, AddIndicatorTab, SelectionBar, DiscussionStatus, Sparkline, applyOverviewShape, groupBySection, ageMinutes, shouldShowDegraded, truncate }
4143
+ }
4144
+
4145
+ // ── src/client/index.js ─────────────────────────────────────────
4146
+ /**
4147
+ * Browser-half entry (docs/09 §3).
4148
+ *
4149
+ * Registers one 'shell.overlay' entry that manages its own open/close state, so
4150
+ * the plugin never competes with the shipped overlay occupants. Every side effect
4151
+ * (stylesheet, subscription) is owned by the plugin context and removed on
4152
+ * unload.
4153
+ *
4154
+ * @module client/index
4155
+ */
4156
+
4157
+
4158
+
4159
+
4160
+ /** Services the client half needs. 'slots' is provided by the shipped runtime. */
4161
+ const inject = ['slots']
4162
+
4163
+ /** The slot this plugin contributes to. */
4164
+ const SLOT = 'shell.overlay'
4165
+
4166
+ /** A no-op React stand-in used only for the module-scope component bindings. */
4167
+ const PLACEHOLDER_REACT = { createElement: () => null, Fragment: null, useState: (v) => [v, () => {}], useEffect: () => {}, useMemo: (f) => f(), useRef: (v) => ({ current: v }) }
4168
+
4169
+ /**
4170
+ * The shared helpers the components need. The bundler concatenates the real
4171
+ * implementations into this scope before this module, so the names resolve at
4172
+ * call time; declaring the map keeps the dependency explicit.
4173
+ */
4174
+ const SHARED_FORMAT = {
4175
+ buildLinePath,
4176
+ buildBarRects,
4177
+ buildSparklineShape,
4178
+ buildXAxis,
4179
+ buildYAxis,
4180
+ buildZeroLine,
4181
+ buildTimeAxis,
4182
+ buildCandles,
4183
+ buildCrosshair,
4184
+ buildReferenceLines,
4185
+ nearestIndex,
4186
+ valueDomain,
4187
+ hasOhlc,
4188
+ linearScale,
4189
+ niceTicks,
4190
+ formatValue,
4191
+ formatChange,
4192
+ formatAge,
4193
+ changeColor,
4194
+ statusDot,
4195
+ metricTooltip,
4196
+ truncate,
4197
+ }
4198
+
4199
+ /** Registration id inside the overlay list. */
4200
+ const ENTRY_ID = 'show-me-data'
4201
+
4202
+ /**
4203
+ * Build the module-level store and components once per page.
4204
+ *
4205
+ * @param {object} React - React instance from the loader.
4206
+ * @param {object} format - the shared 'core/format' plus chart helpers.
4207
+ * @param {{ locale?: string }} [options] - options.
4208
+ * @returns {{ store: object, components: object, copy: object }} panel runtime.
4209
+ */
4210
+ function createPanelRuntime(React, format, options = {}) {
4211
+ const store = createStore()
4212
+ const copy = copyFor(options.locale)
4213
+ const components = createComponents(React, { store, api, copy, format, sessions: options.sessions, applyIndicatorChange })
4214
+ return { store, components, copy }
4215
+ }
4216
+
4217
+ /**
4218
+ * Build the panel's components for one React instance.
4219
+ *
4220
+ * Exported because the structure tests render components with a React stub; the
4221
+ * runtime path calls it with the real React it receives.
4222
+ *
4223
+ * @param {object} React - React instance.
4224
+ * @param {{ store?: object, copy?: object, api?: object, format?: object }} [deps] - overrides.
4225
+ * @returns {object} components plus the store and copy table they use.
4226
+ */
4227
+ function initPanel(React, deps = {}) {
4228
+ const store = deps.store ?? createStore()
4229
+ const copy = deps.copy ?? copyFor('zh')
4230
+ const components = createComponents(React, {
4231
+ store,
4232
+ api: deps.api ?? api,
4233
+ copy,
4234
+ format: deps.format ?? SHARED_FORMAT,
4235
+ sessions: deps.sessions,
4236
+ applyIndicatorChange: deps.applyIndicatorChange ?? applyIndicatorChange,
4237
+ // Injectable so a test can drive the discussion poll to completion instead of
4238
+ // leaving a timer that keeps the process alive.
4239
+ pollIntervalMs: deps.pollIntervalMs,
4240
+ pollTimeoutMs: deps.pollTimeoutMs,
4241
+ })
4242
+ // The store helpers ride along so the structure tests can exercise the state
4243
+ // transitions (not just the render output) of one React instance.
4244
+ return { ...components, store, copy, applyIndicatorChange, INITIAL_STATE }
4245
+ }
4246
+
4247
+ /** Components for the shared-format placeholder React, so '__test' has bindings. */
4248
+ const PANEL = initPanel(PLACEHOLDER_REACT)
4249
+ const {
4250
+ DataRadarOverlay,
4251
+ MetricCard,
4252
+ NoteworthyList,
4253
+ Chart,
4254
+ StatsStrip,
4255
+ AiPanel,
4256
+ DetailDrawer,
4257
+ SettingsTab,
4258
+ AddIndicatorTab,
4259
+ Sparkline,
4260
+ } = PANEL
4261
+
4262
+ /**
4263
+ * Mount the browser half.
4264
+ *
4265
+ * @param {object} ctx - client plugin context.
4266
+ * @param {object} [deps] - injectable dependencies (tests pass stubs).
4267
+ * @param {object} [deps.React] - React instance.
4268
+ * @param {object} [deps.format] - formatting helpers.
4269
+ * @param {Document} [deps.document] - document (browser only).
4270
+ * @returns {void}
4271
+ */
4272
+ function apply(ctx, deps = {}) {
4273
+ const React = deps.React ?? require('react')
4274
+ // The shared core modules are concatenated into this same scope by the
4275
+ // bundler, so their symbols are referenced directly rather than re-imported.
4276
+ const format = deps.format ?? {
4277
+ buildLinePath,
4278
+ buildBarRects,
4279
+ buildSparklineShape,
4280
+ buildXAxis,
4281
+ buildYAxis,
4282
+ buildZeroLine,
4283
+ buildTimeAxis,
4284
+ buildCandles,
4285
+ buildCrosshair,
4286
+ buildReferenceLines,
4287
+ nearestIndex,
4288
+ valueDomain,
4289
+ hasOhlc,
4290
+ linearScale,
4291
+ niceTicks,
4292
+ formatValue,
4293
+ formatChange,
4294
+ formatAge,
4295
+ changeColor,
4296
+ statusDot,
4297
+ metricTooltip,
4298
+ truncate,
4299
+ }
4300
+ const runtime = createPanelRuntime(React, format, {
4301
+ locale: typeof navigator === 'undefined' ? undefined : navigator.language,
4302
+ sessions: ctx.get !== undefined ? ctx.get('sessions') : undefined,
4303
+ })
4304
+ const doc = deps.document ?? (typeof document === 'undefined' ? undefined : document)
4305
+
4306
+ if (doc !== undefined) {
4307
+ ctx.effect(() => insertStyles(doc), 'show-me-data: styles')
4308
+ }
4309
+
4310
+ ctx.slots.inject(SLOT, () =>
4311
+ ctx.slots.register({ name: SLOT, id: ENTRY_ID, order: 30 }, runtime.components.DataRadarOverlay),
4312
+ )
4313
+ }
4314
+
4315
+ // Symbols exposed for tests and for the shell to introspect.
4316
+ const __test = { MISSING_MARKERS, MIN_STDDEV_N, MIN_PERCENTILE_N, parseNumber, round, sortDedupe, ohlcOf, hasOhlc, mom, yoy, movingAverage, diff, stdDev, percentile, slope, zScoreLatestChange, missingCount, FREQ_CADENCE_DAYS, stats, percentileRank, applyTransform, transformSuffix, clamp, linearScale, niceTicks, extent, thin, box, coord, runs, buildLinePath, buildAreaPath, buildSparkline, buildSparklineShape, BODY_RATIO, MAX_BODY_PX, valueDomain, buildCandles, nearestIndex, indexToX, buildCrosshair, buildReferenceLines, valueAxis, buildBarRects, buildZeroLine, buildYAxis, buildEvenTicks, buildXAxis, formatAxisDate, formatSpanDate, buildTimeAxis, COLORS, formatValue, formatChange, formatAge, changeColor, statusDot, formatDate, metricTooltip, truncate, COPY, COPY_EN, GROUP_LABELS, copyFor, REQUEST_TIMEOUT_MS, API_ROOT, request, stream, parseSseFrame, api, INITIAL_STATE, createStore, applyOverview, applyDetail, applyIndicatorChange, applyAiText, applyAiResult, applyError, shouldShowDegradedBanner, DISCUSSION_POLL_INTERVAL_MS, DISCUSSION_POLL_MS, PANEL_CSS, insertStyles, createComponents, inject, SLOT, PLACEHOLDER_REACT, SHARED_FORMAT, ENTRY_ID, createPanelRuntime, initPanel, PANEL, apply, DataRadarOverlay, MetricCard, NoteworthyList, Chart, StatsStrip, AiPanel, DetailDrawer, SettingsTab, AddIndicatorTab, Sparkline }
4317
+ exports.apply = apply
4318
+ exports.inject = inject
4319
+ exports.__test = __test
4320
+ return module.exports
4321
+ },
4322
+ })