@363045841yyt/klinechart-core 0.7.3 → 0.7.5-alpha.2

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 (231) hide show
  1. package/README.md +201 -201
  2. package/README.zh-CN.md +201 -201
  3. package/dist/engine/renderers/webgl/candleSurface.js +47 -47
  4. package/dist/version.d.ts +1 -1
  5. package/dist/version.d.ts.map +1 -1
  6. package/dist/version.js +1 -2
  7. package/dist/version.js.map +1 -1
  8. package/package.json +129 -122
  9. package/src/__tests__/signal.test.ts +124 -124
  10. package/src/config/chartSettings.ts +66 -66
  11. package/src/controllers/__tests__/drawing.test.ts +214 -214
  12. package/src/controllers/__tests__/indicatorSelector.test.ts +481 -481
  13. package/src/controllers/__tests__/toolbar.test.ts +225 -225
  14. package/src/controllers/createChartController.ts +665 -665
  15. package/src/controllers/createDrawingController.ts +96 -96
  16. package/src/controllers/createIndicatorSelectorController.ts +307 -307
  17. package/src/controllers/createToolbarController.ts +146 -146
  18. package/src/controllers/index.ts +19 -19
  19. package/src/controllers/types.ts +284 -284
  20. package/src/engine/__tests__/chart.dpr.test.ts +401 -401
  21. package/src/engine/__tests__/paneRenderer.resize.test.ts +92 -92
  22. package/src/engine/chart-store.ts +121 -121
  23. package/src/engine/chart.d.ts +617 -617
  24. package/src/engine/chart.ts +2815 -2815
  25. package/src/engine/controller/__tests__/interaction.dpr.test.ts +259 -259
  26. package/src/engine/controller/interaction.ts +722 -722
  27. package/src/engine/controller/markerInteraction.ts +130 -130
  28. package/src/engine/controller/pinchTracker.ts +82 -82
  29. package/src/engine/controller/tooltipPosition.ts +48 -48
  30. package/src/engine/draw/__tests__/pixelAlign.spec.ts +176 -176
  31. package/src/engine/draw/pixelAlign.ts +259 -259
  32. package/src/engine/drawing/index.ts +655 -655
  33. package/src/engine/drawing/interaction.ts +842 -842
  34. package/src/engine/drawing/plugin.ts +343 -343
  35. package/src/engine/indicators/__tests__/__fixtures__/golden/atr.json +38 -38
  36. package/src/engine/indicators/__tests__/__fixtures__/golden/dema.json +14 -14
  37. package/src/engine/indicators/__tests__/__fixtures__/golden/hma.json +14 -14
  38. package/src/engine/indicators/__tests__/__fixtures__/golden/index.ts +55 -55
  39. package/src/engine/indicators/__tests__/__fixtures__/golden/kama.json +14 -14
  40. package/src/engine/indicators/__tests__/__fixtures__/golden/tema.json +14 -14
  41. package/src/engine/indicators/__tests__/__fixtures__/golden/wma.json +40 -40
  42. package/src/engine/indicators/__tests__/__fixtures__/synthetic.ts +65 -65
  43. package/src/engine/indicators/__tests__/_propertyAssertions.ts +76 -76
  44. package/src/engine/indicators/__tests__/atr.test.ts +153 -153
  45. package/src/engine/indicators/__tests__/calculators.test.ts +614 -614
  46. package/src/engine/indicators/__tests__/cmf-mfi.test.ts +100 -100
  47. package/src/engine/indicators/__tests__/dema.test.ts +73 -73
  48. package/src/engine/indicators/__tests__/donchian.test.ts +70 -70
  49. package/src/engine/indicators/__tests__/hma.test.ts +73 -73
  50. package/src/engine/indicators/__tests__/ichimoku.test.ts +105 -105
  51. package/src/engine/indicators/__tests__/kama.test.ts +80 -80
  52. package/src/engine/indicators/__tests__/keltner.test.ts +65 -65
  53. package/src/engine/indicators/__tests__/pivot-fib.test.ts +110 -110
  54. package/src/engine/indicators/__tests__/roc.test.ts +68 -68
  55. package/src/engine/indicators/__tests__/sar.test.ts +86 -86
  56. package/src/engine/indicators/__tests__/scheduler.test.ts +831 -831
  57. package/src/engine/indicators/__tests__/soa.test.ts +533 -533
  58. package/src/engine/indicators/__tests__/structure.test.ts +110 -110
  59. package/src/engine/indicators/__tests__/supertrend.test.ts +65 -65
  60. package/src/engine/indicators/__tests__/tema.test.ts +68 -68
  61. package/src/engine/indicators/__tests__/trix.test.ts +70 -70
  62. package/src/engine/indicators/__tests__/volatility.test.ts +117 -117
  63. package/src/engine/indicators/__tests__/volume.test.ts +115 -115
  64. package/src/engine/indicators/__tests__/volumeProfile.test.ts +74 -74
  65. package/src/engine/indicators/__tests__/vwap.test.ts +69 -69
  66. package/src/engine/indicators/__tests__/wma.test.ts +112 -112
  67. package/src/engine/indicators/__tests__/zones.test.ts +95 -95
  68. package/src/engine/indicators/atrState.ts +27 -27
  69. package/src/engine/indicators/bollState.ts +51 -51
  70. package/src/engine/indicators/calculators.ts +2593 -2593
  71. package/src/engine/indicators/cciState.ts +25 -25
  72. package/src/engine/indicators/chaikinVolState.ts +32 -32
  73. package/src/engine/indicators/cmfState.ts +27 -27
  74. package/src/engine/indicators/demaState.ts +27 -27
  75. package/src/engine/indicators/donchianState.ts +43 -43
  76. package/src/engine/indicators/eneState.ts +43 -43
  77. package/src/engine/indicators/expmaState.ts +43 -43
  78. package/src/engine/indicators/fastkState.ts +25 -25
  79. package/src/engine/indicators/fibState.ts +41 -41
  80. package/src/engine/indicators/hmaState.ts +27 -27
  81. package/src/engine/indicators/hvState.ts +28 -28
  82. package/src/engine/indicators/ichimokuState.ts +70 -70
  83. package/src/engine/indicators/indicator.worker.ts +169 -169
  84. package/src/engine/indicators/indicatorDefinitionRegistry.ts +62 -62
  85. package/src/engine/indicators/indicatorMetadata.ts +110 -110
  86. package/src/engine/indicators/indicatorRegistry.ts +106 -106
  87. package/src/engine/indicators/indicatorRuntime.ts +1548 -1548
  88. package/src/engine/indicators/kamaState.ts +34 -34
  89. package/src/engine/indicators/keltnerState.ts +49 -49
  90. package/src/engine/indicators/kstState.ts +42 -42
  91. package/src/engine/indicators/maState.ts +36 -36
  92. package/src/engine/indicators/macdState.ts +76 -76
  93. package/src/engine/indicators/mfiState.ts +27 -27
  94. package/src/engine/indicators/momState.ts +25 -25
  95. package/src/engine/indicators/obvState.ts +25 -25
  96. package/src/engine/indicators/parkinsonState.ts +28 -28
  97. package/src/engine/indicators/pivotState.ts +51 -51
  98. package/src/engine/indicators/pvtState.ts +25 -25
  99. package/src/engine/indicators/rocState.ts +27 -27
  100. package/src/engine/indicators/rsiState.ts +65 -65
  101. package/src/engine/indicators/sarState.ts +41 -41
  102. package/src/engine/indicators/scheduler.ts +1205 -1205
  103. package/src/engine/indicators/soa.ts +352 -352
  104. package/src/engine/indicators/stateComposer.ts +1262 -1262
  105. package/src/engine/indicators/stochState.ts +26 -26
  106. package/src/engine/indicators/structureState.ts +69 -69
  107. package/src/engine/indicators/supertrendState.ts +37 -37
  108. package/src/engine/indicators/temaState.ts +27 -27
  109. package/src/engine/indicators/trixState.ts +35 -35
  110. package/src/engine/indicators/vmaState.ts +27 -27
  111. package/src/engine/indicators/volumeProfileState.ts +63 -63
  112. package/src/engine/indicators/vwapState.ts +29 -29
  113. package/src/engine/indicators/wmaState.ts +27 -27
  114. package/src/engine/indicators/wmsrState.ts +25 -25
  115. package/src/engine/indicators/workerProtocol.ts +613 -613
  116. package/src/engine/indicators/zonesState.ts +47 -47
  117. package/src/engine/layout/pane.ts +161 -161
  118. package/src/engine/marker/registry.ts +265 -265
  119. package/src/engine/paneRenderer.ts +169 -169
  120. package/src/engine/renderers/Indicator/atr.ts +237 -237
  121. package/src/engine/renderers/Indicator/boll.ts +317 -317
  122. package/src/engine/renderers/Indicator/cci.ts +275 -275
  123. package/src/engine/renderers/Indicator/chaikinVol.ts +138 -138
  124. package/src/engine/renderers/Indicator/cmf.ts +137 -137
  125. package/src/engine/renderers/Indicator/dema.ts +136 -136
  126. package/src/engine/renderers/Indicator/donchian.ts +137 -137
  127. package/src/engine/renderers/Indicator/ene.ts +271 -271
  128. package/src/engine/renderers/Indicator/expma.ts +197 -197
  129. package/src/engine/renderers/Indicator/fastk.ts +316 -316
  130. package/src/engine/renderers/Indicator/fib.ts +141 -141
  131. package/src/engine/renderers/Indicator/hma.ts +136 -136
  132. package/src/engine/renderers/Indicator/hv.ts +124 -124
  133. package/src/engine/renderers/Indicator/ichimoku.ts +181 -181
  134. package/src/engine/renderers/Indicator/index.ts +241 -241
  135. package/src/engine/renderers/Indicator/indicatorData.ts +650 -650
  136. package/src/engine/renderers/Indicator/kama.ts +136 -136
  137. package/src/engine/renderers/Indicator/keltner.ts +137 -137
  138. package/src/engine/renderers/Indicator/kst.ts +302 -302
  139. package/src/engine/renderers/Indicator/ma.ts +200 -200
  140. package/src/engine/renderers/Indicator/macd.ts +477 -477
  141. package/src/engine/renderers/Indicator/macdLegend.ts +141 -141
  142. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +272 -272
  143. package/src/engine/renderers/Indicator/mfi.ts +142 -142
  144. package/src/engine/renderers/Indicator/mom.ts +311 -311
  145. package/src/engine/renderers/Indicator/obv.ts +123 -123
  146. package/src/engine/renderers/Indicator/parkinson.ts +124 -124
  147. package/src/engine/renderers/Indicator/pivot.ts +131 -131
  148. package/src/engine/renderers/Indicator/pvt.ts +123 -123
  149. package/src/engine/renderers/Indicator/roc.ts +143 -143
  150. package/src/engine/renderers/Indicator/rsi.ts +390 -390
  151. package/src/engine/renderers/Indicator/sar.ts +113 -113
  152. package/src/engine/renderers/Indicator/scale/atr_scale.ts +19 -19
  153. package/src/engine/renderers/Indicator/scale/cci_scale.ts +19 -19
  154. package/src/engine/renderers/Indicator/scale/fastk_scale.ts +19 -19
  155. package/src/engine/renderers/Indicator/scale/indicator_scale.ts +204 -204
  156. package/src/engine/renderers/Indicator/scale/kst_scale.ts +19 -19
  157. package/src/engine/renderers/Indicator/scale/macd_scale.ts +22 -22
  158. package/src/engine/renderers/Indicator/scale/mom_scale.ts +19 -19
  159. package/src/engine/renderers/Indicator/scale/rsi_scale.ts +19 -19
  160. package/src/engine/renderers/Indicator/scale/stoch_scale.ts +19 -19
  161. package/src/engine/renderers/Indicator/scale/volume_scale.ts +26 -26
  162. package/src/engine/renderers/Indicator/scale/wmsr_scale.ts +19 -19
  163. package/src/engine/renderers/Indicator/stoch.ts +359 -359
  164. package/src/engine/renderers/Indicator/structure.ts +126 -126
  165. package/src/engine/renderers/Indicator/subPaneConfig.ts +265 -265
  166. package/src/engine/renderers/Indicator/supertrend.ts +115 -115
  167. package/src/engine/renderers/Indicator/tema.ts +136 -136
  168. package/src/engine/renderers/Indicator/trix.ts +158 -158
  169. package/src/engine/renderers/Indicator/vma.ts +124 -124
  170. package/src/engine/renderers/Indicator/volumeProfile.ts +125 -125
  171. package/src/engine/renderers/Indicator/vwap.ts +123 -123
  172. package/src/engine/renderers/Indicator/wma.ts +136 -136
  173. package/src/engine/renderers/Indicator/wmsr.ts +328 -328
  174. package/src/engine/renderers/Indicator/zones.ts +104 -104
  175. package/src/engine/renderers/__tests__/boll.renderer.test.ts +314 -314
  176. package/src/engine/renderers/__tests__/ene.renderer.test.ts +305 -305
  177. package/src/engine/renderers/__tests__/expma.renderer.test.ts +279 -279
  178. package/src/engine/renderers/__tests__/ma.renderer.test.ts +426 -426
  179. package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +502 -502
  180. package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +173 -173
  181. package/src/engine/renderers/candle.ts +459 -459
  182. package/src/engine/renderers/crosshair.ts +69 -69
  183. package/src/engine/renderers/customMarkers.ts +162 -162
  184. package/src/engine/renderers/extremaMarkers.ts +246 -246
  185. package/src/engine/renderers/gridLines.ts +90 -90
  186. package/src/engine/renderers/lastPrice.ts +97 -97
  187. package/src/engine/renderers/paneTitle.ts +136 -136
  188. package/src/engine/renderers/subVolume.ts +236 -236
  189. package/src/engine/renderers/timeAxis.ts +121 -121
  190. package/src/engine/renderers/webgl/candleSurface.ts +955 -955
  191. package/src/engine/renderers/webgl/sharedWebGLSurface.ts +146 -146
  192. package/src/engine/renderers/yAxis.ts +105 -105
  193. package/src/engine/scale/__tests__/logFormula.spec.ts +148 -148
  194. package/src/engine/scale/logFormula.ts +130 -130
  195. package/src/engine/scale/price.ts +39 -39
  196. package/src/engine/scale/priceScale.ts +264 -264
  197. package/src/engine/subPaneManager.ts +427 -427
  198. package/src/engine/theme/colors.ts +642 -642
  199. package/src/engine/theme/fonts.ts +20 -20
  200. package/src/engine/utils/klineConfig.ts +49 -49
  201. package/src/engine/utils/tickCount.ts +11 -11
  202. package/src/engine/utils/tickPosition.ts +214 -214
  203. package/src/engine/utils/zoom.ts +83 -83
  204. package/src/engine/viewport/viewport.ts +67 -67
  205. package/src/index.ts +3 -3
  206. package/src/plugin/ConfigManager.ts +93 -93
  207. package/src/plugin/EventBus.ts +77 -77
  208. package/src/plugin/HookSystem.ts +106 -106
  209. package/src/plugin/PluginHost.ts +243 -243
  210. package/src/plugin/PluginRegistry.ts +92 -92
  211. package/src/plugin/StateStore.ts +73 -73
  212. package/src/plugin/index.ts +19 -19
  213. package/src/plugin/rendererPluginManager.ts +368 -368
  214. package/src/plugin/stateKeys.ts +8 -8
  215. package/src/plugin/types.ts +526 -526
  216. package/src/reactivity/index.ts +2 -2
  217. package/src/reactivity/signal.ts +119 -119
  218. package/src/semantic/controller.ts +251 -251
  219. package/src/semantic/drawShape.ts +260 -260
  220. package/src/semantic/index.ts +28 -28
  221. package/src/semantic/schema.json +256 -256
  222. package/src/semantic/types.ts +251 -251
  223. package/src/semantic/validator.ts +349 -349
  224. package/src/types/kLine.ts +13 -13
  225. package/src/types/price.ts +56 -56
  226. package/src/types/volumePrice.ts +33 -33
  227. package/src/utils/dateFormat.ts +208 -208
  228. package/src/utils/kLineDraw/axis.ts +562 -562
  229. package/src/utils/priceToY.ts +34 -34
  230. package/src/utils/volumePrice.ts +202 -202
  231. package/src/version.ts +1 -1
@@ -1,722 +1,722 @@
1
- // 交互控制中心
2
-
3
- import type { Chart } from '../chart'
4
- import type { MarkerEntity, CustomMarkerEntity } from '../marker/registry'
5
- import { MarkerInteractionState } from './markerInteraction'
6
- import { PinchTracker } from './pinchTracker'
7
- import { computeTooltipPosition } from './tooltipPosition'
8
- import { UpdateLevel } from '../layout/pane'
9
- import type { ChartSettings } from '../../config/chartSettings'
10
-
11
- interface PointerLocation {
12
- mouseX: number
13
- mouseY: number
14
- }
15
-
16
- export interface InteractionSnapshot {
17
- crosshairPos: { x: number; y: number } | null
18
- crosshairIndex: number | null
19
- crosshairPrice: number | null
20
- hoveredIndex: number | null
21
- activePaneId: string | null
22
- tooltipPos: { x: number; y: number }
23
- tooltipAnchorPlacement: 'right-bottom' | 'left-bottom'
24
- hoveredMarkerData: MarkerEntity | null
25
- hoveredCustomMarker: CustomMarkerEntity | null
26
- isDragging: boolean
27
- isResizingPaneBoundary: boolean
28
- isHoveringPaneBoundary: boolean
29
- hoveredPaneBoundaryId: string | null
30
- isHoveringRightAxis: boolean
31
- }
32
-
33
- /**
34
- * 交互控制器,处理拖拽滚动、缩放、十字线 hover 等交互逻辑
35
- */
36
- export class InteractionController {
37
- private chart: Chart
38
- private isDragging = false
39
- private dragMode: 'none' | 'pan' | 'resize-separator' | 'scale-price' = 'none'
40
- private dragStartX = 0
41
- private scrollStartX = 0
42
-
43
- private applyPanScroll(container: HTMLDivElement, nextScrollLeft: number) {
44
- const maxScrollLeft = Math.max(0, container.scrollWidth - container.clientWidth)
45
- const clampedScrollLeft = Math.min(Math.max(0, nextScrollLeft), maxScrollLeft)
46
- const dpr = this.chart.getCurrentDpr()
47
- const rounded = Math.round(clampedScrollLeft * dpr) / dpr
48
- container.scrollLeft = rounded
49
- }
50
-
51
- /** 垂直拖动相关 */
52
- private dragStartY = 0
53
- private activePaneIdOnDrag: string | null = null
54
-
55
- /** 分隔线拖拽相关 */
56
- private activeSeparatorUpperPaneId: string | null = null
57
- private hoveredSeparatorUpperPaneId: string | null = null
58
-
59
- /** 右轴悬浮相关 */
60
- private hoveredRightAxisPaneId: string | null = null
61
-
62
- /** [触屏]:触摸会话标记,避免触摸触发的模拟 mouse 事件干扰 */
63
- private isTouchSession = false
64
-
65
- private pinchTracker = new PinchTracker()
66
-
67
- /** 十字线位置 */
68
- crosshairPos: { x: number; y: number } | null = null
69
- /** 十字线当前指向的 K 线索引 */
70
- crosshairIndex: number | null = null
71
- /** 十字线指向的价格(用于价格轴平移时跟随) */
72
- crosshairPrice: number | null = null
73
- /** 鼠标悬停的 K 线索引(命中 candle 时有效) */
74
- hoveredIndex: number | null = null
75
- /** 当前活跃的 pane ID */
76
- activePaneId: string | null = null
77
- /** tooltip 位置 */
78
- tooltipPos: { x: number; y: number } = { x: 0, y: 0 }
79
- /** tooltip 尺寸 */
80
- tooltipSize: { width: number; height: number } = { width: 220, height: 180 }
81
- /** tooltip 锚定位放置方向 */
82
- tooltipAnchorPlacement: 'right-bottom' | 'left-bottom' = 'right-bottom'
83
- /** 是否使用 CSS 锚定位 */
84
- private useTooltipAnchorPositioning = false
85
- /** 统一交互状态变更回调 */
86
- private onInteractionChangeCallback?: (snapshot: InteractionSnapshot) => void
87
- /** 用户设置 */
88
- private settings: ChartSettings = {}
89
-
90
- private markerState = new MarkerInteractionState()
91
-
92
- /** 当前帧的 K 线起始 x 坐标数组 */
93
- private kLinePositions: number[] | null = null
94
- /** 当前帧的可见 K 线索引范围 */
95
- private visibleRange: { start: number; end: number } | null = null
96
-
97
- /** hover 去重快照 */
98
- private lastHoverRenderKey = ''
99
-
100
- /** K 线宽度(物理像素),用于计算 K 线中心偏移 */
101
- private kWidthPx: number | null = null
102
-
103
- constructor(chart: Chart) {
104
- this.chart = chart
105
- }
106
-
107
- setOnPinchZoom(callback: (delta: number, centerX: number) => void) {
108
- this.pinchTracker.setOnPinchZoom(callback)
109
- }
110
-
111
- /** 更新用户设置 */
112
- updateSettings(settings: ChartSettings): void {
113
- const prev = this.settings.disableMainPaneVerticalScroll
114
- this.settings = { ...settings }
115
- // 开启自适应时,重置主图垂直偏移
116
- if (!prev && this.settings.disableMainPaneVerticalScroll) {
117
- this.chart.resetPriceOffset('main')
118
- }
119
- }
120
-
121
- getInteractionSnapshot(): InteractionSnapshot {
122
- return {
123
- crosshairPos: this.crosshairPos ? { ...this.crosshairPos } : null,
124
- crosshairIndex: this.crosshairIndex,
125
- crosshairPrice: this.crosshairPrice,
126
- hoveredIndex: this.hoveredIndex,
127
- activePaneId: this.activePaneId,
128
- tooltipPos: { ...this.tooltipPos },
129
- tooltipAnchorPlacement: this.tooltipAnchorPlacement,
130
- hoveredMarkerData: this.markerState.hoveredMarkerData,
131
- hoveredCustomMarker: this.markerState.hoveredCustomMarker,
132
- isDragging: this.isDragging,
133
- isResizingPaneBoundary: this.dragMode === 'resize-separator',
134
- isHoveringPaneBoundary: this.hoveredSeparatorUpperPaneId !== null,
135
- hoveredPaneBoundaryId: this.hoveredSeparatorUpperPaneId,
136
- isHoveringRightAxis: this.hoveredRightAxisPaneId !== null,
137
- }
138
- }
139
-
140
- setOnInteractionChange(callback: (snapshot: InteractionSnapshot) => void) {
141
- this.onInteractionChangeCallback = callback
142
- }
143
-
144
- private notifyInteractionChange() {
145
- this.onInteractionChangeCallback?.(this.getInteractionSnapshot())
146
- }
147
-
148
- private getHoverRenderKey(): string {
149
- const crosshairX = this.crosshairPos ? Math.round(this.crosshairPos.x * this.chart.getCurrentDpr()) : 'n'
150
- const crosshairY = this.crosshairPos ? Math.round(this.crosshairPos.y * this.chart.getCurrentDpr()) : 'n'
151
- return [
152
- this.crosshairIndex ?? 'n',
153
- this.hoveredIndex ?? 'n',
154
- this.activePaneId ?? 'n',
155
- this.hoveredRightAxisPaneId ?? 'n',
156
- this.hoveredSeparatorUpperPaneId ?? 'n',
157
- this.markerState.hoveredMarkerId ?? 'n',
158
- this.markerState.hoveredCustomMarker?.id ?? 'n',
159
- crosshairX,
160
- crosshairY,
161
- ].join('|')
162
- }
163
-
164
- /**
165
- * [触屏]:处理 Pointer 按下事件
166
- * @param e PointerEvent
167
- */
168
- onPointerDown(e: PointerEvent) {
169
- this.isTouchSession = e.pointerType === 'touch'
170
- if (this.pinchTracker.handlePointerDown(e, this.isTouchSession)) {
171
- this.isDragging = false
172
- this.dragMode = 'none'
173
- return
174
- }
175
-
176
- // 单指操作(需要是主指针且不在捏合中,且不是捏合后的残余手指)
177
- if (e.isPrimary === false || this.pinchTracker.getIsPinching()) return
178
- if (this.pinchTracker.getPointerCount() > 1) return
179
-
180
- const location = this.getPlotPointerLocation(e.clientX, e.clientY)
181
- if (!location) return
182
-
183
- const { mouseX, mouseY } = location
184
- const scrollLeft = this.chart.getCachedScrollLeft()
185
-
186
- const markerManager = this.chart.getMarkerManager()
187
- const worldX = scrollLeft + mouseX
188
- const hitMarker = markerManager.hitTest(worldX, mouseY, 3)
189
-
190
- if (hitMarker) {
191
- this.markerState.handleClick(hitMarker)
192
- return
193
- }
194
-
195
- const separatorUpperPaneId = this.hitTestPaneSeparator(mouseY)
196
- if (separatorUpperPaneId) {
197
- this.isDragging = true
198
- this.dragMode = 'resize-separator'
199
- this.dragStartY = e.clientY
200
- this.activeSeparatorUpperPaneId = separatorUpperPaneId
201
- this.hoveredSeparatorUpperPaneId = separatorUpperPaneId
202
- this.clearHover()
203
- this.chart.scheduleDraw()
204
- return
205
- }
206
-
207
- const pane = this.getPaneByY(mouseY)
208
- this.isDragging = true
209
- this.dragMode = 'pan'
210
- this.updatePlotHoverFromPoint(e.clientX, e.clientY)
211
- this.dragStartX = e.clientX
212
- this.dragStartY = e.clientY
213
- this.scrollStartX = this.chart.getCachedScrollLeft()
214
- this.activePaneIdOnDrag = pane?.id || null
215
-
216
- this.chart.scheduleDraw()
217
- }
218
-
219
-
220
-
221
- /**
222
- * 设置 tooltip 尺寸
223
- * @param size 宽高对象
224
- */
225
- setTooltipSize(size: { width: number; height: number }) {
226
- this.tooltipSize = size
227
- }
228
-
229
- setTooltipAnchorPositioning(enabled: boolean) {
230
- this.useTooltipAnchorPositioning = enabled
231
- }
232
-
233
- /**
234
- * 处理 Pointer 抬起事件
235
- * @param e PointerEvent
236
- */
237
- onPointerUp(e: PointerEvent) {
238
- this.pinchTracker.handlePointerUp(e)
239
-
240
- if (e.isPrimary === false) return
241
- this.isDragging = false
242
- this.dragMode = 'none'
243
- this.activePaneIdOnDrag = null
244
- this.activeSeparatorUpperPaneId = null
245
- this.notifyInteractionChange()
246
- }
247
-
248
- /**
249
- * 处理 Pointer 离开事件
250
- * @param e PointerEvent
251
- */
252
- onPointerLeave(e: PointerEvent) {
253
- this.pinchTracker.handlePointerLeave(e)
254
-
255
- if (e.isPrimary === false) return
256
-
257
- this.isDragging = false
258
- this.dragMode = 'none'
259
- this.activePaneIdOnDrag = null
260
- this.clearSeparatorState()
261
- this.isTouchSession = false
262
- this.clearHover()
263
- this.chart.scheduleDraw()
264
- this.notifyInteractionChange()
265
- }
266
-
267
- /** 处理滚动事件 */
268
- onScroll() {
269
- this.kLinePositions = null
270
- this.visibleRange = null
271
- this.clearHover()
272
- this.chart.scheduleDraw()
273
- this.notifyInteractionChange()
274
- }
275
-
276
- /**
277
- * 处理 Pointer 移动事件(支持鼠标和触屏)
278
- * @param e PointerEvent
279
- */
280
- onPointerMove(e: PointerEvent) {
281
- if (this.pinchTracker.handlePointerMove(e)) return
282
-
283
- if (!e.isPrimary) return
284
-
285
- if (e.pointerType === 'touch') {
286
- this.isTouchSession = true
287
- }
288
-
289
- const container = this.chart.getDom().container
290
-
291
- if (this.isDragging) {
292
- if (this.dragMode === 'resize-separator') {
293
- const deltaY = e.clientY - this.dragStartY
294
- if (deltaY !== 0 && this.activeSeparatorUpperPaneId) {
295
- const resized = this.chart.resizePaneBoundary(this.activeSeparatorUpperPaneId, deltaY)
296
- if (resized) {
297
- this.dragStartY = e.clientY
298
- }
299
- }
300
- return
301
- }
302
-
303
- if (this.dragMode === 'scale-price') {
304
- const deltaY = e.clientY - this.dragStartY
305
- if (deltaY !== 0 && this.activePaneIdOnDrag) {
306
- this.chart.scalePrice(this.activePaneIdOnDrag, deltaY)
307
- this.dragStartY = e.clientY
308
- }
309
- return
310
- }
311
-
312
- if (this.dragMode === 'pan') {
313
- const deltaX = this.dragStartX - e.clientX
314
- this.applyPanScroll(container, this.scrollStartX + deltaX)
315
-
316
- const deltaY = e.clientY - this.dragStartY
317
- this.dragStartY = e.clientY
318
- if (deltaY !== 0 && this.activePaneIdOnDrag === 'main') {
319
- if (!this.settings.disableMainPaneVerticalScroll) {
320
- this.chart.translatePrice(this.activePaneIdOnDrag, deltaY)
321
- }
322
- }
323
- }
324
- return
325
- }
326
-
327
- const location = this.getPlotPointerLocation(e.clientX, e.clientY)
328
- if (!location) return
329
- this.hoveredSeparatorUpperPaneId = this.hitTestPaneSeparator(location.mouseY)
330
-
331
- this.updatePlotHoverFromPoint(e.clientX, e.clientY)
332
- const hoverRenderKey = this.getHoverRenderKey()
333
- if (hoverRenderKey !== this.lastHoverRenderKey) {
334
- this.lastHoverRenderKey = hoverRenderKey
335
- this.chart.scheduleDraw(UpdateLevel.Overlay)
336
- }
337
- this.notifyInteractionChange()
338
- }
339
-
340
-
341
- /**
342
- * 设置当前帧的 K 线起始 x 坐标数组和可见范围
343
- * @param positions K 线起始 x 坐标数组
344
- * @param visibleRange 可见 K 线索引范围
345
- * @param kWidthPx K 线宽度(物理像素)
346
- */
347
- setKLinePositions(
348
- positions: number[] | null,
349
- visibleRange: { start: number; end: number } | null,
350
- kWidthPx?: number
351
- ) {
352
- this.kLinePositions = positions
353
- this.visibleRange = visibleRange
354
- if (kWidthPx !== undefined) {
355
- this.kWidthPx = kWidthPx
356
- }
357
- }
358
-
359
- onRightAxisPointerDown(e: PointerEvent) {
360
- if (e.isPrimary === false) return
361
- this.isTouchSession = e.pointerType === 'touch'
362
- const location = this.getRightAxisPointerLocation(e.clientX, e.clientY)
363
- if (!location) return
364
- if (this.beginScalePriceDrag(e.clientY, location.mouseY)) {
365
- this.chart.scheduleDraw()
366
- this.notifyInteractionChange()
367
- }
368
- }
369
-
370
- onRightAxisPointerMove(e: PointerEvent) {
371
- if (!e.isPrimary) return
372
- if (e.pointerType === 'touch') {
373
- this.isTouchSession = true
374
- }
375
-
376
- if (this.isDragging && this.dragMode === 'scale-price') {
377
- const deltaY = e.clientY - this.dragStartY
378
- if (deltaY !== 0 && this.activePaneIdOnDrag) {
379
- this.chart.scalePrice(this.activePaneIdOnDrag, deltaY)
380
- this.dragStartY = e.clientY
381
- }
382
- return
383
- }
384
-
385
- this.updateRightAxisHoverFromPoint(e.clientX, e.clientY)
386
- const hoverRenderKey = this.getHoverRenderKey()
387
- if (hoverRenderKey !== this.lastHoverRenderKey) {
388
- this.lastHoverRenderKey = hoverRenderKey
389
- this.chart.scheduleDraw(UpdateLevel.Overlay)
390
- }
391
- this.notifyInteractionChange()
392
- }
393
-
394
- onRightAxisPointerUp(e: PointerEvent) {
395
- this.onPointerUp(e)
396
- }
397
-
398
- onRightAxisPointerLeave(e: PointerEvent) {
399
- if (e.isPrimary === false) return
400
- if (this.isDragging && this.dragMode === 'scale-price') return
401
- this.hoveredRightAxisPaneId = null
402
- this.notifyInteractionChange()
403
- }
404
-
405
- /** 检查是否正在拖拽 */
406
- isDraggingState(): boolean {
407
- return this.isDragging
408
- }
409
-
410
- setOnMarkerHover(callback: (marker: MarkerEntity | null) => void) {
411
- this.markerState.setOnMarkerHover(callback)
412
- }
413
-
414
- setOnMarkerClick(callback: (marker: MarkerEntity) => void) {
415
- this.markerState.setOnMarkerClick(callback)
416
- }
417
-
418
- setOnCustomMarkerHover(callback: (marker: CustomMarkerEntity | null) => void) {
419
- this.markerState.setOnCustomMarkerHover(callback)
420
- }
421
-
422
- setOnCustomMarkerClick(callback: (marker: CustomMarkerEntity) => void) {
423
- this.markerState.setOnCustomMarkerClick(callback)
424
- }
425
-
426
- /** 命中可拖拽分隔线(返回上方 paneId) */
427
- private hitTestPaneSeparator(mouseY: number): string | null {
428
- const paneRenderers = this.chart.getPaneRenderers()
429
- if (paneRenderers.length < 2) return null
430
-
431
- const SEP_HIT_HALF = 5
432
- for (let i = 0; i < paneRenderers.length - 1; i++) {
433
- const upper = paneRenderers[i]?.getPane()
434
- const lower = paneRenderers[i + 1]?.getPane()
435
- if (!upper || !lower) continue
436
- const boundaryY = upper.top + upper.height
437
- if (Math.abs(mouseY - boundaryY) <= SEP_HIT_HALF) {
438
- return upper.id
439
- }
440
- }
441
- return null
442
- }
443
-
444
- private getPaneByY(mouseY: number) {
445
- const paneRenderers = this.chart.getPaneRenderers()
446
- const renderer = paneRenderers.find((r) => {
447
- const pane = r.getPane()
448
- return mouseY >= pane.top && mouseY <= pane.top + pane.height
449
- })
450
- return renderer?.getPane() || null
451
- }
452
-
453
- private getPlotPointerLocation(clientX: number, clientY: number): PointerLocation | null {
454
- const container = this.chart.getDom().container
455
- const rect = container.getBoundingClientRect()
456
- const mouseX = clientX - rect.left
457
- const mouseY = clientY - rect.top
458
- return { mouseX, mouseY }
459
- }
460
-
461
- private getRightAxisPointerLocation(clientX: number, clientY: number): PointerLocation {
462
- const rightAxisLayer = this.chart.getDom().rightAxisLayer
463
- const rect = rightAxisLayer.getBoundingClientRect()
464
- const mouseX = clientX - rect.left
465
- const mouseY = clientY - rect.top
466
- return { mouseX, mouseY }
467
- }
468
-
469
- private beginScalePriceDrag(clientY: number, mouseY: number) {
470
- const pane = this.getPaneByY(mouseY)
471
- if (!pane) return false
472
- // 主图禁用垂直滚动时,禁止价格轴缩放
473
- if (pane.id === 'main' && this.settings.disableMainPaneVerticalScroll) {
474
- return false
475
- }
476
- this.isDragging = true
477
- this.dragMode = 'scale-price'
478
- this.dragStartY = clientY
479
- this.activePaneIdOnDrag = pane.id
480
- this.hoveredRightAxisPaneId = pane.id
481
- this.hoveredSeparatorUpperPaneId = null
482
- this.crosshairPos = null
483
- this.crosshairIndex = null
484
- this.crosshairPrice = null
485
- this.hoveredIndex = null
486
- this.activePaneId = pane.id
487
- return true
488
- }
489
-
490
- clearHover() {
491
- this.lastHoverRenderKey = ''
492
- this.hoveredRightAxisPaneId = null
493
- this.crosshairPos = null
494
- this.crosshairIndex = null
495
- this.crosshairPrice = null
496
- this.hoveredIndex = null
497
- this.activePaneId = null
498
-
499
- this.markerState.clearAll(this.chart.getMarkerManager())
500
- }
501
-
502
-
503
- private clearSeparatorState() {
504
- this.activeSeparatorUpperPaneId = null
505
- this.hoveredSeparatorUpperPaneId = null
506
- this.hoveredRightAxisPaneId = null
507
- }
508
-
509
- /**
510
- * 从屏幕坐标更新 hover 状态
511
- * @param clientX 屏幕 x 坐标
512
- * @param clientY 屏幕 y 坐标
513
- */
514
-
515
- private updateRightAxisHoverFromPoint(clientX: number, clientY: number) {
516
- const location = this.getRightAxisPointerLocation(clientX, clientY)
517
- if (!location) return
518
-
519
- const { mouseY } = location
520
- const viewport = this.chart.getViewport()
521
- const plotHeight = viewport?.plotHeight ?? Math.max(1, Math.round(this.chart.getDom().container.clientHeight))
522
- if (mouseY < 0 || mouseY > plotHeight) {
523
- this.hoveredRightAxisPaneId = null
524
- return
525
- }
526
-
527
- const pane = this.getPaneByY(mouseY)
528
- this.hoveredRightAxisPaneId = pane?.id || null
529
- this.hoveredSeparatorUpperPaneId = null
530
- this.crosshairPos = null
531
- this.crosshairIndex = null
532
- this.crosshairPrice = null
533
- this.hoveredIndex = null
534
- this.activePaneId = pane?.id || null
535
- }
536
-
537
- private updatePlotHoverFromPoint(clientX: number, clientY: number) {
538
- const location = this.getPlotPointerLocation(clientX, clientY)
539
- if (!location) return
540
-
541
- const { mouseX, mouseY } = location
542
- const container = this.chart.getDom().container
543
- const viewport = this.chart.getViewport()
544
- const viewWidth = viewport?.viewWidth ?? Math.max(1, Math.round(container.clientWidth))
545
- const viewHeight = viewport?.viewHeight ?? Math.max(1, Math.round(container.clientHeight))
546
- const plotWidth = viewport?.plotWidth ?? viewWidth
547
- const plotHeight = viewport?.plotHeight ?? viewHeight
548
- if (mouseX < 0 || mouseY < 0 || mouseX > plotWidth || mouseY > plotHeight) {
549
- this.clearHover()
550
- return
551
- }
552
-
553
- this.hoveredRightAxisPaneId = null
554
-
555
- const scrollLeft = this.chart.getCachedScrollLeft()
556
- const dpr = this.chart.getCurrentDpr()
557
-
558
- const separatorUpperPaneId = this.hitTestPaneSeparator(mouseY)
559
- this.hoveredSeparatorUpperPaneId = separatorUpperPaneId
560
- if (separatorUpperPaneId) {
561
- this.crosshairPos = null
562
- this.crosshairIndex = null
563
- this.crosshairPrice = null
564
- this.hoveredIndex = null
565
- this.activePaneId = null
566
- return
567
- }
568
-
569
- const markerManager = this.chart.getMarkerManager()
570
- const worldX = scrollLeft + mouseX
571
- if (this.markerState.updateHoverFromPoint(worldX, mouseX, mouseY, markerManager)) {
572
- this.crosshairPos = null
573
- this.crosshairIndex = null
574
- this.crosshairPrice = null
575
- this.hoveredIndex = null
576
- return
577
- }
578
-
579
- if (!this.kLinePositions || !this.visibleRange || !this.kWidthPx) {
580
- this.clearHover()
581
- return
582
- }
583
-
584
- const kWidthLogical = this.kWidthPx / dpr
585
-
586
- let lo = 0, hi = this.kLinePositions.length
587
- while (lo < hi) {
588
- const mid = (lo + hi) >> 1
589
- if (this.kLinePositions[mid]! < worldX) {
590
- lo = mid + 1
591
- } else {
592
- hi = mid
593
- }
594
- }
595
-
596
- let localIdx = lo
597
- if (lo > 0 && lo < this.kLinePositions.length) {
598
- const prevCenter = this.kLinePositions[lo - 1]! + kWidthLogical / 2
599
- const currCenter = this.kLinePositions[lo]! + kWidthLogical / 2
600
- if (Math.abs(worldX - prevCenter) < Math.abs(worldX - currCenter)) {
601
- localIdx = lo - 1
602
- }
603
- } else if (lo === this.kLinePositions.length && this.kLinePositions.length > 0) {
604
- localIdx = this.kLinePositions.length - 1
605
- }
606
-
607
- const idx = localIdx + this.visibleRange.start
608
- const data = this.chart.getData()
609
-
610
- const pane = this.getPaneByY(mouseY)
611
- this.activePaneId = pane?.id || null
612
-
613
- if (idx >= 0 && idx < (data?.length ?? 0)) {
614
- this.crosshairIndex = idx
615
-
616
- const kLineStartX = this.kLinePositions[localIdx]!
617
- // 与影线位置算法一致: leftPx + (widthPx - 1) / 2
618
- const leftPx = Math.round(kLineStartX * dpr)
619
- const wickXPx = leftPx + (this.kWidthPx - 1) / 2
620
- const snappedX = wickXPx / dpr - scrollLeft
621
-
622
- this.crosshairPos = {
623
- x: Math.min(Math.max(snappedX, 0), plotWidth),
624
- y: Math.min(Math.max(mouseY, 0), plotHeight),
625
- }
626
-
627
- if (pane) {
628
- const localY = mouseY - pane.top
629
- this.crosshairPrice = pane.yAxis.yToPrice(localY)
630
- } else {
631
- this.crosshairPrice = null
632
- }
633
- } else {
634
- this.crosshairIndex = null
635
- this.crosshairPos = null
636
- this.crosshairPrice = null
637
- }
638
-
639
- const k = typeof this.crosshairIndex === 'number' ? data[this.crosshairIndex] : undefined
640
- if (!k || !pane || !pane.capabilities.candleHitTest) {
641
- this.hoveredIndex = null
642
- return
643
- }
644
-
645
- const localY = mouseY - pane.top
646
- const openY = pane.yAxis.priceToY(k.open)
647
- const closeY = pane.yAxis.priceToY(k.close)
648
- const highY = pane.yAxis.priceToY(k.high)
649
- const lowY = pane.yAxis.priceToY(k.low)
650
- const bodyTop = Math.min(openY, closeY)
651
- const bodyBottom = Math.max(openY, closeY)
652
-
653
- const kLineStartX = this.kLinePositions[localIdx]!
654
- const inUnitX = worldX - kLineStartX
655
- const cxLogical = kWidthLogical / 2
656
-
657
- const MIN_BODY_HIT_HEIGHT = 8
658
- const bodyHeight = Math.abs(bodyBottom - bodyTop)
659
- const effectiveBodyTop = bodyHeight < MIN_BODY_HIT_HEIGHT ? (bodyTop + bodyBottom) / 2 - MIN_BODY_HIT_HEIGHT / 2 : bodyTop
660
- const effectiveBodyBottom = bodyHeight < MIN_BODY_HIT_HEIGHT ? (bodyTop + bodyBottom) / 2 + MIN_BODY_HIT_HEIGHT / 2 : bodyBottom
661
-
662
- const HIT_WICK_HALF_EXTENDED = 3
663
-
664
- const hitBody = localY >= effectiveBodyTop && localY <= effectiveBodyBottom &&
665
- inUnitX >= 0 && inUnitX <= kWidthLogical
666
- const hitWick = Math.abs(inUnitX - cxLogical) <= HIT_WICK_HALF_EXTENDED &&
667
- localY >= Math.min(highY, lowY) && localY <= Math.max(highY, lowY)
668
-
669
- if (!hitBody && !hitWick) {
670
- this.hoveredIndex = null
671
- return
672
- }
673
-
674
- this.hoveredIndex = this.crosshairIndex
675
-
676
- const tooltipResult = computeTooltipPosition({
677
- mouseX,
678
- mouseY,
679
- viewWidth,
680
- viewHeight,
681
- plotWidth,
682
- plotHeight,
683
- tooltipSize: this.tooltipSize,
684
- useAnchorPositioning: this.useTooltipAnchorPositioning,
685
- })
686
- if (tooltipResult.anchorPlacement) {
687
- this.tooltipAnchorPlacement = tooltipResult.anchorPlacement
688
- }
689
- this.tooltipPos = tooltipResult.pos
690
- }
691
-
692
-
693
- /**
694
- * 重置所有交互状态(数据更新时调用)
695
- */
696
- reset(): void {
697
- this.isDragging = false
698
- this.dragMode = 'none'
699
- this.dragStartX = 0
700
- this.dragStartY = 0
701
- this.scrollStartX = 0
702
- this.activePaneIdOnDrag = null
703
- this.clearSeparatorState()
704
- this.isTouchSession = false
705
- this.pinchTracker.reset()
706
- this.crosshairPos = null
707
- this.crosshairIndex = null
708
- this.crosshairPrice = null
709
- this.hoveredIndex = null
710
- this.activePaneId = null
711
- this.markerState.reset()
712
- this.kLinePositions = null
713
- this.visibleRange = null
714
- this.lastHoverRenderKey = ''
715
- this.kWidthPx = null
716
- }
717
-
718
- /** 获取十字线指向的 K 线索引 */
719
- getCrosshairIndex(): number | null {
720
- return this.crosshairIndex
721
- }
722
- }
1
+ // 交互控制中心
2
+
3
+ import type { Chart } from '../chart'
4
+ import type { MarkerEntity, CustomMarkerEntity } from '../marker/registry'
5
+ import { MarkerInteractionState } from './markerInteraction'
6
+ import { PinchTracker } from './pinchTracker'
7
+ import { computeTooltipPosition } from './tooltipPosition'
8
+ import { UpdateLevel } from '../layout/pane'
9
+ import type { ChartSettings } from '../../config/chartSettings'
10
+
11
+ interface PointerLocation {
12
+ mouseX: number
13
+ mouseY: number
14
+ }
15
+
16
+ export interface InteractionSnapshot {
17
+ crosshairPos: { x: number; y: number } | null
18
+ crosshairIndex: number | null
19
+ crosshairPrice: number | null
20
+ hoveredIndex: number | null
21
+ activePaneId: string | null
22
+ tooltipPos: { x: number; y: number }
23
+ tooltipAnchorPlacement: 'right-bottom' | 'left-bottom'
24
+ hoveredMarkerData: MarkerEntity | null
25
+ hoveredCustomMarker: CustomMarkerEntity | null
26
+ isDragging: boolean
27
+ isResizingPaneBoundary: boolean
28
+ isHoveringPaneBoundary: boolean
29
+ hoveredPaneBoundaryId: string | null
30
+ isHoveringRightAxis: boolean
31
+ }
32
+
33
+ /**
34
+ * 交互控制器,处理拖拽滚动、缩放、十字线 hover 等交互逻辑
35
+ */
36
+ export class InteractionController {
37
+ private chart: Chart
38
+ private isDragging = false
39
+ private dragMode: 'none' | 'pan' | 'resize-separator' | 'scale-price' = 'none'
40
+ private dragStartX = 0
41
+ private scrollStartX = 0
42
+
43
+ private applyPanScroll(container: HTMLDivElement, nextScrollLeft: number) {
44
+ const maxScrollLeft = Math.max(0, container.scrollWidth - container.clientWidth)
45
+ const clampedScrollLeft = Math.min(Math.max(0, nextScrollLeft), maxScrollLeft)
46
+ const dpr = this.chart.getCurrentDpr()
47
+ const rounded = Math.round(clampedScrollLeft * dpr) / dpr
48
+ container.scrollLeft = rounded
49
+ }
50
+
51
+ /** 垂直拖动相关 */
52
+ private dragStartY = 0
53
+ private activePaneIdOnDrag: string | null = null
54
+
55
+ /** 分隔线拖拽相关 */
56
+ private activeSeparatorUpperPaneId: string | null = null
57
+ private hoveredSeparatorUpperPaneId: string | null = null
58
+
59
+ /** 右轴悬浮相关 */
60
+ private hoveredRightAxisPaneId: string | null = null
61
+
62
+ /** [触屏]:触摸会话标记,避免触摸触发的模拟 mouse 事件干扰 */
63
+ private isTouchSession = false
64
+
65
+ private pinchTracker = new PinchTracker()
66
+
67
+ /** 十字线位置 */
68
+ crosshairPos: { x: number; y: number } | null = null
69
+ /** 十字线当前指向的 K 线索引 */
70
+ crosshairIndex: number | null = null
71
+ /** 十字线指向的价格(用于价格轴平移时跟随) */
72
+ crosshairPrice: number | null = null
73
+ /** 鼠标悬停的 K 线索引(命中 candle 时有效) */
74
+ hoveredIndex: number | null = null
75
+ /** 当前活跃的 pane ID */
76
+ activePaneId: string | null = null
77
+ /** tooltip 位置 */
78
+ tooltipPos: { x: number; y: number } = { x: 0, y: 0 }
79
+ /** tooltip 尺寸 */
80
+ tooltipSize: { width: number; height: number } = { width: 220, height: 180 }
81
+ /** tooltip 锚定位放置方向 */
82
+ tooltipAnchorPlacement: 'right-bottom' | 'left-bottom' = 'right-bottom'
83
+ /** 是否使用 CSS 锚定位 */
84
+ private useTooltipAnchorPositioning = false
85
+ /** 统一交互状态变更回调 */
86
+ private onInteractionChangeCallback?: (snapshot: InteractionSnapshot) => void
87
+ /** 用户设置 */
88
+ private settings: ChartSettings = {}
89
+
90
+ private markerState = new MarkerInteractionState()
91
+
92
+ /** 当前帧的 K 线起始 x 坐标数组 */
93
+ private kLinePositions: number[] | null = null
94
+ /** 当前帧的可见 K 线索引范围 */
95
+ private visibleRange: { start: number; end: number } | null = null
96
+
97
+ /** hover 去重快照 */
98
+ private lastHoverRenderKey = ''
99
+
100
+ /** K 线宽度(物理像素),用于计算 K 线中心偏移 */
101
+ private kWidthPx: number | null = null
102
+
103
+ constructor(chart: Chart) {
104
+ this.chart = chart
105
+ }
106
+
107
+ setOnPinchZoom(callback: (delta: number, centerX: number) => void) {
108
+ this.pinchTracker.setOnPinchZoom(callback)
109
+ }
110
+
111
+ /** 更新用户设置 */
112
+ updateSettings(settings: ChartSettings): void {
113
+ const prev = this.settings.disableMainPaneVerticalScroll
114
+ this.settings = { ...settings }
115
+ // 开启自适应时,重置主图垂直偏移
116
+ if (!prev && this.settings.disableMainPaneVerticalScroll) {
117
+ this.chart.resetPriceOffset('main')
118
+ }
119
+ }
120
+
121
+ getInteractionSnapshot(): InteractionSnapshot {
122
+ return {
123
+ crosshairPos: this.crosshairPos ? { ...this.crosshairPos } : null,
124
+ crosshairIndex: this.crosshairIndex,
125
+ crosshairPrice: this.crosshairPrice,
126
+ hoveredIndex: this.hoveredIndex,
127
+ activePaneId: this.activePaneId,
128
+ tooltipPos: { ...this.tooltipPos },
129
+ tooltipAnchorPlacement: this.tooltipAnchorPlacement,
130
+ hoveredMarkerData: this.markerState.hoveredMarkerData,
131
+ hoveredCustomMarker: this.markerState.hoveredCustomMarker,
132
+ isDragging: this.isDragging,
133
+ isResizingPaneBoundary: this.dragMode === 'resize-separator',
134
+ isHoveringPaneBoundary: this.hoveredSeparatorUpperPaneId !== null,
135
+ hoveredPaneBoundaryId: this.hoveredSeparatorUpperPaneId,
136
+ isHoveringRightAxis: this.hoveredRightAxisPaneId !== null,
137
+ }
138
+ }
139
+
140
+ setOnInteractionChange(callback: (snapshot: InteractionSnapshot) => void) {
141
+ this.onInteractionChangeCallback = callback
142
+ }
143
+
144
+ private notifyInteractionChange() {
145
+ this.onInteractionChangeCallback?.(this.getInteractionSnapshot())
146
+ }
147
+
148
+ private getHoverRenderKey(): string {
149
+ const crosshairX = this.crosshairPos ? Math.round(this.crosshairPos.x * this.chart.getCurrentDpr()) : 'n'
150
+ const crosshairY = this.crosshairPos ? Math.round(this.crosshairPos.y * this.chart.getCurrentDpr()) : 'n'
151
+ return [
152
+ this.crosshairIndex ?? 'n',
153
+ this.hoveredIndex ?? 'n',
154
+ this.activePaneId ?? 'n',
155
+ this.hoveredRightAxisPaneId ?? 'n',
156
+ this.hoveredSeparatorUpperPaneId ?? 'n',
157
+ this.markerState.hoveredMarkerId ?? 'n',
158
+ this.markerState.hoveredCustomMarker?.id ?? 'n',
159
+ crosshairX,
160
+ crosshairY,
161
+ ].join('|')
162
+ }
163
+
164
+ /**
165
+ * [触屏]:处理 Pointer 按下事件
166
+ * @param e PointerEvent
167
+ */
168
+ onPointerDown(e: PointerEvent) {
169
+ this.isTouchSession = e.pointerType === 'touch'
170
+ if (this.pinchTracker.handlePointerDown(e, this.isTouchSession)) {
171
+ this.isDragging = false
172
+ this.dragMode = 'none'
173
+ return
174
+ }
175
+
176
+ // 单指操作(需要是主指针且不在捏合中,且不是捏合后的残余手指)
177
+ if (e.isPrimary === false || this.pinchTracker.getIsPinching()) return
178
+ if (this.pinchTracker.getPointerCount() > 1) return
179
+
180
+ const location = this.getPlotPointerLocation(e.clientX, e.clientY)
181
+ if (!location) return
182
+
183
+ const { mouseX, mouseY } = location
184
+ const scrollLeft = this.chart.getCachedScrollLeft()
185
+
186
+ const markerManager = this.chart.getMarkerManager()
187
+ const worldX = scrollLeft + mouseX
188
+ const hitMarker = markerManager.hitTest(worldX, mouseY, 3)
189
+
190
+ if (hitMarker) {
191
+ this.markerState.handleClick(hitMarker)
192
+ return
193
+ }
194
+
195
+ const separatorUpperPaneId = this.hitTestPaneSeparator(mouseY)
196
+ if (separatorUpperPaneId) {
197
+ this.isDragging = true
198
+ this.dragMode = 'resize-separator'
199
+ this.dragStartY = e.clientY
200
+ this.activeSeparatorUpperPaneId = separatorUpperPaneId
201
+ this.hoveredSeparatorUpperPaneId = separatorUpperPaneId
202
+ this.clearHover()
203
+ this.chart.scheduleDraw()
204
+ return
205
+ }
206
+
207
+ const pane = this.getPaneByY(mouseY)
208
+ this.isDragging = true
209
+ this.dragMode = 'pan'
210
+ this.updatePlotHoverFromPoint(e.clientX, e.clientY)
211
+ this.dragStartX = e.clientX
212
+ this.dragStartY = e.clientY
213
+ this.scrollStartX = this.chart.getCachedScrollLeft()
214
+ this.activePaneIdOnDrag = pane?.id || null
215
+
216
+ this.chart.scheduleDraw()
217
+ }
218
+
219
+
220
+
221
+ /**
222
+ * 设置 tooltip 尺寸
223
+ * @param size 宽高对象
224
+ */
225
+ setTooltipSize(size: { width: number; height: number }) {
226
+ this.tooltipSize = size
227
+ }
228
+
229
+ setTooltipAnchorPositioning(enabled: boolean) {
230
+ this.useTooltipAnchorPositioning = enabled
231
+ }
232
+
233
+ /**
234
+ * 处理 Pointer 抬起事件
235
+ * @param e PointerEvent
236
+ */
237
+ onPointerUp(e: PointerEvent) {
238
+ this.pinchTracker.handlePointerUp(e)
239
+
240
+ if (e.isPrimary === false) return
241
+ this.isDragging = false
242
+ this.dragMode = 'none'
243
+ this.activePaneIdOnDrag = null
244
+ this.activeSeparatorUpperPaneId = null
245
+ this.notifyInteractionChange()
246
+ }
247
+
248
+ /**
249
+ * 处理 Pointer 离开事件
250
+ * @param e PointerEvent
251
+ */
252
+ onPointerLeave(e: PointerEvent) {
253
+ this.pinchTracker.handlePointerLeave(e)
254
+
255
+ if (e.isPrimary === false) return
256
+
257
+ this.isDragging = false
258
+ this.dragMode = 'none'
259
+ this.activePaneIdOnDrag = null
260
+ this.clearSeparatorState()
261
+ this.isTouchSession = false
262
+ this.clearHover()
263
+ this.chart.scheduleDraw()
264
+ this.notifyInteractionChange()
265
+ }
266
+
267
+ /** 处理滚动事件 */
268
+ onScroll() {
269
+ this.kLinePositions = null
270
+ this.visibleRange = null
271
+ this.clearHover()
272
+ this.chart.scheduleDraw()
273
+ this.notifyInteractionChange()
274
+ }
275
+
276
+ /**
277
+ * 处理 Pointer 移动事件(支持鼠标和触屏)
278
+ * @param e PointerEvent
279
+ */
280
+ onPointerMove(e: PointerEvent) {
281
+ if (this.pinchTracker.handlePointerMove(e)) return
282
+
283
+ if (!e.isPrimary) return
284
+
285
+ if (e.pointerType === 'touch') {
286
+ this.isTouchSession = true
287
+ }
288
+
289
+ const container = this.chart.getDom().container
290
+
291
+ if (this.isDragging) {
292
+ if (this.dragMode === 'resize-separator') {
293
+ const deltaY = e.clientY - this.dragStartY
294
+ if (deltaY !== 0 && this.activeSeparatorUpperPaneId) {
295
+ const resized = this.chart.resizePaneBoundary(this.activeSeparatorUpperPaneId, deltaY)
296
+ if (resized) {
297
+ this.dragStartY = e.clientY
298
+ }
299
+ }
300
+ return
301
+ }
302
+
303
+ if (this.dragMode === 'scale-price') {
304
+ const deltaY = e.clientY - this.dragStartY
305
+ if (deltaY !== 0 && this.activePaneIdOnDrag) {
306
+ this.chart.scalePrice(this.activePaneIdOnDrag, deltaY)
307
+ this.dragStartY = e.clientY
308
+ }
309
+ return
310
+ }
311
+
312
+ if (this.dragMode === 'pan') {
313
+ const deltaX = this.dragStartX - e.clientX
314
+ this.applyPanScroll(container, this.scrollStartX + deltaX)
315
+
316
+ const deltaY = e.clientY - this.dragStartY
317
+ this.dragStartY = e.clientY
318
+ if (deltaY !== 0 && this.activePaneIdOnDrag === 'main') {
319
+ if (!this.settings.disableMainPaneVerticalScroll) {
320
+ this.chart.translatePrice(this.activePaneIdOnDrag, deltaY)
321
+ }
322
+ }
323
+ }
324
+ return
325
+ }
326
+
327
+ const location = this.getPlotPointerLocation(e.clientX, e.clientY)
328
+ if (!location) return
329
+ this.hoveredSeparatorUpperPaneId = this.hitTestPaneSeparator(location.mouseY)
330
+
331
+ this.updatePlotHoverFromPoint(e.clientX, e.clientY)
332
+ const hoverRenderKey = this.getHoverRenderKey()
333
+ if (hoverRenderKey !== this.lastHoverRenderKey) {
334
+ this.lastHoverRenderKey = hoverRenderKey
335
+ this.chart.scheduleDraw(UpdateLevel.Overlay)
336
+ }
337
+ this.notifyInteractionChange()
338
+ }
339
+
340
+
341
+ /**
342
+ * 设置当前帧的 K 线起始 x 坐标数组和可见范围
343
+ * @param positions K 线起始 x 坐标数组
344
+ * @param visibleRange 可见 K 线索引范围
345
+ * @param kWidthPx K 线宽度(物理像素)
346
+ */
347
+ setKLinePositions(
348
+ positions: number[] | null,
349
+ visibleRange: { start: number; end: number } | null,
350
+ kWidthPx?: number
351
+ ) {
352
+ this.kLinePositions = positions
353
+ this.visibleRange = visibleRange
354
+ if (kWidthPx !== undefined) {
355
+ this.kWidthPx = kWidthPx
356
+ }
357
+ }
358
+
359
+ onRightAxisPointerDown(e: PointerEvent) {
360
+ if (e.isPrimary === false) return
361
+ this.isTouchSession = e.pointerType === 'touch'
362
+ const location = this.getRightAxisPointerLocation(e.clientX, e.clientY)
363
+ if (!location) return
364
+ if (this.beginScalePriceDrag(e.clientY, location.mouseY)) {
365
+ this.chart.scheduleDraw()
366
+ this.notifyInteractionChange()
367
+ }
368
+ }
369
+
370
+ onRightAxisPointerMove(e: PointerEvent) {
371
+ if (!e.isPrimary) return
372
+ if (e.pointerType === 'touch') {
373
+ this.isTouchSession = true
374
+ }
375
+
376
+ if (this.isDragging && this.dragMode === 'scale-price') {
377
+ const deltaY = e.clientY - this.dragStartY
378
+ if (deltaY !== 0 && this.activePaneIdOnDrag) {
379
+ this.chart.scalePrice(this.activePaneIdOnDrag, deltaY)
380
+ this.dragStartY = e.clientY
381
+ }
382
+ return
383
+ }
384
+
385
+ this.updateRightAxisHoverFromPoint(e.clientX, e.clientY)
386
+ const hoverRenderKey = this.getHoverRenderKey()
387
+ if (hoverRenderKey !== this.lastHoverRenderKey) {
388
+ this.lastHoverRenderKey = hoverRenderKey
389
+ this.chart.scheduleDraw(UpdateLevel.Overlay)
390
+ }
391
+ this.notifyInteractionChange()
392
+ }
393
+
394
+ onRightAxisPointerUp(e: PointerEvent) {
395
+ this.onPointerUp(e)
396
+ }
397
+
398
+ onRightAxisPointerLeave(e: PointerEvent) {
399
+ if (e.isPrimary === false) return
400
+ if (this.isDragging && this.dragMode === 'scale-price') return
401
+ this.hoveredRightAxisPaneId = null
402
+ this.notifyInteractionChange()
403
+ }
404
+
405
+ /** 检查是否正在拖拽 */
406
+ isDraggingState(): boolean {
407
+ return this.isDragging
408
+ }
409
+
410
+ setOnMarkerHover(callback: (marker: MarkerEntity | null) => void) {
411
+ this.markerState.setOnMarkerHover(callback)
412
+ }
413
+
414
+ setOnMarkerClick(callback: (marker: MarkerEntity) => void) {
415
+ this.markerState.setOnMarkerClick(callback)
416
+ }
417
+
418
+ setOnCustomMarkerHover(callback: (marker: CustomMarkerEntity | null) => void) {
419
+ this.markerState.setOnCustomMarkerHover(callback)
420
+ }
421
+
422
+ setOnCustomMarkerClick(callback: (marker: CustomMarkerEntity) => void) {
423
+ this.markerState.setOnCustomMarkerClick(callback)
424
+ }
425
+
426
+ /** 命中可拖拽分隔线(返回上方 paneId) */
427
+ private hitTestPaneSeparator(mouseY: number): string | null {
428
+ const paneRenderers = this.chart.getPaneRenderers()
429
+ if (paneRenderers.length < 2) return null
430
+
431
+ const SEP_HIT_HALF = 5
432
+ for (let i = 0; i < paneRenderers.length - 1; i++) {
433
+ const upper = paneRenderers[i]?.getPane()
434
+ const lower = paneRenderers[i + 1]?.getPane()
435
+ if (!upper || !lower) continue
436
+ const boundaryY = upper.top + upper.height
437
+ if (Math.abs(mouseY - boundaryY) <= SEP_HIT_HALF) {
438
+ return upper.id
439
+ }
440
+ }
441
+ return null
442
+ }
443
+
444
+ private getPaneByY(mouseY: number) {
445
+ const paneRenderers = this.chart.getPaneRenderers()
446
+ const renderer = paneRenderers.find((r) => {
447
+ const pane = r.getPane()
448
+ return mouseY >= pane.top && mouseY <= pane.top + pane.height
449
+ })
450
+ return renderer?.getPane() || null
451
+ }
452
+
453
+ private getPlotPointerLocation(clientX: number, clientY: number): PointerLocation | null {
454
+ const container = this.chart.getDom().container
455
+ const rect = container.getBoundingClientRect()
456
+ const mouseX = clientX - rect.left
457
+ const mouseY = clientY - rect.top
458
+ return { mouseX, mouseY }
459
+ }
460
+
461
+ private getRightAxisPointerLocation(clientX: number, clientY: number): PointerLocation {
462
+ const rightAxisLayer = this.chart.getDom().rightAxisLayer
463
+ const rect = rightAxisLayer.getBoundingClientRect()
464
+ const mouseX = clientX - rect.left
465
+ const mouseY = clientY - rect.top
466
+ return { mouseX, mouseY }
467
+ }
468
+
469
+ private beginScalePriceDrag(clientY: number, mouseY: number) {
470
+ const pane = this.getPaneByY(mouseY)
471
+ if (!pane) return false
472
+ // 主图禁用垂直滚动时,禁止价格轴缩放
473
+ if (pane.id === 'main' && this.settings.disableMainPaneVerticalScroll) {
474
+ return false
475
+ }
476
+ this.isDragging = true
477
+ this.dragMode = 'scale-price'
478
+ this.dragStartY = clientY
479
+ this.activePaneIdOnDrag = pane.id
480
+ this.hoveredRightAxisPaneId = pane.id
481
+ this.hoveredSeparatorUpperPaneId = null
482
+ this.crosshairPos = null
483
+ this.crosshairIndex = null
484
+ this.crosshairPrice = null
485
+ this.hoveredIndex = null
486
+ this.activePaneId = pane.id
487
+ return true
488
+ }
489
+
490
+ clearHover() {
491
+ this.lastHoverRenderKey = ''
492
+ this.hoveredRightAxisPaneId = null
493
+ this.crosshairPos = null
494
+ this.crosshairIndex = null
495
+ this.crosshairPrice = null
496
+ this.hoveredIndex = null
497
+ this.activePaneId = null
498
+
499
+ this.markerState.clearAll(this.chart.getMarkerManager())
500
+ }
501
+
502
+
503
+ private clearSeparatorState() {
504
+ this.activeSeparatorUpperPaneId = null
505
+ this.hoveredSeparatorUpperPaneId = null
506
+ this.hoveredRightAxisPaneId = null
507
+ }
508
+
509
+ /**
510
+ * 从屏幕坐标更新 hover 状态
511
+ * @param clientX 屏幕 x 坐标
512
+ * @param clientY 屏幕 y 坐标
513
+ */
514
+
515
+ private updateRightAxisHoverFromPoint(clientX: number, clientY: number) {
516
+ const location = this.getRightAxisPointerLocation(clientX, clientY)
517
+ if (!location) return
518
+
519
+ const { mouseY } = location
520
+ const viewport = this.chart.getViewport()
521
+ const plotHeight = viewport?.plotHeight ?? Math.max(1, Math.round(this.chart.getDom().container.clientHeight))
522
+ if (mouseY < 0 || mouseY > plotHeight) {
523
+ this.hoveredRightAxisPaneId = null
524
+ return
525
+ }
526
+
527
+ const pane = this.getPaneByY(mouseY)
528
+ this.hoveredRightAxisPaneId = pane?.id || null
529
+ this.hoveredSeparatorUpperPaneId = null
530
+ this.crosshairPos = null
531
+ this.crosshairIndex = null
532
+ this.crosshairPrice = null
533
+ this.hoveredIndex = null
534
+ this.activePaneId = pane?.id || null
535
+ }
536
+
537
+ private updatePlotHoverFromPoint(clientX: number, clientY: number) {
538
+ const location = this.getPlotPointerLocation(clientX, clientY)
539
+ if (!location) return
540
+
541
+ const { mouseX, mouseY } = location
542
+ const container = this.chart.getDom().container
543
+ const viewport = this.chart.getViewport()
544
+ const viewWidth = viewport?.viewWidth ?? Math.max(1, Math.round(container.clientWidth))
545
+ const viewHeight = viewport?.viewHeight ?? Math.max(1, Math.round(container.clientHeight))
546
+ const plotWidth = viewport?.plotWidth ?? viewWidth
547
+ const plotHeight = viewport?.plotHeight ?? viewHeight
548
+ if (mouseX < 0 || mouseY < 0 || mouseX > plotWidth || mouseY > plotHeight) {
549
+ this.clearHover()
550
+ return
551
+ }
552
+
553
+ this.hoveredRightAxisPaneId = null
554
+
555
+ const scrollLeft = this.chart.getCachedScrollLeft()
556
+ const dpr = this.chart.getCurrentDpr()
557
+
558
+ const separatorUpperPaneId = this.hitTestPaneSeparator(mouseY)
559
+ this.hoveredSeparatorUpperPaneId = separatorUpperPaneId
560
+ if (separatorUpperPaneId) {
561
+ this.crosshairPos = null
562
+ this.crosshairIndex = null
563
+ this.crosshairPrice = null
564
+ this.hoveredIndex = null
565
+ this.activePaneId = null
566
+ return
567
+ }
568
+
569
+ const markerManager = this.chart.getMarkerManager()
570
+ const worldX = scrollLeft + mouseX
571
+ if (this.markerState.updateHoverFromPoint(worldX, mouseX, mouseY, markerManager)) {
572
+ this.crosshairPos = null
573
+ this.crosshairIndex = null
574
+ this.crosshairPrice = null
575
+ this.hoveredIndex = null
576
+ return
577
+ }
578
+
579
+ if (!this.kLinePositions || !this.visibleRange || !this.kWidthPx) {
580
+ this.clearHover()
581
+ return
582
+ }
583
+
584
+ const kWidthLogical = this.kWidthPx / dpr
585
+
586
+ let lo = 0, hi = this.kLinePositions.length
587
+ while (lo < hi) {
588
+ const mid = (lo + hi) >> 1
589
+ if (this.kLinePositions[mid]! < worldX) {
590
+ lo = mid + 1
591
+ } else {
592
+ hi = mid
593
+ }
594
+ }
595
+
596
+ let localIdx = lo
597
+ if (lo > 0 && lo < this.kLinePositions.length) {
598
+ const prevCenter = this.kLinePositions[lo - 1]! + kWidthLogical / 2
599
+ const currCenter = this.kLinePositions[lo]! + kWidthLogical / 2
600
+ if (Math.abs(worldX - prevCenter) < Math.abs(worldX - currCenter)) {
601
+ localIdx = lo - 1
602
+ }
603
+ } else if (lo === this.kLinePositions.length && this.kLinePositions.length > 0) {
604
+ localIdx = this.kLinePositions.length - 1
605
+ }
606
+
607
+ const idx = localIdx + this.visibleRange.start
608
+ const data = this.chart.getData()
609
+
610
+ const pane = this.getPaneByY(mouseY)
611
+ this.activePaneId = pane?.id || null
612
+
613
+ if (idx >= 0 && idx < (data?.length ?? 0)) {
614
+ this.crosshairIndex = idx
615
+
616
+ const kLineStartX = this.kLinePositions[localIdx]!
617
+ // 与影线位置算法一致: leftPx + (widthPx - 1) / 2
618
+ const leftPx = Math.round(kLineStartX * dpr)
619
+ const wickXPx = leftPx + (this.kWidthPx - 1) / 2
620
+ const snappedX = wickXPx / dpr - scrollLeft
621
+
622
+ this.crosshairPos = {
623
+ x: Math.min(Math.max(snappedX, 0), plotWidth),
624
+ y: Math.min(Math.max(mouseY, 0), plotHeight),
625
+ }
626
+
627
+ if (pane) {
628
+ const localY = mouseY - pane.top
629
+ this.crosshairPrice = pane.yAxis.yToPrice(localY)
630
+ } else {
631
+ this.crosshairPrice = null
632
+ }
633
+ } else {
634
+ this.crosshairIndex = null
635
+ this.crosshairPos = null
636
+ this.crosshairPrice = null
637
+ }
638
+
639
+ const k = typeof this.crosshairIndex === 'number' ? data[this.crosshairIndex] : undefined
640
+ if (!k || !pane || !pane.capabilities.candleHitTest) {
641
+ this.hoveredIndex = null
642
+ return
643
+ }
644
+
645
+ const localY = mouseY - pane.top
646
+ const openY = pane.yAxis.priceToY(k.open)
647
+ const closeY = pane.yAxis.priceToY(k.close)
648
+ const highY = pane.yAxis.priceToY(k.high)
649
+ const lowY = pane.yAxis.priceToY(k.low)
650
+ const bodyTop = Math.min(openY, closeY)
651
+ const bodyBottom = Math.max(openY, closeY)
652
+
653
+ const kLineStartX = this.kLinePositions[localIdx]!
654
+ const inUnitX = worldX - kLineStartX
655
+ const cxLogical = kWidthLogical / 2
656
+
657
+ const MIN_BODY_HIT_HEIGHT = 8
658
+ const bodyHeight = Math.abs(bodyBottom - bodyTop)
659
+ const effectiveBodyTop = bodyHeight < MIN_BODY_HIT_HEIGHT ? (bodyTop + bodyBottom) / 2 - MIN_BODY_HIT_HEIGHT / 2 : bodyTop
660
+ const effectiveBodyBottom = bodyHeight < MIN_BODY_HIT_HEIGHT ? (bodyTop + bodyBottom) / 2 + MIN_BODY_HIT_HEIGHT / 2 : bodyBottom
661
+
662
+ const HIT_WICK_HALF_EXTENDED = 3
663
+
664
+ const hitBody = localY >= effectiveBodyTop && localY <= effectiveBodyBottom &&
665
+ inUnitX >= 0 && inUnitX <= kWidthLogical
666
+ const hitWick = Math.abs(inUnitX - cxLogical) <= HIT_WICK_HALF_EXTENDED &&
667
+ localY >= Math.min(highY, lowY) && localY <= Math.max(highY, lowY)
668
+
669
+ if (!hitBody && !hitWick) {
670
+ this.hoveredIndex = null
671
+ return
672
+ }
673
+
674
+ this.hoveredIndex = this.crosshairIndex
675
+
676
+ const tooltipResult = computeTooltipPosition({
677
+ mouseX,
678
+ mouseY,
679
+ viewWidth,
680
+ viewHeight,
681
+ plotWidth,
682
+ plotHeight,
683
+ tooltipSize: this.tooltipSize,
684
+ useAnchorPositioning: this.useTooltipAnchorPositioning,
685
+ })
686
+ if (tooltipResult.anchorPlacement) {
687
+ this.tooltipAnchorPlacement = tooltipResult.anchorPlacement
688
+ }
689
+ this.tooltipPos = tooltipResult.pos
690
+ }
691
+
692
+
693
+ /**
694
+ * 重置所有交互状态(数据更新时调用)
695
+ */
696
+ reset(): void {
697
+ this.isDragging = false
698
+ this.dragMode = 'none'
699
+ this.dragStartX = 0
700
+ this.dragStartY = 0
701
+ this.scrollStartX = 0
702
+ this.activePaneIdOnDrag = null
703
+ this.clearSeparatorState()
704
+ this.isTouchSession = false
705
+ this.pinchTracker.reset()
706
+ this.crosshairPos = null
707
+ this.crosshairIndex = null
708
+ this.crosshairPrice = null
709
+ this.hoveredIndex = null
710
+ this.activePaneId = null
711
+ this.markerState.reset()
712
+ this.kLinePositions = null
713
+ this.visibleRange = null
714
+ this.lastHoverRenderKey = ''
715
+ this.kWidthPx = null
716
+ }
717
+
718
+ /** 获取十字线指向的 K 线索引 */
719
+ getCrosshairIndex(): number | null {
720
+ return this.crosshairIndex
721
+ }
722
+ }