@363045841yyt/klinechart-core 0.8.4 → 0.8.6

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 (79) hide show
  1. package/dist/config/chartSettings.d.ts +1 -1
  2. package/dist/config/chartSettings.d.ts.map +1 -1
  3. package/dist/config/chartSettings.js +8 -4
  4. package/dist/config/chartSettings.js.map +1 -1
  5. package/dist/controllers/createChartController.d.ts.map +1 -1
  6. package/dist/controllers/createChartController.js +11 -1
  7. package/dist/controllers/createChartController.js.map +1 -1
  8. package/dist/controllers/types.d.ts +2 -0
  9. package/dist/controllers/types.d.ts.map +1 -1
  10. package/dist/data-fetchers/dataBuffer.d.ts.map +1 -1
  11. package/dist/data-fetchers/dataBuffer.js +1 -4
  12. package/dist/data-fetchers/dataBuffer.js.map +1 -1
  13. package/dist/data-fetchers/gotdx.d.ts.map +1 -1
  14. package/dist/data-fetchers/gotdx.js +1 -0
  15. package/dist/data-fetchers/gotdx.js.map +1 -1
  16. package/dist/engine/controller/interaction.d.ts +6 -0
  17. package/dist/engine/controller/interaction.d.ts.map +1 -1
  18. package/dist/engine/controller/interaction.js +51 -8
  19. package/dist/engine/controller/interaction.js.map +1 -1
  20. package/dist/engine/data/chartDataManager.js +1 -1
  21. package/dist/engine/data/chartDataManager.js.map +1 -1
  22. package/dist/engine/indicators/calculators.d.ts.map +1 -1
  23. package/dist/engine/indicators/calculators.js +20 -3
  24. package/dist/engine/indicators/calculators.js.map +1 -1
  25. package/dist/engine/indicators/ichimokuState.d.ts +2 -0
  26. package/dist/engine/indicators/ichimokuState.d.ts.map +1 -1
  27. package/dist/engine/indicators/ichimokuState.js.map +1 -1
  28. package/dist/engine/indicators/visibleStateComposers.d.ts +14 -0
  29. package/dist/engine/indicators/visibleStateComposers.d.ts.map +1 -1
  30. package/dist/engine/indicators/visibleStateComposers.js +34 -0
  31. package/dist/engine/indicators/visibleStateComposers.js.map +1 -1
  32. package/dist/engine/renderers/Indicator/ichimoku.d.ts.map +1 -1
  33. package/dist/engine/renderers/Indicator/ichimoku.js +25 -3
  34. package/dist/engine/renderers/Indicator/ichimoku.js.map +1 -1
  35. package/dist/engine/renderers/Indicator/mainIndicatorLegend.d.ts.map +1 -1
  36. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js +92 -0
  37. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js.map +1 -1
  38. package/dist/engine/renderers/crosshair.js +1 -1
  39. package/dist/engine/renderers/crosshair.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/mcp/chartBridge.d.ts +3 -0
  45. package/dist/mcp/chartBridge.d.ts.map +1 -1
  46. package/dist/mcp/chartBridge.js +15 -2
  47. package/dist/mcp/chartBridge.js.map +1 -1
  48. package/dist/tokens/theme-dark.js +1 -1
  49. package/dist/utils/kLineDraw/axis.js +1 -1
  50. package/dist/utils/kLineDraw/axis.js.map +1 -1
  51. package/dist/utils/uuid.d.ts +2 -0
  52. package/dist/utils/uuid.d.ts.map +1 -0
  53. package/dist/utils/uuid.js +10 -0
  54. package/dist/utils/uuid.js.map +1 -0
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +3 -2
  58. package/src/config/chartSettings.ts +14 -10
  59. package/src/controllers/createChartController.ts +10 -1
  60. package/src/controllers/types.ts +2 -0
  61. package/src/data-fetchers/__tests__/dataBuffer.test.ts +1 -1
  62. package/src/data-fetchers/dataBuffer.ts +1 -4
  63. package/src/data-fetchers/gotdx.ts +2 -0
  64. package/src/engine/controller/interaction.ts +56 -9
  65. package/src/engine/data/chartDataManager.ts +1 -1
  66. package/src/engine/indicators/__tests__/ichimoku.test.ts +3 -3
  67. package/src/engine/indicators/calculators.ts +22 -3
  68. package/src/engine/indicators/ichimokuState.ts +2 -0
  69. package/src/engine/indicators/visibleStateComposers.ts +51 -0
  70. package/src/engine/renderers/Indicator/ichimoku.ts +23 -3
  71. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +102 -0
  72. package/src/engine/renderers/crosshair.ts +1 -1
  73. package/src/index.ts +1 -0
  74. package/src/mcp/chartBridge.ts +20 -2
  75. package/src/tokens/__tests__/__snapshots__/baseline.test.ts.snap +1 -1
  76. package/src/tokens/theme-dark.ts +1 -1
  77. package/src/utils/kLineDraw/axis.ts +1 -1
  78. package/src/utils/uuid.ts +8 -0
  79. package/src/version.ts +1 -1
@@ -190,7 +190,7 @@ describe('DataBuffer', () => {
190
190
  })
191
191
 
192
192
  buffer.ensureRange(oneYearAgo - 30 * MS_PER_DAY, oneYearAgo)
193
- buffer.ensureRange(oneYearAgo - 60 * MS_PER_DAY, oneYearAgo)
193
+ buffer.ensureRange(oneYearAgo - 120 * MS_PER_DAY, oneYearAgo)
194
194
 
195
195
  await vi.waitFor(() => {
196
196
  expect(buffer.loading()).toBe(false)
@@ -97,15 +97,12 @@ export class DataBuffer {
97
97
 
98
98
  if (requestStartTs >= this._loadedWindow.earliestTs) return
99
99
 
100
- const incrementalStart = requestStartTs - INCREMENTAL_LOAD_DAYS * MS_PER_DAY
101
100
  const incrementalEnd = this._loadedWindow.earliestTs
102
101
 
103
- if (incrementalEnd <= incrementalStart) return
104
-
105
102
  if (this._attemptedBoundaries.has(incrementalEnd)) return
106
103
 
107
104
  this._attemptedBoundaries.add(incrementalEnd)
108
- this.fetchRange(incrementalStart, incrementalEnd)
105
+ this.fetchRange(requestStartTs, incrementalEnd)
109
106
  }
110
107
 
111
108
  private loadInitial(): void {
@@ -42,6 +42,7 @@ interface SecurityBar {
42
42
  Turnover: number
43
43
  RisePrice: number
44
44
  RiseRate: number
45
+ Amplitude: number
45
46
  Year: number
46
47
  Month: number
47
48
  Day: number
@@ -76,6 +77,7 @@ function mapBar(item: SecurityBar, code: string): KLineData {
76
77
  turnoverRate: item.Turnover,
77
78
  changeAmount: item.RisePrice,
78
79
  changePercent: item.RiseRate,
80
+ amplitude: item.Amplitude,
79
81
  stockCode: code,
80
82
  }
81
83
  }
@@ -36,7 +36,7 @@ export interface InteractionSnapshot {
36
36
  export class InteractionController {
37
37
  private chart: Chart
38
38
  private isDragging = false
39
- private dragMode: 'none' | 'pan' | 'resize-separator' | 'scale-price' = 'none'
39
+ private dragMode: 'none' | 'pan' | 'resize-separator' | 'scale-price' | 'explore' = 'none'
40
40
  private dragStartX = 0
41
41
  private scrollStartX = 0
42
42
 
@@ -62,6 +62,13 @@ export class InteractionController {
62
62
  /** [触屏]:触摸会话标记,避免触摸触发的模拟 mouse 事件干扰 */
63
63
  private isTouchSession = false
64
64
 
65
+ /** 触屏探索模式:true=长按出十字线不滚动,false=直接滚动 */
66
+ private exploreMode = true
67
+ /** 触屏按下时的时间戳/位置(用于 tap 检测) */
68
+ private touchStartTime = 0
69
+ private touchStartX = 0
70
+ private touchStartY = 0
71
+
65
72
  private pinchTracker = new PinchTracker()
66
73
 
67
74
  /** 十字线位置 */
@@ -217,14 +224,22 @@ export class InteractionController {
217
224
 
218
225
  const pane = this.getPaneByY(mouseY)
219
226
  this.isDragging = true
220
- this.dragMode = 'pan'
221
- this.updatePlotHoverFromPoint(e.clientX, e.clientY)
227
+ this.touchStartTime = Date.now()
228
+ this.touchStartX = e.clientX
229
+ this.touchStartY = e.clientY
230
+ this.dragMode = this.isTouchSession && this.exploreMode ? 'explore' : 'pan'
231
+ if (this.dragMode === 'explore') {
232
+ this.updatePlotHoverFromPoint(e.clientX, e.clientY)
233
+ }
222
234
  this.dragStartX = e.clientX
223
235
  this.dragStartY = e.clientY
224
236
  this.scrollStartX = this.chart.getCachedScrollLeft()
225
237
  this.activePaneIdOnDrag = pane?.id || null
226
238
 
227
239
  this.chart.scheduleDraw()
240
+ if (this.dragMode === 'explore') {
241
+ this.notifyInteractionChange()
242
+ }
228
243
  }
229
244
 
230
245
 
@@ -250,14 +265,37 @@ export class InteractionController {
250
265
 
251
266
  if (e.isPrimary === false) return
252
267
  const wasPanning = this.dragMode === 'pan'
268
+ const wasExploring = this.dragMode === 'explore'
269
+
270
+ if (wasExploring && this.isTouchSession) {
271
+ const elapsed = Date.now() - this.touchStartTime
272
+ const dx = e.clientX - this.touchStartX
273
+ const dy = e.clientY - this.touchStartY
274
+ if (elapsed < 200 && Math.abs(dx) < 5 && Math.abs(dy) < 5) {
275
+ // Quick tap → dismiss crosshair, switch to scroll mode
276
+ this.exploreMode = false
277
+ this.clearHover()
278
+ this.chart.scheduleDraw()
279
+ this.notifyInteractionChange()
280
+ } else {
281
+ // Long press or drag → keep crosshair
282
+ this.exploreMode = true
283
+ this.updatePlotHoverFromPoint(e.clientX, e.clientY)
284
+ this.chart.scheduleDraw()
285
+ this.notifyInteractionChange()
286
+ }
287
+ }
288
+
289
+ if (wasPanning) {
290
+ this.exploreMode = true
291
+ this.chart.checkVisibleRangeGap()
292
+ }
293
+
253
294
  this.isDragging = false
254
295
  this.dragMode = 'none'
255
296
  this.activePaneIdOnDrag = null
256
297
  this.activeSeparatorUpperPaneId = null
257
298
  this.notifyInteractionChange()
258
- if (wasPanning) {
259
- this.chart.checkVisibleRangeGap()
260
- }
261
299
  }
262
300
 
263
301
  /**
@@ -273,10 +311,12 @@ export class InteractionController {
273
311
  this.dragMode = 'none'
274
312
  this.activePaneIdOnDrag = null
275
313
  this.clearSeparatorState()
314
+ if (!this.isTouchSession) {
315
+ this.clearHover()
316
+ this.chart.scheduleDraw()
317
+ this.notifyInteractionChange()
318
+ }
276
319
  this.isTouchSession = false
277
- this.clearHover()
278
- this.chart.scheduleDraw()
279
- this.notifyInteractionChange()
280
320
  }
281
321
 
282
322
  /** 处理滚动事件 */
@@ -326,6 +366,13 @@ export class InteractionController {
326
366
  return
327
367
  }
328
368
 
369
+ if (this.dragMode === 'explore') {
370
+ this.updatePlotHoverFromPoint(e.clientX, e.clientY)
371
+ this.chart.scheduleDraw()
372
+ this.notifyInteractionChange()
373
+ return
374
+ }
375
+
329
376
  if (this.dragMode === 'pan') {
330
377
  const deltaX = this.dragStartX - e.clientX
331
378
  this.applyPanScroll(container, this.scrollStartX + deltaX)
@@ -348,7 +348,7 @@ export class ChartDataManager {
348
348
  let firstVisibleTs: number | undefined
349
349
 
350
350
  if (range.start < 0 && this._dataFetcher) {
351
- const earlierThanEarliest = window.earliestTs - 90 * MS_PER_DAY
351
+ const earlierThanEarliest = window.earliestTs - 365 * MS_PER_DAY
352
352
  this._dataBuffer.ensureRange(earlierThanEarliest, window.earliestTs)
353
353
  firstVisibleTs = this._internalData[0]?.timestamp
354
354
  } else if (range.start < this._internalData.length) {
@@ -15,10 +15,10 @@ describe('calcIchimokuData', () => {
15
15
 
16
16
  it('on constantPrice (H=L=100) all lines collapse to 100', () => {
17
17
  // Only constantPrice (30 bars) is shorter than spanBPeriod=52, so use small periods for this test
18
- const out = calcIchimokuData(constantPrice, 5, 10, 15, 5)
19
- // Test data is 30 bars, so spanA/B/chikou exist within the window
18
+ const displacement = 5
19
+ const out = calcIchimokuData(constantPrice, 5, 10, 15, displacement)
20
20
  const valid = out.filter((p): p is NonNullable<typeof p> => p !== undefined)
21
- expect(valid.length).toBe(constantPrice.length)
21
+ expect(valid.length).toBe(constantPrice.length + displacement)
22
22
  // After warm-up, tenkan and kijun should both be 100
23
23
  for (let t = 15; t < out.length - 5; t++) {
24
24
  const p = out[t]!
@@ -1559,7 +1559,7 @@ export function calcDonchianDataSoA(
1559
1559
  // spanA(t) = (tenkan(t-displacement) + kijun(t-displacement)) / 2 ← 前置 displacement
1560
1560
  // spanB(t) = 用 spanBPeriod 计算后再前置 displacement
1561
1561
  // chikou(t) = close(t+displacement) ← 后置 displacement
1562
- // 注:不做未来云的延伸(输出长度 = data.length;最后 displacement 根没 spanA/B;前 displacement 根没 chikou
1562
+ // 输出长度 = data.length + displacement,末尾 displacement 根为未来云(仅 spanA/spanB
1563
1563
  // ============================================================================
1564
1564
 
1565
1565
  export interface IchimokuPoint {
@@ -1600,8 +1600,11 @@ export function calcIchimokuData(
1600
1600
  displacement: number,
1601
1601
  ): (IchimokuPoint | undefined)[] {
1602
1602
  const n = data.length
1603
- const result: (IchimokuPoint | undefined)[] = new Array(n).fill(undefined)
1604
- if (n === 0 || tenkanPeriod <= 0 || kijunPeriod <= 0 || spanBPeriod <= 0) return result
1603
+ const totalLen = n + displacement
1604
+ const result: (IchimokuPoint | undefined)[] = new Array(totalLen).fill(undefined)
1605
+ if (n === 0 || tenkanPeriod <= 0 || kijunPeriod <= 0 || spanBPeriod <= 0) {
1606
+ return result.slice(0, n)
1607
+ }
1605
1608
 
1606
1609
  const tenkan = _rollingMidline(data, tenkanPeriod)
1607
1610
  const kijun = _rollingMidline(data, kijunPeriod)
@@ -1631,6 +1634,22 @@ export function calcIchimokuData(
1631
1634
  result[t] = point
1632
1635
  }
1633
1636
 
1637
+ // 未来云:在 data 末尾延伸 displacement 根,仅含 spanA/spanB
1638
+ for (let f = 0; f < displacement; f++) {
1639
+ const t = n + f
1640
+ const src = t - displacement
1641
+ const point: IchimokuPoint = {}
1642
+ if (src >= 0 && src < n) {
1643
+ if (tenkan[src] !== undefined && kijun[src] !== undefined) {
1644
+ point.spanA = (tenkan[src]! + kijun[src]!) / 2
1645
+ }
1646
+ if (spanBSource[src] !== undefined) {
1647
+ point.spanB = spanBSource[src]
1648
+ }
1649
+ }
1650
+ result[t] = point
1651
+ }
1652
+
1634
1653
  return result
1635
1654
  }
1636
1655
 
@@ -10,6 +10,8 @@ import { createIndicatorStateKey } from '../../plugin/stateKeys'
10
10
  * - chikou (迟行线) = close[t+displacement],后置位移
11
11
  *
12
12
  * 任一字段都可能 undefined(数据不足或位移外)。
13
+ *
14
+ * series 长度 = data.length + displacement,末尾 displacement 槽位为未来云(仅 spanA/spanB)。
13
15
  */
14
16
  export interface IchimokuPoint {
15
17
  tenkan?: number
@@ -542,6 +542,57 @@ export function createValuePointVisibleStateComposer<T extends object>(
542
542
  }
543
543
  }
544
544
 
545
+ /**
546
+ * 一目均衡表专用的 visible state composer。
547
+ * 与 createValuePointVisibleStateComposer 的区别:
548
+ * - 将 visibleRange 向后扩展 displacement 根,以确保未来云的极值计入 valueMin/valueMax
549
+ */
550
+ export function createIchimokuVisibleStateComposer<T extends object>(
551
+ bundleKey: string,
552
+ emptyState: {
553
+ timestamp: number
554
+ series: (T | undefined)[]
555
+ params: unknown
556
+ valueMin: number
557
+ valueMax: number
558
+ visibleMin: number
559
+ visibleMax: number
560
+ },
561
+ fields: readonly (keyof T)[],
562
+ ): IndicatorVisibleStateComposer {
563
+ return ({ bundle, visibleRange, timestamp, active }) => {
564
+ const source = getPointArraySeriesBundle<T>(bundle, bundleKey)
565
+ if (!active) {
566
+ return {
567
+ ...emptyState,
568
+ timestamp,
569
+ series: source.series,
570
+ params: source.params,
571
+ }
572
+ }
573
+
574
+ const displacement = (source.params as Record<string, unknown>)?.displacement as number ?? 26
575
+ const extendedRange = {
576
+ start: visibleRange.start,
577
+ end: Math.min(visibleRange.end + displacement, source.series.length),
578
+ }
579
+ const extremes = calcPointArrayExtremes(source.series, fields, extendedRange)
580
+ const bounds = computeMAFamilyBounds(
581
+ Number.isFinite(extremes.min) && Number.isFinite(extremes.max) ? extremes : null,
582
+ emptyState,
583
+ )
584
+ return {
585
+ timestamp,
586
+ series: source.series,
587
+ params: source.params,
588
+ valueMin: bounds.valueMin,
589
+ valueMax: bounds.valueMax,
590
+ visibleMin: extremes.min,
591
+ visibleMax: extremes.max,
592
+ }
593
+ }
594
+ }
595
+
545
596
  export function createBandVisibleStateComposer<T extends object>(
546
597
  bundleKey: string,
547
598
  emptyState: {
@@ -8,7 +8,8 @@ import { Indicator } from '../../indicators/indicatorDefinitionRegistry'
8
8
  import { resolveStateKey, type TitleInfo, type TitleValueItem, type GetTitleInfoFn } from '../../indicators/indicatorMetadata'
9
9
  import type { IndicatorScheduler, IchimokuSchedulerConfig } from '../../indicators/scheduler'
10
10
  import { calcIchimokuData } from '../../indicators/calculators'
11
- import { createValuePointVisibleStateComposer } from '../../indicators/visibleStateComposers'
11
+ import { createIchimokuVisibleStateComposer } from '../../indicators/visibleStateComposers'
12
+ import { getPhysicalKLineConfig } from '../../utils/klineConfig'
12
13
 
13
14
  const TENKAN_COLOR = '#dc2626'
14
15
  const KIJUN_COLOR = '#2563eb'
@@ -50,7 +51,7 @@ export function createIchimokuRendererPlugin(options: IchimokuRendererOptions =
50
51
  description: '一目均衡表渲染器(WebGL 线 + Canvas2D 云图)',
51
52
  debugName: 'Ichimoku',
52
53
  paneId,
53
- priority: RENDERER_PRIORITY.MAIN,
54
+ priority: RENDERER_PRIORITY.INDICATOR,
54
55
 
55
56
  onInstall(host: PluginHost) { pluginHost = host },
56
57
  getDeclaredNamespaces() { const key = resolveKey(); return key ? [key] : [] },
@@ -90,6 +91,25 @@ export function createIchimokuRendererPlugin(options: IchimokuRendererOptions =
90
91
  }
91
92
  }
92
93
 
94
+ // 未来云:在数据末尾延伸 displacement 根 spanA/spanB 线及云段
95
+ const dataLen = (context.data as unknown[]).length
96
+ if (dataLen < series.length) {
97
+ const physConfig = getPhysicalKLineConfig(context.kWidth, context.kGap, context.dpr)
98
+ const futureEnd = Math.min(dataLen + params.displacement, series.length)
99
+ for (let i = dataLen; i < futureEnd; i++) {
100
+ const p = series[i]
101
+ if (!p) continue
102
+ const leftPx = physConfig.startXPx + i * physConfig.unitPx
103
+ const wickXPx = leftPx + (physConfig.kWidthPx - 1) / 2
104
+ const centerX = wickXPx / context.dpr
105
+ if (params.showSpanA && p.spanA !== undefined) spanAPts.push({ x: centerX, y: toY(p.spanA) })
106
+ if (params.showSpanB && p.spanB !== undefined) spanBPts.push({ x: centerX, y: toY(p.spanB) })
107
+ if (params.showCloud && p.spanA !== undefined && p.spanB !== undefined) {
108
+ cloudSegs.push({ x: centerX, ya: toY(p.spanA), yb: toY(p.spanB), bull: p.spanA > p.spanB })
109
+ }
110
+ }
111
+ }
112
+
93
113
  // Cloud fill (Canvas2D only)
94
114
  if (params.showCloud && cloudSegs.length >= 2) {
95
115
  ctx.save()
@@ -209,7 +229,7 @@ export function getIchimokuTitleInfo(
209
229
  allowMainPane: true,
210
230
  mainPane: { rendererName: 'ichimoku_main', toActiveConfig: (params, active) => ({ ...params, showTenkan: active, showKijun: active, showSpanA: active, showSpanB: active, showChikou: active, showCloud: active }) },
211
231
  scale: { indicatorKey: 'ichimoku', label: 'Ichimoku', decimals: 2 },
212
- visibleState: { compose: createValuePointVisibleStateComposer('ichimoku', EMPTY_ICHIMOKU_STATE, ['tenkan', 'kijun', 'spanA', 'spanB', 'chikou']) },
232
+ visibleState: { compose: createIchimokuVisibleStateComposer('ichimoku', EMPTY_ICHIMOKU_STATE, ['tenkan', 'kijun', 'spanA', 'spanB', 'chikou']) },
213
233
  runtime: { defaultConfig:{tenkanPeriod:9,kijunPeriod:26,spanBPeriod:52,displacement:26,showTenkan:true,showKijun:true,showSpanA:true,showSpanB:true,showCloud:true,showChikou:true}, computeKey:'calcIchimokuData', compute:(data,c)=>calcIchimokuData(data,c.tenkanPeriod,c.kijunPeriod,c.spanBPeriod,c.displacement) },
214
234
  })
215
235
  class IchimokuDefinition {
@@ -81,6 +81,102 @@ export function createMainIndicatorLegendRendererPlugin(options: {
81
81
  const targetIndex = crosshairIndex ?? Math.min(range.end - 1, klineData.length - 1)
82
82
  const rows: Array<{ draw: (rowIndex: number) => void }> = []
83
83
 
84
+ if (typeof crosshairIndex === 'number') {
85
+ const k = klineData[targetIndex]
86
+ if (k) {
87
+ const isUp = k.close >= k.open
88
+ const volText = typeof k.volume === 'number' ? formatVolumeShort(k.volume) : null
89
+ const upColor = isUp ? colors.candleUpBody : colors.candleDownBody
90
+
91
+ if (context.paneWidth >= 400) {
92
+ rows.push({
93
+ draw: (rowIndex: number) => {
94
+ let x = legendX
95
+ const y = config.yPaddingPx / 2 + legendYOffset + rowIndex * lineHeight
96
+
97
+ overlayCtx.fillStyle = colors.text.primary
98
+ overlayCtx.fillText('O ', x, y)
99
+ x += measureTextWidth(overlayCtx, 'O ')
100
+ overlayCtx.fillStyle = upColor
101
+ overlayCtx.fillText(k.open.toFixed(2), x, y)
102
+ x += measureTextWidth(overlayCtx, k.open.toFixed(2)) + gap
103
+
104
+ overlayCtx.fillStyle = colors.text.primary
105
+ overlayCtx.fillText('H ', x, y)
106
+ x += measureTextWidth(overlayCtx, 'H ')
107
+ overlayCtx.fillText(k.high.toFixed(2), x, y)
108
+ x += measureTextWidth(overlayCtx, k.high.toFixed(2)) + gap
109
+
110
+ overlayCtx.fillText('L ', x, y)
111
+ x += measureTextWidth(overlayCtx, 'L ')
112
+ overlayCtx.fillText(k.low.toFixed(2), x, y)
113
+ x += measureTextWidth(overlayCtx, k.low.toFixed(2)) + gap
114
+
115
+ overlayCtx.fillStyle = colors.text.primary
116
+ overlayCtx.fillText('C ', x, y)
117
+ x += measureTextWidth(overlayCtx, 'C ')
118
+ overlayCtx.fillStyle = upColor
119
+ overlayCtx.fillText(k.close.toFixed(2), x, y)
120
+ x += measureTextWidth(overlayCtx, k.close.toFixed(2)) + gap
121
+
122
+ if (volText) {
123
+ overlayCtx.fillStyle = colors.text.tertiary
124
+ overlayCtx.fillText('Vol ', x, y)
125
+ x += measureTextWidth(overlayCtx, 'Vol ')
126
+ overlayCtx.fillStyle = colors.text.primary
127
+ overlayCtx.fillText(volText, x, y)
128
+ }
129
+ },
130
+ })
131
+ } else {
132
+ rows.push({
133
+ draw: (rowIndex: number) => {
134
+ let x = legendX
135
+ const y = config.yPaddingPx / 2 + legendYOffset + rowIndex * lineHeight
136
+
137
+ overlayCtx.fillStyle = colors.text.primary
138
+ overlayCtx.fillText('O ', x, y)
139
+ x += measureTextWidth(overlayCtx, 'O ')
140
+ overlayCtx.fillStyle = upColor
141
+ overlayCtx.fillText(k.open.toFixed(2), x, y)
142
+ x += measureTextWidth(overlayCtx, k.open.toFixed(2)) + gap
143
+
144
+ overlayCtx.fillStyle = colors.text.primary
145
+ overlayCtx.fillText('H ', x, y)
146
+ x += measureTextWidth(overlayCtx, 'H ')
147
+ overlayCtx.fillText(k.high.toFixed(2), x, y)
148
+ x += measureTextWidth(overlayCtx, k.high.toFixed(2)) + gap
149
+
150
+ overlayCtx.fillText('L ', x, y)
151
+ x += measureTextWidth(overlayCtx, 'L ')
152
+ overlayCtx.fillText(k.low.toFixed(2), x, y)
153
+ },
154
+ })
155
+ rows.push({
156
+ draw: (rowIndex: number) => {
157
+ let x = legendX
158
+ const y = config.yPaddingPx / 2 + legendYOffset + rowIndex * lineHeight
159
+
160
+ overlayCtx.fillStyle = colors.text.primary
161
+ overlayCtx.fillText('C ', x, y)
162
+ x += measureTextWidth(overlayCtx, 'C ')
163
+ overlayCtx.fillStyle = upColor
164
+ overlayCtx.fillText(k.close.toFixed(2), x, y)
165
+ x += measureTextWidth(overlayCtx, k.close.toFixed(2)) + gap
166
+
167
+ if (volText) {
168
+ overlayCtx.fillStyle = colors.text.tertiary
169
+ overlayCtx.fillText('Vol ', x, y)
170
+ x += measureTextWidth(overlayCtx, 'Vol ')
171
+ overlayCtx.fillStyle = colors.text.primary
172
+ overlayCtx.fillText(volText, x, y)
173
+ }
174
+ },
175
+ })
176
+ }
177
+ }
178
+ }
179
+
84
180
  const scheduler = pluginHost && typeof pluginHost.getService === 'function'
85
181
  ? pluginHost.getService<IndicatorScheduler>('indicatorScheduler')
86
182
  : undefined
@@ -237,3 +333,9 @@ function findBaselineByTimestamp(data: ReadonlyArray<KLineData>, timestamp: numb
237
333
  }
238
334
  return null
239
335
  }
336
+
337
+ function formatVolumeShort(v: number): string {
338
+ if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
339
+ if (v >= 1e4) return (v / 1e4).toFixed(2) + '万'
340
+ return v.toFixed(2)
341
+ }
@@ -30,7 +30,7 @@ export function createCrosshairRendererPlugin(options: {
30
30
  const colors = resolveThemeColors(context.theme, context.isAsiaMarket, context.colorPresetSettings)
31
31
  const state = options.getCrosshairState()
32
32
 
33
- if (state.isDragging || !state.pos) return
33
+ if (!state.pos) return
34
34
 
35
35
  const { x } = state.pos
36
36
  const isActive = pane.id === state.activePaneId
package/src/index.ts CHANGED
@@ -4,3 +4,4 @@ export * from './mcp'
4
4
  export { VERSION } from './version'
5
5
  export * from './tokens'
6
6
  export { formatTimestamp } from './utils/dateFormat'
7
+ export { generateUUID } from './utils/uuid'
@@ -1,4 +1,5 @@
1
1
  import type { ToolCall, ToolResult, ControllerDescription, ToolCallHandler } from './types'
2
+ import { generateUUID } from '../utils/uuid'
2
3
 
3
4
  export interface ChartBridgeOptions {
4
5
  wsUrl: string
@@ -6,6 +7,7 @@ export interface ChartBridgeOptions {
6
7
  sessionId?: string
7
8
  autoReconnect?: boolean
8
9
  reconnectDelay?: number
10
+ maxReconnectDelay?: number
9
11
  heartbeatInterval?: number
10
12
  wsImpl?: new (url: string) => WebSocket
11
13
  }
@@ -22,6 +24,7 @@ export class ChartBridge {
22
24
  readonly sessionId: string
23
25
  private readonly autoReconnect: boolean
24
26
  private readonly reconnectDelay: number
27
+ private readonly maxReconnectDelay: number
25
28
  private readonly heartbeatInterval: number
26
29
  private readonly onToolCall: ToolCallHandler
27
30
 
@@ -29,6 +32,7 @@ export class ChartBridge {
29
32
  private ws: WebSocket | null = null
30
33
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
31
34
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null
35
+ private _reconnectAttempt = 0
32
36
  private destroyed = false
33
37
 
34
38
  private listeners = new Map<ChartBridgeEvent, Set<MessageHandler>>()
@@ -39,9 +43,10 @@ export class ChartBridge {
39
43
  onStateChange?: () => void
40
44
 
41
45
  constructor(options: ChartBridgeOptions) {
42
- this.sessionId = options.sessionId ?? crypto.randomUUID()
46
+ this.sessionId = options.sessionId ?? generateUUID()
43
47
  this.autoReconnect = options.autoReconnect ?? true
44
48
  this.reconnectDelay = options.reconnectDelay ?? 3000
49
+ this.maxReconnectDelay = options.maxReconnectDelay ?? 30_000
45
50
  this.heartbeatInterval = options.heartbeatInterval ?? 30_000
46
51
  this.onToolCall = options.onToolCall
47
52
  this.wsImpl = options.wsImpl ?? WebSocket
@@ -59,6 +64,7 @@ export class ChartBridge {
59
64
  const ws = new this.wsImpl(this.wsUrl)
60
65
 
61
66
  ws.onopen = () => {
67
+ this._reconnectAttempt = 0
62
68
  this.ws = ws
63
69
  console.info(
64
70
  `[ChartBridge] WS opened → sending register (sessionId=${this.sessionId})`,
@@ -115,6 +121,7 @@ export class ChartBridge {
115
121
 
116
122
  destroy(): void {
117
123
  this.destroyed = true
124
+ this._reconnectAttempt = 0
118
125
  this.disconnect()
119
126
  this.listeners.clear()
120
127
  }
@@ -188,11 +195,22 @@ export class ChartBridge {
188
195
 
189
196
  private scheduleReconnect(): void {
190
197
  this.cancelReconnect()
198
+ const base = this.reconnectDelay
199
+ const attempt = this._reconnectAttempt
200
+ const exponential = Math.min(base * Math.pow(2, attempt), this.maxReconnectDelay)
201
+ const jitter = 0.5 + Math.random() * 0.5
202
+ const delay = Math.round(exponential * jitter)
203
+
204
+ this._reconnectAttempt = attempt + 1
205
+ console.info(
206
+ `[ChartBridge] reconnect scheduled in ${delay}ms (attempt ${attempt + 1})`,
207
+ )
208
+
191
209
  this.reconnectTimer = setTimeout(() => {
192
210
  if (!this.destroyed) {
193
211
  this.connect()
194
212
  }
195
- }, this.reconnectDelay)
213
+ }, delay)
196
214
  }
197
215
 
198
216
  private cancelReconnect(): void {
@@ -64,7 +64,7 @@ exports[`theme baseline — dark > CSS declaration block (snapshot) 1`] = `
64
64
  --klc-color-tag-bg-transparent: transparent;
65
65
  --klc-color-tag-bg-active: #1890ff;
66
66
  --klc-color-tag-bg-active-hover: #40a9ff;
67
- --klc-color-tag-bg-hover: #3a3a4a;
67
+ --klc-color-tag-bg-hover: #262C36;
68
68
  --klc-color-border-dark: rgba(255, 255, 255, 0.15);
69
69
  --klc-color-border-medium: rgba(255, 255, 255, 0.12);
70
70
  --klc-color-border-light: rgba(255, 255, 255, 0.08);
@@ -105,7 +105,7 @@ export const darkTheme: Theme = {
105
105
  transparent: 'transparent',
106
106
  active: '#1890ff',
107
107
  activeHover: '#40a9ff',
108
- hover: '#3a3a4a',
108
+ hover: '#262C36',
109
109
  },
110
110
  border: {
111
111
  dark: 'rgba(255, 255, 255, 0.15)',
@@ -505,7 +505,7 @@ export function drawAxisPriceLabel(ctx: CanvasRenderingContext2D, opts: AxisPric
505
505
 
506
506
  const centerX = x + width / 2
507
507
  ctx.fillStyle = textColor
508
- ctx.fillText(priceText, roundToPhysicalPixel(centerX, dpr), alignToPhysicalPixelCenter(yy, dpr))
508
+ ctx.fillText(priceText, roundToPhysicalPixel(centerX, dpr), roundToPhysicalPixel(yy, dpr) + 1)
509
509
 
510
510
  ctx.restore()
511
511
  }
@@ -0,0 +1,8 @@
1
+ export function generateUUID(): string {
2
+ if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
3
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
4
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40
5
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80
6
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'))
7
+ return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`
8
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.8.4"
1
+ export const VERSION = "0.8.6"