@363045841yyt/klinechart-core 0.7.3 → 0.7.5

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 (247) hide show
  1. package/README.md +201 -201
  2. package/README.zh-CN.md +201 -201
  3. package/dist/controllers/index.d.ts +1 -0
  4. package/dist/controllers/index.d.ts.map +1 -1
  5. package/dist/controllers/index.js +1 -0
  6. package/dist/controllers/index.js.map +1 -1
  7. package/dist/engine/chart.d.ts +11 -19
  8. package/dist/engine/chart.d.ts.map +1 -1
  9. package/dist/engine/chart.js +92 -109
  10. package/dist/engine/chart.js.map +1 -1
  11. package/dist/engine/renderers/Indicator/indicatorData.d.ts +1 -0
  12. package/dist/engine/renderers/Indicator/indicatorData.d.ts.map +1 -1
  13. package/dist/engine/renderers/Indicator/indicatorData.js +1 -1
  14. package/dist/engine/renderers/Indicator/indicatorData.js.map +1 -1
  15. package/dist/engine/renderers/webgl/candleSurface.js +47 -47
  16. package/dist/engine/subPaneManager.d.ts +4 -0
  17. package/dist/engine/subPaneManager.d.ts.map +1 -1
  18. package/dist/engine/subPaneManager.js +13 -0
  19. package/dist/engine/subPaneManager.js.map +1 -1
  20. package/dist/version.d.ts +1 -1
  21. package/dist/version.d.ts.map +1 -1
  22. package/dist/version.js +1 -2
  23. package/dist/version.js.map +1 -1
  24. package/package.json +129 -122
  25. package/src/__tests__/signal.test.ts +124 -124
  26. package/src/config/chartSettings.ts +66 -66
  27. package/src/controllers/__tests__/drawing.test.ts +214 -214
  28. package/src/controllers/__tests__/indicatorSelector.test.ts +481 -481
  29. package/src/controllers/__tests__/toolbar.test.ts +225 -225
  30. package/src/controllers/createChartController.ts +665 -665
  31. package/src/controllers/createDrawingController.ts +96 -96
  32. package/src/controllers/createIndicatorSelectorController.ts +307 -307
  33. package/src/controllers/createToolbarController.ts +146 -146
  34. package/src/controllers/index.ts +20 -19
  35. package/src/controllers/types.ts +284 -284
  36. package/src/engine/__tests__/chart.dpr.test.ts +401 -401
  37. package/src/engine/__tests__/paneRenderer.resize.test.ts +92 -92
  38. package/src/engine/chart-store.ts +121 -121
  39. package/src/engine/chart.d.ts +617 -617
  40. package/src/engine/chart.ts +2803 -2815
  41. package/src/engine/controller/__tests__/interaction.dpr.test.ts +259 -259
  42. package/src/engine/controller/interaction.ts +722 -722
  43. package/src/engine/controller/markerInteraction.ts +130 -130
  44. package/src/engine/controller/pinchTracker.ts +82 -82
  45. package/src/engine/controller/tooltipPosition.ts +48 -48
  46. package/src/engine/draw/__tests__/pixelAlign.spec.ts +176 -176
  47. package/src/engine/draw/pixelAlign.ts +259 -259
  48. package/src/engine/drawing/index.ts +655 -655
  49. package/src/engine/drawing/interaction.ts +842 -842
  50. package/src/engine/drawing/plugin.ts +343 -343
  51. package/src/engine/indicators/__tests__/__fixtures__/golden/atr.json +38 -38
  52. package/src/engine/indicators/__tests__/__fixtures__/golden/dema.json +14 -14
  53. package/src/engine/indicators/__tests__/__fixtures__/golden/hma.json +14 -14
  54. package/src/engine/indicators/__tests__/__fixtures__/golden/index.ts +55 -55
  55. package/src/engine/indicators/__tests__/__fixtures__/golden/kama.json +14 -14
  56. package/src/engine/indicators/__tests__/__fixtures__/golden/tema.json +14 -14
  57. package/src/engine/indicators/__tests__/__fixtures__/golden/wma.json +40 -40
  58. package/src/engine/indicators/__tests__/__fixtures__/synthetic.ts +65 -65
  59. package/src/engine/indicators/__tests__/_propertyAssertions.ts +76 -76
  60. package/src/engine/indicators/__tests__/atr.test.ts +153 -153
  61. package/src/engine/indicators/__tests__/calculators.test.ts +614 -614
  62. package/src/engine/indicators/__tests__/cmf-mfi.test.ts +100 -100
  63. package/src/engine/indicators/__tests__/dema.test.ts +73 -73
  64. package/src/engine/indicators/__tests__/donchian.test.ts +70 -70
  65. package/src/engine/indicators/__tests__/hma.test.ts +73 -73
  66. package/src/engine/indicators/__tests__/ichimoku.test.ts +105 -105
  67. package/src/engine/indicators/__tests__/kama.test.ts +80 -80
  68. package/src/engine/indicators/__tests__/keltner.test.ts +65 -65
  69. package/src/engine/indicators/__tests__/pivot-fib.test.ts +110 -110
  70. package/src/engine/indicators/__tests__/roc.test.ts +68 -68
  71. package/src/engine/indicators/__tests__/sar.test.ts +86 -86
  72. package/src/engine/indicators/__tests__/scheduler.test.ts +831 -831
  73. package/src/engine/indicators/__tests__/soa.test.ts +533 -533
  74. package/src/engine/indicators/__tests__/structure.test.ts +110 -110
  75. package/src/engine/indicators/__tests__/supertrend.test.ts +65 -65
  76. package/src/engine/indicators/__tests__/tema.test.ts +68 -68
  77. package/src/engine/indicators/__tests__/trix.test.ts +70 -70
  78. package/src/engine/indicators/__tests__/volatility.test.ts +117 -117
  79. package/src/engine/indicators/__tests__/volume.test.ts +115 -115
  80. package/src/engine/indicators/__tests__/volumeProfile.test.ts +74 -74
  81. package/src/engine/indicators/__tests__/vwap.test.ts +69 -69
  82. package/src/engine/indicators/__tests__/wma.test.ts +112 -112
  83. package/src/engine/indicators/__tests__/zones.test.ts +95 -95
  84. package/src/engine/indicators/atrState.ts +27 -27
  85. package/src/engine/indicators/bollState.ts +51 -51
  86. package/src/engine/indicators/calculators.ts +2593 -2593
  87. package/src/engine/indicators/cciState.ts +25 -25
  88. package/src/engine/indicators/chaikinVolState.ts +32 -32
  89. package/src/engine/indicators/cmfState.ts +27 -27
  90. package/src/engine/indicators/demaState.ts +27 -27
  91. package/src/engine/indicators/donchianState.ts +43 -43
  92. package/src/engine/indicators/eneState.ts +43 -43
  93. package/src/engine/indicators/expmaState.ts +43 -43
  94. package/src/engine/indicators/fastkState.ts +25 -25
  95. package/src/engine/indicators/fibState.ts +41 -41
  96. package/src/engine/indicators/hmaState.ts +27 -27
  97. package/src/engine/indicators/hvState.ts +28 -28
  98. package/src/engine/indicators/ichimokuState.ts +70 -70
  99. package/src/engine/indicators/indicator.worker.ts +169 -169
  100. package/src/engine/indicators/indicatorDefinitionRegistry.ts +62 -62
  101. package/src/engine/indicators/indicatorMetadata.ts +110 -110
  102. package/src/engine/indicators/indicatorRegistry.ts +106 -106
  103. package/src/engine/indicators/indicatorRuntime.ts +1548 -1548
  104. package/src/engine/indicators/kamaState.ts +34 -34
  105. package/src/engine/indicators/keltnerState.ts +49 -49
  106. package/src/engine/indicators/kstState.ts +42 -42
  107. package/src/engine/indicators/maState.ts +36 -36
  108. package/src/engine/indicators/macdState.ts +76 -76
  109. package/src/engine/indicators/mfiState.ts +27 -27
  110. package/src/engine/indicators/momState.ts +25 -25
  111. package/src/engine/indicators/obvState.ts +25 -25
  112. package/src/engine/indicators/parkinsonState.ts +28 -28
  113. package/src/engine/indicators/pivotState.ts +51 -51
  114. package/src/engine/indicators/pvtState.ts +25 -25
  115. package/src/engine/indicators/rocState.ts +27 -27
  116. package/src/engine/indicators/rsiState.ts +65 -65
  117. package/src/engine/indicators/sarState.ts +41 -41
  118. package/src/engine/indicators/scheduler.ts +1205 -1205
  119. package/src/engine/indicators/soa.ts +352 -352
  120. package/src/engine/indicators/stateComposer.ts +1262 -1262
  121. package/src/engine/indicators/stochState.ts +26 -26
  122. package/src/engine/indicators/structureState.ts +69 -69
  123. package/src/engine/indicators/supertrendState.ts +37 -37
  124. package/src/engine/indicators/temaState.ts +27 -27
  125. package/src/engine/indicators/trixState.ts +35 -35
  126. package/src/engine/indicators/vmaState.ts +27 -27
  127. package/src/engine/indicators/volumeProfileState.ts +63 -63
  128. package/src/engine/indicators/vwapState.ts +29 -29
  129. package/src/engine/indicators/wmaState.ts +27 -27
  130. package/src/engine/indicators/wmsrState.ts +25 -25
  131. package/src/engine/indicators/workerProtocol.ts +613 -613
  132. package/src/engine/indicators/zonesState.ts +47 -47
  133. package/src/engine/layout/pane.ts +161 -161
  134. package/src/engine/marker/registry.ts +265 -265
  135. package/src/engine/paneRenderer.ts +169 -169
  136. package/src/engine/renderers/Indicator/atr.ts +237 -237
  137. package/src/engine/renderers/Indicator/boll.ts +317 -317
  138. package/src/engine/renderers/Indicator/cci.ts +275 -275
  139. package/src/engine/renderers/Indicator/chaikinVol.ts +138 -138
  140. package/src/engine/renderers/Indicator/cmf.ts +137 -137
  141. package/src/engine/renderers/Indicator/dema.ts +136 -136
  142. package/src/engine/renderers/Indicator/donchian.ts +137 -137
  143. package/src/engine/renderers/Indicator/ene.ts +271 -271
  144. package/src/engine/renderers/Indicator/expma.ts +197 -197
  145. package/src/engine/renderers/Indicator/fastk.ts +316 -316
  146. package/src/engine/renderers/Indicator/fib.ts +141 -141
  147. package/src/engine/renderers/Indicator/hma.ts +136 -136
  148. package/src/engine/renderers/Indicator/hv.ts +124 -124
  149. package/src/engine/renderers/Indicator/ichimoku.ts +181 -181
  150. package/src/engine/renderers/Indicator/index.ts +241 -241
  151. package/src/engine/renderers/Indicator/indicatorData.ts +650 -650
  152. package/src/engine/renderers/Indicator/kama.ts +136 -136
  153. package/src/engine/renderers/Indicator/keltner.ts +137 -137
  154. package/src/engine/renderers/Indicator/kst.ts +302 -302
  155. package/src/engine/renderers/Indicator/ma.ts +200 -200
  156. package/src/engine/renderers/Indicator/macd.ts +477 -477
  157. package/src/engine/renderers/Indicator/macdLegend.ts +141 -141
  158. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +272 -272
  159. package/src/engine/renderers/Indicator/mfi.ts +142 -142
  160. package/src/engine/renderers/Indicator/mom.ts +311 -311
  161. package/src/engine/renderers/Indicator/obv.ts +123 -123
  162. package/src/engine/renderers/Indicator/parkinson.ts +124 -124
  163. package/src/engine/renderers/Indicator/pivot.ts +131 -131
  164. package/src/engine/renderers/Indicator/pvt.ts +123 -123
  165. package/src/engine/renderers/Indicator/roc.ts +143 -143
  166. package/src/engine/renderers/Indicator/rsi.ts +390 -390
  167. package/src/engine/renderers/Indicator/sar.ts +113 -113
  168. package/src/engine/renderers/Indicator/scale/atr_scale.ts +19 -19
  169. package/src/engine/renderers/Indicator/scale/cci_scale.ts +19 -19
  170. package/src/engine/renderers/Indicator/scale/fastk_scale.ts +19 -19
  171. package/src/engine/renderers/Indicator/scale/indicator_scale.ts +204 -204
  172. package/src/engine/renderers/Indicator/scale/kst_scale.ts +19 -19
  173. package/src/engine/renderers/Indicator/scale/macd_scale.ts +22 -22
  174. package/src/engine/renderers/Indicator/scale/mom_scale.ts +19 -19
  175. package/src/engine/renderers/Indicator/scale/rsi_scale.ts +19 -19
  176. package/src/engine/renderers/Indicator/scale/stoch_scale.ts +19 -19
  177. package/src/engine/renderers/Indicator/scale/volume_scale.ts +26 -26
  178. package/src/engine/renderers/Indicator/scale/wmsr_scale.ts +19 -19
  179. package/src/engine/renderers/Indicator/stoch.ts +359 -359
  180. package/src/engine/renderers/Indicator/structure.ts +126 -126
  181. package/src/engine/renderers/Indicator/subPaneConfig.ts +265 -265
  182. package/src/engine/renderers/Indicator/supertrend.ts +115 -115
  183. package/src/engine/renderers/Indicator/tema.ts +136 -136
  184. package/src/engine/renderers/Indicator/trix.ts +158 -158
  185. package/src/engine/renderers/Indicator/vma.ts +124 -124
  186. package/src/engine/renderers/Indicator/volumeProfile.ts +125 -125
  187. package/src/engine/renderers/Indicator/vwap.ts +123 -123
  188. package/src/engine/renderers/Indicator/wma.ts +136 -136
  189. package/src/engine/renderers/Indicator/wmsr.ts +328 -328
  190. package/src/engine/renderers/Indicator/zones.ts +104 -104
  191. package/src/engine/renderers/__tests__/boll.renderer.test.ts +314 -314
  192. package/src/engine/renderers/__tests__/ene.renderer.test.ts +305 -305
  193. package/src/engine/renderers/__tests__/expma.renderer.test.ts +279 -279
  194. package/src/engine/renderers/__tests__/ma.renderer.test.ts +426 -426
  195. package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +502 -502
  196. package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +173 -173
  197. package/src/engine/renderers/candle.ts +459 -459
  198. package/src/engine/renderers/crosshair.ts +69 -69
  199. package/src/engine/renderers/customMarkers.ts +162 -162
  200. package/src/engine/renderers/extremaMarkers.ts +246 -246
  201. package/src/engine/renderers/gridLines.ts +90 -90
  202. package/src/engine/renderers/lastPrice.ts +97 -97
  203. package/src/engine/renderers/paneTitle.ts +136 -136
  204. package/src/engine/renderers/subVolume.ts +236 -236
  205. package/src/engine/renderers/timeAxis.ts +121 -121
  206. package/src/engine/renderers/webgl/candleSurface.ts +955 -955
  207. package/src/engine/renderers/webgl/sharedWebGLSurface.ts +146 -146
  208. package/src/engine/renderers/yAxis.ts +105 -105
  209. package/src/engine/scale/__tests__/logFormula.spec.ts +148 -148
  210. package/src/engine/scale/logFormula.ts +130 -130
  211. package/src/engine/scale/price.ts +39 -39
  212. package/src/engine/scale/priceScale.ts +264 -264
  213. package/src/engine/subPaneManager.ts +442 -427
  214. package/src/engine/theme/colors.ts +642 -642
  215. package/src/engine/theme/fonts.ts +20 -20
  216. package/src/engine/utils/klineConfig.ts +49 -49
  217. package/src/engine/utils/tickCount.ts +11 -11
  218. package/src/engine/utils/tickPosition.ts +214 -214
  219. package/src/engine/utils/zoom.ts +83 -83
  220. package/src/engine/viewport/viewport.ts +67 -67
  221. package/src/index.ts +3 -3
  222. package/src/plugin/ConfigManager.ts +93 -93
  223. package/src/plugin/EventBus.ts +77 -77
  224. package/src/plugin/HookSystem.ts +106 -106
  225. package/src/plugin/PluginHost.ts +243 -243
  226. package/src/plugin/PluginRegistry.ts +92 -92
  227. package/src/plugin/StateStore.ts +73 -73
  228. package/src/plugin/index.ts +19 -19
  229. package/src/plugin/rendererPluginManager.ts +368 -368
  230. package/src/plugin/stateKeys.ts +8 -8
  231. package/src/plugin/types.ts +526 -526
  232. package/src/reactivity/index.ts +2 -2
  233. package/src/reactivity/signal.ts +119 -119
  234. package/src/semantic/controller.ts +251 -251
  235. package/src/semantic/drawShape.ts +260 -260
  236. package/src/semantic/index.ts +28 -28
  237. package/src/semantic/schema.json +256 -256
  238. package/src/semantic/types.ts +251 -251
  239. package/src/semantic/validator.ts +349 -349
  240. package/src/types/kLine.ts +13 -13
  241. package/src/types/price.ts +56 -56
  242. package/src/types/volumePrice.ts +33 -33
  243. package/src/utils/dateFormat.ts +208 -208
  244. package/src/utils/kLineDraw/axis.ts +562 -562
  245. package/src/utils/priceToY.ts +34 -34
  246. package/src/utils/volumePrice.ts +202 -202
  247. 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
+ }