dsh-plugin-show-me-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Prompt construction — four pure functions (docs/06 §2).
3
+ *
4
+ * The rules the model is given are deliberately strict because the panel must
5
+ * never present an unsourced number: only '<data>' values may be cited, every
6
+ * conclusion carries '[id@date, value]', and insufficient data is a valid answer.
7
+ *
8
+ * @module core/ai/prompts
9
+ */
10
+
11
+ /** Default output budget for every prompt. */
12
+ export const MAX_CHARS = 1200
13
+
14
+ /** Default section budget. */
15
+ export const MAX_SECTIONS = 6
16
+
17
+ /** The shared system prompt (docs/06 §2.1). */
18
+ export const SYSTEM_PROMPT = `你是宏观数据分析助手。你的唯一数据来源是下面 <data> 块中提供的观测值。
19
+
20
+ 硬性规则:
21
+ 1. 只能引用 <data> 中出现过的指标 ID、日期与数值。禁止引入 <data> 之外的数据、事件或新闻。
22
+ 2. 每个结论句后面用 [指标ID@日期, 值] 的形式标注依据。
23
+ 3. 如果 <data> 不足以回答问题,直接说明"数据不足"并列出缺少什么,不要推测。
24
+ 4. 区分事实与解释:先用一句话陈述数据事实,再给出可能解释,并明确标注为"可能"。
25
+ 5. 不做点位预测,不给买卖建议,不使用"必然/一定/肯定"等绝对措辞。
26
+ 6. 单位与口径必须与 <data> 一致(% 就是 %,不要写成小数)。
27
+ 7. 输出中文,Markdown,最多 {maxSections} 个要点,总长不超过 {maxChars} 字。`
28
+
29
+ /**
30
+ * Render the system prompt with its budgets filled in.
31
+ *
32
+ * @param {{ maxChars?: number, maxSections?: number }} [options] - budgets.
33
+ * @returns {string} system prompt.
34
+ */
35
+ export function systemPrompt({ maxChars = MAX_CHARS, maxSections = MAX_SECTIONS } = {}) {
36
+ return SYSTEM_PROMPT.replace('{maxSections}', String(maxSections)).replace('{maxChars}', String(maxChars))
37
+ }
38
+
39
+ /**
40
+ * Wrap the digest in its '<data>' block. An empty digest says so explicitly, so
41
+ * the model cannot read an empty block as "no constraints".
42
+ *
43
+ * @param {string} digest - digest text.
44
+ * @returns {string} data block.
45
+ */
46
+ export function dataBlock(digest) {
47
+ const body = typeof digest === 'string' && digest.trim() !== '' ? digest.trim() : '(无数据:本次没有任何可用的指标观测)'
48
+ return `<data>\n${body}\n</data>`
49
+ }
50
+
51
+ /**
52
+ * The scenario prompts, as pure functions of structured input.
53
+ *
54
+ * @param {object} input - scenario input.
55
+ * @param {string} [input.digest] - '<data>' content.
56
+ * @param {string} [input.indicatorId] - indicator for 'explain'.
57
+ * @param {string} [input.label] - indicator label for 'explain'.
58
+ * @param {{ from: string, to: string }} [input.range] - range.
59
+ * @param {string} [input.question] - user question for 'answer'.
60
+ * @param {string} [input.scope] - scope label for 'overview'.
61
+ * @param {string} [input.request] - natural-language request for 'propose'.
62
+ * @param {string[]} [input.violations] - validator feedback for a retry.
63
+ * @param {string[]} [input.supportedAdapters] - adapters 'propose' may use.
64
+ * @returns {{ system: string, user: string }} messages.
65
+ */
66
+ export function buildExplainPrompt({ digest, indicatorId, label, range, violations }) {
67
+ const lines = []
68
+ lines.push(dataBlock(digest))
69
+ lines.push('')
70
+ lines.push(`任务:解析指标 ${indicatorId ?? '(未指定)'}${label === undefined ? '' : `(${label})`}`)
71
+ if (range !== undefined) lines.push(`时间范围:${range.from} .. ${range.to}`)
72
+ lines.push('输出 5 段:① 这个指标是什么(口径、单位、频率)② 本期读数意味着什么 ③ 与历史对比(分位/极值/趋势)④ 可能影响的资产或政策(标注"可能")⑤ 反向风险。')
73
+ lines.push(...retryLines(violations))
74
+ return { system: systemPrompt(), user: lines.join('\n') }
75
+ }
76
+
77
+ /**
78
+ * @param {object} input - scenario input.
79
+ * @returns {{ system: string, user: string }} messages.
80
+ */
81
+ export function buildSummaryPrompt({ digest, range, violations }) {
82
+ const lines = []
83
+ lines.push(dataBlock(digest))
84
+ lines.push('')
85
+ lines.push('任务:给出一段「时段总结」。')
86
+ if (range !== undefined) lines.push(`时间范围:${range.from} .. ${range.to}`)
87
+ lines.push('输出 4 段,每段 1–3 条:① 关键变化 ② 相互印证 ③ 背离与矛盾 ④ 下一个该盯的数据。')
88
+ lines.push(...retryLines(violations))
89
+ return { system: systemPrompt(), user: lines.join('\n') }
90
+ }
91
+
92
+ /**
93
+ * The whole-panel analysis prompt: "what is going on", not "what does this mean".
94
+ *
95
+ * It names the scope and the counts so the model can say "本组 12 个指标中 3 个
96
+ * 触发规则" instead of guessing at coverage, and it asks for cross-indicator
97
+ * structure — corroboration, divergence, and what to watch — because a summary
98
+ * that only restates each series adds nothing to the panel the reader already sees.
99
+ *
100
+ * @param {object} input - scenario input.
101
+ * @param {string} input.digest - '<data>' content.
102
+ * @param {string} [input.scope] - human scope label ('美国', '全部指标', …).
103
+ * @param {{ from: string, to: string }} [input.range] - range.
104
+ * @param {number} [input.indicatorCount] - observables in scope.
105
+ * @param {number} [input.noteworthyCount] - attention rules triggered in scope.
106
+ * @param {string[]} [input.violations] - feedback from the previous attempt.
107
+ * @returns {{ system: string, user: string }} messages.
108
+ */
109
+ export function buildOverviewPrompt({ digest, scope, range, indicatorCount, noteworthyCount, violations }) {
110
+ const lines = []
111
+ lines.push(dataBlock(digest))
112
+ lines.push('')
113
+ lines.push(`任务:给出「${scope ?? '整体'}」的总体分析。`)
114
+ if (range !== undefined) lines.push(`时间范围:${range.from} .. ${range.to}`)
115
+ if (Number.isFinite(indicatorCount)) lines.push(`本范围指标数:${indicatorCount}`)
116
+ if (Number.isFinite(noteworthyCount)) lines.push(`其中触发关注规则:${noteworthyCount}`)
117
+ lines.push('输出 5 段,每段 1–3 条,全部基于 <data>:')
118
+ lines.push('① 总体态势:这段区间内哪些指标在动、方向是否一致,先用事实句概括。')
119
+ lines.push('② 关键变化:列出变化幅度最大或触发规则的指标,每条带 [指标ID@日期, 值]。')
120
+ lines.push('③ 相互印证:哪些指标互相支持同一结论。')
121
+ lines.push('④ 背离与矛盾:哪些指标方向相反或口径不一致,需要同时看。')
122
+ lines.push('⑤ 下一个该盯的数据:指出本范围里最重要的待更新项及其滞后天数。')
123
+ lines.push('不要逐个复述指标定义;读者已经能看到面板上的单项数据。')
124
+ lines.push(...retryLines(violations))
125
+ return { system: systemPrompt(), user: lines.join('\n') }
126
+ }
127
+
128
+ /**
129
+ * @param {object} input - scenario input.
130
+ * @returns {{ system: string, user: string }} messages.
131
+ */
132
+ export function buildAnswerPrompt({ digest, question, range, violations }) {
133
+ const lines = []
134
+ lines.push(dataBlock(digest))
135
+ lines.push('')
136
+ lines.push(`用户问题:${typeof question === 'string' && question.trim() !== '' ? question.trim() : '(未提供问题)'}`)
137
+ if (range !== undefined) lines.push(`可用时间范围:${range.from} .. ${range.to}`)
138
+ lines.push('输出结构:结论文(≤3 句)→ 依据列表(每条带 [指标ID@日期, 值])→ 数据边界说明(哪些问题数据无法回答)。')
139
+ lines.push('只回答被问到的内容;问题超出 <data> 覆盖范围时,直接回答"数据不足"并说明缺什么。')
140
+ lines.push(...retryLines(violations))
141
+ return { system: systemPrompt(), user: lines.join('\n') }
142
+ }
143
+
144
+ /**
145
+ * 'propose' is a structured task: JSON out, no free text (docs/06 §4).
146
+ *
147
+ * @param {object} input - scenario input.
148
+ * @returns {{ system: string, user: string }} messages.
149
+ */
150
+ export function buildProposePrompt({ request, supportedAdapters = [], knownIds = [], violations }) {
151
+ const lines = []
152
+ lines.push('任务:把用户的自然语言需求解析成指标定义候选,只输出 JSON。')
153
+ lines.push(`用户需求:${typeof request === 'string' && request.trim() !== '' ? request.trim() : '(空)'}`)
154
+ lines.push('')
155
+ lines.push('只能使用下列已注册数据源适配器,且 seriesRef 必须是你确知存在的标识:')
156
+ for (const adapter of supportedAdapters) lines.push(`- ${adapter}`)
157
+ lines.push('')
158
+ if (knownIds.length > 0) lines.push(`已存在(会判为冲突):${knownIds.slice(0, 40).join(', ')}`)
159
+ lines.push('')
160
+ lines.push('输出 JSON:{ "candidates": [ { "id", "label": {"zh","en"}, "unit", "freq", "group", "importance", "source": {"adapter","seriesRef","params"}, "display": {"transform","decimals","polarity"}, "confidence", "rationale" } ], "unsupported": [ { "request", "reason", "alternatives": [] } ] }')
161
+ lines.push('规则:不确定的 seriesRef 一律放进 unsupported 并说明原因,绝对不要猜一个看起来合理的 ID。')
162
+ lines.push(...retryLines(violations))
163
+ return { system: systemPrompt({ maxSections: 1, maxChars: 4000 }), user: lines.join('\n') }
164
+ }
165
+
166
+ /**
167
+ * Feedback appended when the validator rejected a previous answer.
168
+ *
169
+ * @param {string[]|undefined} violations - violations from 'app/ai-validate.js'.
170
+ * @returns {string[]} lines (empty when there were none).
171
+ */
172
+ function retryLines(violations) {
173
+ if (!Array.isArray(violations) || violations.length === 0) return []
174
+ return [
175
+ '',
176
+ '上一次输出未通过溯源校验,请重写。问题清单:',
177
+ ...violations.slice(0, 10).map((entry) => `- ${entry}`),
178
+ '要求:只保留能在 <data> 中找到依据的结论,无法溯源的数字与断言一律删除。',
179
+ ]
180
+ }
181
+
182
+ /**
183
+ * The question a discussion asks when the reader clicks the button without
184
+ * typing one.
185
+ *
186
+ * A discussion opened with *only* context never runs a turn: the message waits in
187
+ * the session inbox for a turn that nothing starts, the session opens empty, and
188
+ * the panel — polling for an answer that cannot exist — reports "会话已结束但没有
189
+ * 文本回答(可能是模型未配置或请求失败)". The reader clicked a button labelled
190
+ * 「讨论」, so the honest behaviour is to ask something; the panel shows which
191
+ * question it asked, and the reader can continue in the session from there.
192
+ *
193
+ * @param {{ subject?: 'indicator'|'group'|'noteworthy', label?: string }} [options] - subject.
194
+ * @returns {string} the opening question.
195
+ */
196
+ export function buildDefaultQuestion({ subject = 'indicator', label } = {}) {
197
+ const name = typeof label === 'string' && label.trim() !== '' ? label.trim() : '这些数据'
198
+ if (subject === 'group') {
199
+ return `请基于上下文中的面板数据回答:${name}目前整体处于什么状态?最值得关注的变化有哪些?它们之间如何相互印证或背离?接下来该盯哪些数据?`
200
+ }
201
+ if (subject === 'noteworthy') {
202
+ return `这条「值得关注」为什么会被触发?请基于上下文中的面板数据说明 ${name} 当前的含义,以及接下来该盯什么。`
203
+ }
204
+ return `请基于上下文中的面板数据回答:${name}当前处于什么位置?最近的变化说明了什么?接下来该盯哪些相关数据?`
205
+ }
206
+
207
+ /** The four scenario builders, keyed by operation. */
208
+ export const PROMPT_BUILDERS = {
209
+ explain: buildExplainPrompt,
210
+ summarize: buildSummaryPrompt,
211
+ answer: buildAnswerPrompt,
212
+ propose: buildProposePrompt,
213
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Axis label geometry (docs/05 §4.1).
3
+ *
4
+ * @module core/chart/axis
5
+ */
6
+ import { box } from './line.js'
7
+ import { linearScale, niceTicks } from './scale.js'
8
+
9
+ /**
10
+ * Build y-axis tick labels.
11
+ *
12
+ * @param {{ min: number, max: number }} yDomain - y domain.
13
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, ticks?: number, decimals?: number, suffix?: string }} geometry - chart geometry.
14
+ * @returns {Array<{ y: number, value: number, label: string }>} tick entries.
15
+ */
16
+ export function buildYAxis(yDomain, geometry) {
17
+ const { innerH, padY } = box({ width: geometry.width ?? 1, height: geometry.height, padding: geometry.padding, paddingX: geometry.paddingX, paddingY: geometry.paddingY })
18
+ const decimals = geometry.decimals ?? 2
19
+ const suffix = geometry.suffix ?? ''
20
+ // No explicit count → derive "nice" 1/2/5×10ⁿ ticks from the domain; an
21
+ // explicit count → evenly spaced ticks inside the given domain.
22
+ const values = geometry.ticks === undefined
23
+ ? niceTicks(yDomain.min, yDomain.max, 5).ticks.filter((t) => t >= yDomain.min && t <= yDomain.max)
24
+ : buildEvenTicks(yDomain, geometry.ticks)
25
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
26
+ return values.map((value) => ({
27
+ y: Number(y(value).toFixed(2)),
28
+ value,
29
+ label: `${Number(value.toFixed(decimals))}${suffix}`,
30
+ }))
31
+ }
32
+
33
+ /**
34
+ * Evenly spaced ticks across a domain (used when the caller fixes the count).
35
+ *
36
+ * @param {{ min: number, max: number }} domain - domain.
37
+ * @param {number} count - tick count.
38
+ * @returns {number[]} tick values.
39
+ */
40
+ function buildEvenTicks(domain, count) {
41
+ if (count <= 1) return [domain.min]
42
+ const step = (domain.max - domain.min) / (count - 1)
43
+ return Array.from({ length: count }, (_, i) => Number((domain.min + step * i).toFixed(12)))
44
+ }
45
+
46
+ /**
47
+ * Build x-axis tick labels from date strings.
48
+ *
49
+ * @param {string[]} dates - ascending 'YYYY-MM-DD' values.
50
+ * @param {{ width: number, padding?: number, paddingX?: number, count?: number }} geometry - chart geometry.
51
+ * @returns {Array<{ x: number, label: string, t: string }>} tick entries.
52
+ */
53
+ export function buildXAxis(dates, geometry) {
54
+ if (!Array.isArray(dates) || dates.length === 0) return []
55
+ const { innerW, padX } = box({ width: geometry.width, height: 1, padding: geometry.padding, paddingX: geometry.paddingX })
56
+ const wanted = Math.min(geometry.count ?? 4, dates.length)
57
+ const step = dates.length <= wanted ? 1 : (dates.length - 1) / (wanted - 1)
58
+ const picked = []
59
+ for (let i = 0; i < wanted; i += 1) picked.push(Math.round(i * step))
60
+ const unique = [...new Set(picked)].filter((i) => i >= 0 && i < dates.length)
61
+ const span = Math.max(1, dates.length - 1)
62
+ return unique.map((index) => ({
63
+ x: Number((padX + (index / span) * innerW).toFixed(2)),
64
+ label: formatAxisDate(dates[index]),
65
+ t: dates[index],
66
+ }))
67
+ }
68
+
69
+ /**
70
+ * Short axis date: 'MM-DD' inside one year, 'YYYY-MM' across years.
71
+ *
72
+ * @param {string} date - 'YYYY-MM-DD'.
73
+ * @returns {string} axis label.
74
+ */
75
+ export function formatAxisDate(date) {
76
+ return `${date.slice(5, 7)}-${date.slice(8, 10)}`
77
+ }
78
+
79
+ /**
80
+ * Axis date label that stays unambiguous across a multi-year window.
81
+ *
82
+ * A daily chart spanning years labelled only 'MM-DD' reads as repeating dates,
83
+ * so the month is enough inside one year and the year is shown once the window
84
+ * crosses a boundary (docs/05 §4.2).
85
+ *
86
+ * @param {string} date - 'YYYY-MM-DD'.
87
+ * @param {{ crossesYears?: boolean }} [options] - options.
88
+ * @returns {string} axis label.
89
+ */
90
+ export function formatSpanDate(date, options = {}) {
91
+ return options.crossesYears === true ? `${date.slice(0, 4)}-${date.slice(5, 7)}` : formatAxisDate(date)
92
+ }
93
+
94
+ /**
95
+ * X ticks for a time series drawn with the shared plot box.
96
+ *
97
+ * Placed by index rather than by value, because the chart is index-spaced (a
98
+ * weekend must not open a gap), and it always keeps the first and last point so
99
+ * the axis states the window it covers.
100
+ *
101
+ * @param {string[]} dates - ascending 'YYYY-MM-DD' values.
102
+ * @param {{ width: number, height?: number, paddingX?: number, paddingY?: number, padding?: number, count?: number }} geometry - chart geometry.
103
+ * @returns {Array<{ x: number, label: string, t: string, anchor: 'start'|'middle'|'end' }>} ticks.
104
+ */
105
+ export function buildTimeAxis(dates, geometry) {
106
+ if (!Array.isArray(dates) || dates.length === 0) return []
107
+ const { padX, innerW } = box({
108
+ width: geometry.width,
109
+ height: geometry.height ?? 1,
110
+ padding: geometry.padding,
111
+ paddingX: geometry.paddingX,
112
+ paddingY: geometry.paddingY,
113
+ padLeft: geometry.padLeft,
114
+ padRight: geometry.padRight,
115
+ padX: geometry.padX,
116
+ innerW: geometry.innerW,
117
+ })
118
+ const wanted = Math.min(Math.max(2, geometry.count ?? 5), dates.length)
119
+ const crossesYears = dates[0].slice(0, 4) !== dates[dates.length - 1].slice(0, 4)
120
+ const span = dates.length - 1
121
+ const picked = new Set([0, span])
122
+ for (let i = 1; i < wanted - 1; i += 1) picked.add(Math.round((i / (wanted - 1)) * span))
123
+ return [...picked]
124
+ .sort((a, b) => a - b)
125
+ .map((index) => ({
126
+ index,
127
+ x: Number((padX + (span === 0 ? innerW / 2 : (index / span) * innerW)).toFixed(2)),
128
+ t: dates[index],
129
+ label: formatSpanDate(dates[index], { crossesYears }),
130
+ // Edge labels are anchored inward so they never run off the plot.
131
+ anchor: index === 0 ? 'start' : index === span ? 'end' : 'middle',
132
+ }))
133
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Bar-chart rectangles and axis geometry (docs/05 §4.1).
3
+ *
4
+ * @module core/chart/bar
5
+ */
6
+ import { box } from './line.js'
7
+ import { extent, linearScale } from './scale.js'
8
+
9
+ /**
10
+ * Build bar rectangles for a column chart. Bars are anchored to the zero line
11
+ * when the domain crosses zero, so positive and negative values read correctly.
12
+ *
13
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
14
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, yDomain?: { min: number, max: number }, gap?: number }} geometry - chart geometry.
15
+ * @returns {Array<{ x: number, y: number, w: number, h: number, t: string, v: number }>} rectangles.
16
+ */
17
+ export function buildBarRects(points, geometry) {
18
+ if (!Array.isArray(points) || points.length === 0) return []
19
+ const { innerW, innerH, padX, padY } = box(geometry)
20
+ const finite = points.filter((p) => Number.isFinite(p?.v))
21
+ if (finite.length === 0) return []
22
+
23
+ const dataExtent = extent(finite.map((p) => p.v))
24
+ const yDomain = geometry.yDomain ?? { min: Math.min(0, dataExtent.min), max: Math.max(0, dataExtent.max) }
25
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
26
+ const gap = geometry.gap ?? 1
27
+ const slot = innerW / finite.length
28
+ const width = Math.max(1, slot - gap)
29
+ const zeroY = y(0)
30
+
31
+ return finite.map((point, index) => {
32
+ const valueY = y(point.v)
33
+ const top = Math.min(zeroY, valueY)
34
+ const height = Math.max(1, Math.abs(valueY - zeroY))
35
+ return {
36
+ x: Number((padX + index * slot + gap / 2).toFixed(2)),
37
+ y: Number(top.toFixed(2)),
38
+ w: Number(width.toFixed(2)),
39
+ h: Number(height.toFixed(2)),
40
+ t: point.t,
41
+ v: point.v,
42
+ }
43
+ })
44
+ }
45
+
46
+ /**
47
+ * The y coordinate of the zero line, or 'null' when zero is outside the domain.
48
+ *
49
+ * @param {{ min: number, max: number }} yDomain - y domain.
50
+ * @param {{ height: number, padding?: number, paddingY?: number }} geometry - chart geometry.
51
+ * @returns {number|null} zero-line y coordinate.
52
+ */
53
+ export function buildZeroLine(yDomain, geometry) {
54
+ const { innerH, padY } = box({ width: 1, height: geometry.height, padding: geometry.padding, paddingY: geometry.paddingY })
55
+ if (!(yDomain.min <= 0 && yDomain.max >= 0)) return null
56
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
57
+ return Number(y(0).toFixed(2))
58
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Candlestick geometry and hover snapping (docs/05 §4).
3
+ *
4
+ * The panel draws its own SVG, so a candle is four numbers turned into two
5
+ * screen-space primitives: a thin high-low wick and a body spanning open to
6
+ * close. Everything here is a pure function of points and geometry, which keeps
7
+ * the interactive chart testable without a browser.
8
+ *
9
+ * Colour is deliberately *not* decided here: the caller owns polarity, and the
10
+ * only rule this layer encodes is the arithmetic — a candle is up when the close
11
+ * is at or above the open.
12
+ *
13
+ * @module core/chart/candle
14
+ */
15
+
16
+ import { ohlcOf } from '../stats/series.js'
17
+ import { box } from './line.js'
18
+ import { clamp, extent, linearScale, niceTicks } from './scale.js'
19
+
20
+ /** Default share of one x-step a candle body may occupy. */
21
+ export const BODY_RATIO = 0.7
22
+
23
+ /**
24
+ * Widest a candle body may be drawn, in px.
25
+ *
26
+ * With few points the step is enormous, and a one-step-wide bar reads as a
27
+ * block rather than a bar; capping the width keeps three points looking like
28
+ * three candles.
29
+ */
30
+ export const MAX_BODY_PX = 16
31
+
32
+ /**
33
+ * The value domain a chart should cover.
34
+ *
35
+ * A candlestick view must span the wicks, not only the closes: using the close
36
+ * extent would clip highs and lows at the top and bottom of the plot.
37
+ *
38
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} points - ascending points.
39
+ * @param {{ useExtremes?: boolean }} [options] - options.
40
+ * @returns {{ min: number, max: number }|undefined} domain, or 'undefined' when nothing is finite.
41
+ */
42
+ export function valueDomain(points, options = {}) {
43
+ const values = []
44
+ for (const point of points ?? []) {
45
+ if (!Number.isFinite(point?.v)) continue
46
+ const bar = options.useExtremes === true ? ohlcOf(point) : undefined
47
+ values.push(bar === undefined ? point.v : bar.h, bar === undefined ? point.v : bar.l)
48
+ }
49
+ return extent(values)
50
+ }
51
+
52
+ /**
53
+ * Build candle geometry for a point list.
54
+ *
55
+ * Every entry carries the numbers the renderer needs and nothing else: `up` is
56
+ * the arithmetic fact (close >= open) that the caller maps to a colour, and
57
+ * `hollow` marks a bar whose body is too short to fill, which is how a flat
58
+ * session still shows as a visible tick rather than disappearing.
59
+ *
60
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} points - ascending points.
61
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, yDomain?: { min: number, max: number }, bodyRatio?: number, maxBody?: number, minBody?: number }} geometry - chart geometry.
62
+ * @returns {Array<{ t: string, x: number, yHigh: number, yLow: number, yOpen: number, yClose: number, bodyTop: number, bodyH: number, up: boolean, hollow: boolean, point: object }>} candles.
63
+ */
64
+ export function buildCandles(points, geometry) {
65
+ const list = points ?? []
66
+ const bars = list.filter((point) => ohlcOf(point) !== undefined)
67
+ if (bars.length === 0) return []
68
+ const { innerW, padX, padY, innerH } = box({ ...geometry, height: geometry.height })
69
+ const domain = geometry.yDomain ?? valueDomain(list, { useExtremes: true })
70
+ if (domain === undefined) return []
71
+ const y = linearScale({ domain: [domain.min, domain.max], range: [padY + innerH, padY] })
72
+ const step = list.length > 1 ? innerW / (list.length - 1) : innerW
73
+ const bodyW = Math.max(1, Math.min(geometry.maxBody ?? MAX_BODY_PX, step * (geometry.bodyRatio ?? BODY_RATIO)))
74
+ const minBody = geometry.minBody ?? 1
75
+ return list.map((point, index) => {
76
+ const bar = ohlcOf(point)
77
+ if (bar === undefined) return undefined
78
+ const x = padX + (list.length > 1 ? (index / (list.length - 1)) * innerW : innerW / 2)
79
+ const yOpen = y(bar.o)
80
+ const yClose = y(point.v)
81
+ const yHigh = y(bar.h)
82
+ const yLow = y(bar.l)
83
+ const bodyTop = Math.min(yOpen, yClose)
84
+ const bodyH = Math.abs(yClose - yOpen)
85
+ return {
86
+ t: point.t,
87
+ x: Number(x.toFixed(2)),
88
+ bodyW: Number(bodyW.toFixed(2)),
89
+ yHigh: Number(yHigh.toFixed(2)),
90
+ yLow: Number(yLow.toFixed(2)),
91
+ yOpen: Number(yOpen.toFixed(2)),
92
+ yClose: Number(yClose.toFixed(2)),
93
+ bodyTop: Number(bodyTop.toFixed(2)),
94
+ bodyH: Number(Math.max(minBody, bodyH).toFixed(2)),
95
+ up: point.v >= bar.o,
96
+ hollow: bodyH < minBody,
97
+ point,
98
+ }
99
+ }).filter(Boolean)
100
+ }
101
+
102
+ /**
103
+ * Snap a pointer position to the nearest data index.
104
+ *
105
+ * Charts are read by pointing at a shape, so the hover target is the nearest
106
+ * *point*, never the exact pixel: this is what makes a 250-bar chart usable with
107
+ * a mouse and what a touch device needs to hit anything at all.
108
+ *
109
+ * @param {number} pointerX - pointer x in svg coordinates.
110
+ * @param {number} count - number of points.
111
+ * @param {{ width: number, padding?: number, paddingX?: number }} geometry - chart geometry.
112
+ * @returns {number} nearest index, or -1 when there is nothing to snap to.
113
+ */
114
+ export function nearestIndex(pointerX, count, geometry) {
115
+ if (!Number.isFinite(pointerX) || !(count > 0)) return -1
116
+ const { padX, innerW } = box({ ...geometry, height: geometry.height ?? 1 })
117
+ if (count === 1) return 0
118
+ const ratio = clamp((pointerX - padX) / innerW, 0, 1)
119
+ return Math.round(ratio * (count - 1))
120
+ }
121
+
122
+ /**
123
+ * The x coordinate of one index inside the plotting box.
124
+ *
125
+ * @param {number} index - point index.
126
+ * @param {number} count - number of points.
127
+ * @param {{ width: number, padding?: number, paddingX?: number }} geometry - chart geometry.
128
+ * @returns {number} x in svg coordinates.
129
+ */
130
+ export function indexToX(index, count, geometry) {
131
+ const { padX, innerW } = box({ ...geometry, height: geometry.height ?? 1 })
132
+ if (count <= 1) return padX + innerW / 2
133
+ return Number((padX + (index / (count - 1)) * innerW).toFixed(2))
134
+ }
135
+
136
+ /**
137
+ * Build the crosshair and its labels for one hovered index.
138
+ *
139
+ * @param {object} input - input.
140
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} input.points - ascending points.
141
+ * @param {number} input.index - hovered index.
142
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, yDomain?: { min: number, max: number } }} input.geometry - chart geometry.
143
+ * @returns {{ x: number, y: number, index: number, point: object, bar: object|undefined, anchoredLeft: boolean }|undefined} crosshair state.
144
+ */
145
+ export function buildCrosshair({ points, index, geometry }) {
146
+ const list = points ?? []
147
+ if (index < 0 || index >= list.length) return undefined
148
+ const point = list[index]
149
+ const { innerW, padX, padY, innerH } = box({ ...geometry, height: geometry.height ?? 1 })
150
+ const bar = ohlcOf(point)
151
+ const domain = geometry.yDomain ?? valueDomain(list, { useExtremes: bar !== undefined })
152
+ const y = linearScale({ domain: domain === undefined ? [0, 1] : [domain.min, domain.max], range: [padY + innerH, padY] })
153
+ const x = indexToX(index, list.length, geometry)
154
+ return {
155
+ index,
156
+ x,
157
+ y: Number(y(point.v).toFixed(2)),
158
+ point,
159
+ bar,
160
+ // The tooltip flips side near the right edge so it never runs out of frame.
161
+ anchoredLeft: x > padX + innerW * 0.62,
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Horizontal reference lines worth drawing for a series.
167
+ *
168
+ * A reader compares the latest reading against the window, so the mean and the
169
+ * two extremes are the lines that carry information. Each one is emitted only
170
+ * when it is finite, and identical values collapse: a two-point series has a
171
+ * mean but a flat one has extremes equal to the last value, which would stack
172
+ * three labels on one pixel.
173
+ *
174
+ * @param {{ min?: number, max?: number, mean?: number }} [stats] - series statistics.
175
+ * @param {{ min: number, max: number }} domain - drawn domain.
176
+ * @param {{ plot: { x: number, y: number, w: number, h: number } }} layout - resolved plot box.
177
+ * @returns {Array<{ kind: string, label: string, value: number, y: number, x1: number, x2: number }>} reference lines.
178
+ */
179
+ export function buildReferenceLines(stats, domain, layout) {
180
+ if (stats === undefined || domain === undefined || layout?.plot === undefined) return []
181
+ const { x, w, y: top, h } = layout.plot
182
+ const y = linearScale({ domain: [domain.min, domain.max], range: [top + h, top] })
183
+ const candidates = [
184
+ { kind: 'max', value: stats.max, label: '高' },
185
+ { kind: 'mean', value: stats.mean, label: '均' },
186
+ { kind: 'min', value: stats.min, label: '低' },
187
+ ]
188
+ const out = []
189
+ const seen = new Set()
190
+ for (const candidate of candidates) {
191
+ if (!Number.isFinite(candidate.value)) continue
192
+ const key = candidate.value.toFixed(6)
193
+ if (seen.has(key)) continue
194
+ seen.add(key)
195
+ out.push({
196
+ kind: candidate.kind,
197
+ label: candidate.label,
198
+ value: candidate.value,
199
+ y: Number(y(candidate.value).toFixed(2)),
200
+ x1: x,
201
+ x2: x + w,
202
+ })
203
+ }
204
+ return out
205
+ }
206
+
207
+ /**
208
+ * A readable tick list for the value axis.
209
+ *
210
+ * @param {{ min: number, max: number }} domain - data domain.
211
+ * @param {number} [count] - desired ticks.
212
+ * @returns {{ min: number, max: number, ticks: number[] }} nice domain and ticks.
213
+ */
214
+ export function valueAxis(domain, count = 5) {
215
+ return niceTicks(domain.min, domain.max, count)
216
+ }