@quantlife/qlchart 0.0.1
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.
- package/.idea/QLChart.iml +12 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/README.md +75 -0
- package/demo/App.css +213 -0
- package/demo/App.tsx +46 -0
- package/demo/components/ControlPanel.tsx +13 -0
- package/demo/components/DemoNav.tsx +27 -0
- package/demo/index.html +16 -0
- package/demo/main.tsx +10 -0
- package/demo/pages/BasicChartDemo.tsx +61 -0
- package/demo/pages/DrawingDemo.tsx +22 -0
- package/demo/pages/IndicatorDemo.tsx +22 -0
- package/demo/pages/LayoutDemo.tsx +35 -0
- package/demo/pages/MultiPeriodDemo.tsx +31 -0
- package/demo/pages/ReplayDemo.tsx +195 -0
- package/demo/pages/SaveDemo.tsx +27 -0
- package/demo/pages/ThemeDemo.tsx +29 -0
- package/demo/standalone-demo.html +597 -0
- package/demo/vite.config.demo.ts +17 -0
- package/dist/index.d.ts +1973 -0
- package/dist/qlchart.js +23169 -0
- package/dist/style.css +1 -0
- package/doc/api/indicator-data-processor.md +35 -0
- package/doc/api-reference/.nojekyll +1 -0
- package/doc/api-reference/assets/hierarchy.js +1 -0
- package/doc/api-reference/assets/highlight.css +43 -0
- package/doc/api-reference/assets/icons.js +18 -0
- package/doc/api-reference/assets/icons.svg +1 -0
- package/doc/api-reference/assets/main.js +60 -0
- package/doc/api-reference/assets/navigation.js +1 -0
- package/doc/api-reference/assets/search.js +1 -0
- package/doc/api-reference/assets/style.css +1611 -0
- package/doc/api-reference/classes/ChartManager.html +16 -0
- package/doc/api-reference/classes/DataManager.html +13 -0
- package/doc/api-reference/classes/DrawingAdapter.html +64 -0
- package/doc/api-reference/classes/DrawingPersistence.html +21 -0
- package/doc/api-reference/classes/EventManager.html +12 -0
- package/doc/api-reference/classes/HollowCandlestickSeries.html +22 -0
- package/doc/api-reference/classes/IndicatorRenderer.html +20 -0
- package/doc/api-reference/classes/KlineReplay.html +31 -0
- package/doc/api-reference/classes/MockDataService.html +13 -0
- package/doc/api-reference/classes/MockIndicatorService.html +4 -0
- package/doc/api-reference/classes/OverlayIndicator.html +11 -0
- package/doc/api-reference/classes/PaneIndicator.html +16 -0
- package/doc/api-reference/classes/PaneManager.html +24 -0
- package/doc/api-reference/classes/RealtimeDataFeed.html +22 -0
- package/doc/api-reference/classes/RenkoSeries.html +22 -0
- package/doc/api-reference/classes/ScreenshotUtil.html +10 -0
- package/doc/api-reference/classes/SeriesManager.html +30 -0
- package/doc/api-reference/classes/ThemeManager.html +18 -0
- package/doc/api-reference/enums/ChartEvent.html +12 -0
- package/doc/api-reference/enums/IndicatorType.html +4 -0
- package/doc/api-reference/enums/SeriesType.html +13 -0
- package/doc/api-reference/functions/ChartFunctionMenu.html +1 -0
- package/doc/api-reference/functions/DrawingModule.html +8 -0
- package/doc/api-reference/functions/IndicatorPanel.html +2 -0
- package/doc/api-reference/functions/IndicatorTag.html +2 -0
- package/doc/api-reference/functions/KlineTypeSelector.html +1 -0
- package/doc/api-reference/functions/LayoutSwitcher.html +1 -0
- package/doc/api-reference/functions/PeriodSelector.html +1 -0
- package/doc/api-reference/functions/QLChartLayout.html +1 -0
- package/doc/api-reference/functions/QLChartPanel.html +10 -0
- package/doc/api-reference/functions/QLChartProvider.html +2 -0
- package/doc/api-reference/functions/QLToolbar.html +1 -0
- package/doc/api-reference/functions/ReplayController.html +1 -0
- package/doc/api-reference/functions/TimeBarModule.html +4 -0
- package/doc/api-reference/functions/TimeRangeSelector.html +1 -0
- package/doc/api-reference/functions/createIndicatorConfig.html +2 -0
- package/doc/api-reference/functions/getToolConfig.html +2 -0
- package/doc/api-reference/functions/getToolsByCategory.html +2 -0
- package/doc/api-reference/functions/getToolsByPriority.html +2 -0
- package/doc/api-reference/functions/mapLibTypeToOurs.html +2 -0
- package/doc/api-reference/functions/mapToolTypeToLib.html +3 -0
- package/doc/api-reference/functions/transformCandlestickData.html +3 -0
- package/doc/api-reference/functions/transformIndicatorData.html +2 -0
- package/doc/api-reference/functions/transformVolumeData.html +3 -0
- package/doc/api-reference/functions/useChart.html +4 -0
- package/doc/api-reference/functions/useChartStore.html +8 -0
- package/doc/api-reference/functions/useCrosshairSync.html +8 -0
- package/doc/api-reference/functions/useDrawingModule.html +1 -0
- package/doc/api-reference/functions/useDrawingStore.html +8 -0
- package/doc/api-reference/functions/useIndicatorStore.html +8 -0
- package/doc/api-reference/functions/useQLChartConfig.html +2 -0
- package/doc/api-reference/functions/useReplayStore.html +8 -0
- package/doc/api-reference/functions/useTheme.html +2 -0
- package/doc/api-reference/functions/useTimeBarStore.html +8 -0
- package/doc/api-reference/index.html +1 -0
- package/doc/api-reference/interfaces/CandlestickData.html +7 -0
- package/doc/api-reference/interfaces/CandlestickRawData.html +8 -0
- package/doc/api-reference/interfaces/ChartFunctionMenuProps.html +2 -0
- package/doc/api-reference/interfaces/ChartManagerCreateOptions.html +4 -0
- package/doc/api-reference/interfaces/ChartOptions.html +8 -0
- package/doc/api-reference/interfaces/ChartRequestParams.html +8 -0
- package/doc/api-reference/interfaces/ChartResponse.html +5 -0
- package/doc/api-reference/interfaces/ChartState.html +24 -0
- package/doc/api-reference/interfaces/ChartThemeOptions.html +5 -0
- package/doc/api-reference/interfaces/CrosshairData.html +5 -0
- package/doc/api-reference/interfaces/DrawingModuleProps.html +5 -0
- package/doc/api-reference/interfaces/DrawingState.html +48 -0
- package/doc/api-reference/interfaces/HistogramData.html +5 -0
- package/doc/api-reference/interfaces/HollowCandlestickData.html +14 -0
- package/doc/api-reference/interfaces/IndicatorConfig.html +11 -0
- package/doc/api-reference/interfaces/IndicatorDataPoint.html +4 -0
- package/doc/api-reference/interfaces/IndicatorDataResponse.html +5 -0
- package/doc/api-reference/interfaces/IndicatorDefinition.html +9 -0
- package/doc/api-reference/interfaces/IndicatorPanelProps.html +3 -0
- package/doc/api-reference/interfaces/IndicatorParamDef.html +8 -0
- package/doc/api-reference/interfaces/IndicatorRawData.html +4 -0
- package/doc/api-reference/interfaces/IndicatorState.html +19 -0
- package/doc/api-reference/interfaces/IndicatorTagProps.html +2 -0
- package/doc/api-reference/interfaces/KlineReplayOptions.html +4 -0
- package/doc/api-reference/interfaces/LayoutSwitcherProps.html +3 -0
- package/doc/api-reference/interfaces/LineData.html +4 -0
- package/doc/api-reference/interfaces/MockDataConfig.html +8 -0
- package/doc/api-reference/interfaces/MockIndicatorConfig.html +5 -0
- package/doc/api-reference/interfaces/PairInfo.html +6 -0
- package/doc/api-reference/interfaces/PaneInfo.html +6 -0
- package/doc/api-reference/interfaces/PanelConfig.html +9 -0
- package/doc/api-reference/interfaces/PersistenceConfig.html +12 -0
- package/doc/api-reference/interfaces/QLChartConfig.html +18 -0
- package/doc/api-reference/interfaces/QLChartLayoutProps.html +9 -0
- package/doc/api-reference/interfaces/QLChartPanelProps.html +13 -0
- package/doc/api-reference/interfaces/QLChartPanelRef.html +14 -0
- package/doc/api-reference/interfaces/QLToolbarProps.html +7 -0
- package/doc/api-reference/interfaces/RealtimeCandle.html +9 -0
- package/doc/api-reference/interfaces/RealtimeSubscribeFn.html +2 -0
- package/doc/api-reference/interfaces/RenkoData.html +16 -0
- package/doc/api-reference/interfaces/ReplayControllerProps.html +2 -0
- package/doc/api-reference/interfaces/ReplayState.html +19 -0
- package/doc/api-reference/interfaces/ThemeConfig.html +14 -0
- package/doc/api-reference/interfaces/TimeBarModuleProps.html +5 -0
- package/doc/api-reference/interfaces/TimeBarState.html +13 -0
- package/doc/api-reference/interfaces/UseChartReturn.html +4 -0
- package/doc/api-reference/interfaces/UseDrawingModuleOptions.html +11 -0
- package/doc/api-reference/interfaces/UseDrawingModuleReturn.html +6 -0
- package/doc/api-reference/interfaces/UseThemeReturn.html +5 -0
- package/doc/api-reference/types/EventHandler.html +2 -0
- package/doc/api-reference/types/FetchFn.html +2 -0
- package/doc/api-reference/types/LayoutMode.html +2 -0
- package/doc/api-reference/types/MarketTrend.html +2 -0
- package/doc/api-reference/types/ThemePreset.html +2 -0
- package/doc/api-reference/variables/BUILTIN_INDICATORS.html +2 -0
- package/doc/api-reference/variables/CATEGORY_LABELS.html +2 -0
- package/doc/api-reference/variables/DRAWING_TOOLS.html +3 -0
- package/doc/api-reference/variables/MARKET_PRESETS.html +1 -0
- package/doc/api-reference/variables/PAIR_PRESETS.html +1 -0
- package/doc/api-reference/variables/darkPreset.html +1 -0
- package/doc/api-reference/variables/lightPreset.html +1 -0
- package/doc/components/drawing-module.md +24 -0
- package/doc/components/indicator-list-panel.md +24 -0
- package/doc/components/indicator-panel.md +17 -0
- package/doc/components/pane-divider.md +25 -0
- package/doc/components/qlchart-layout.md +30 -0
- package/doc/components/qlchart-panel.md +93 -0
- package/doc/components/qlchart-provider.md +73 -0
- package/doc/components/qltoolbar.md +17 -0
- package/doc/components/replay-controller.md +23 -0
- package/doc/components/timebar-module.md +13 -0
- package/doc/core/chart-manager.md +14 -0
- package/doc/core/data-manager.md +33 -0
- package/doc/core/event-manager.md +26 -0
- package/doc/core/pane-manager.md +13 -0
- package/doc/core/series-manager.md +19 -0
- package/doc/core/theme-manager.md +21 -0
- package/doc/examples/basic-chart.md +24 -0
- package/doc/examples/data-format-guide.md +119 -0
- package/doc/examples/drawing-tools.md +30 -0
- package/doc/examples/indicator-properties.md +34 -0
- package/doc/examples/multi-pane.md +24 -0
- package/doc/examples/multi-panel.md +23 -0
- package/doc/examples/realtime-data.md +147 -0
- package/doc/examples/standalone-js.md +333 -0
- package/doc/guide/architecture.md +87 -0
- package/doc/guide/data-flow.md +310 -0
- package/doc/guide/deployment.md +59 -0
- package/doc/guide/drawing-properties.md +40 -0
- package/doc/guide/getting-started.md +94 -0
- package/doc/guide/pane-system.md +47 -0
- package/doc/guide/theme-switching.md +58 -0
- package/doc/hooks/use-chart.md +20 -0
- package/doc/hooks/use-crosshair-sync.md +14 -0
- package/doc/hooks/use-drawing-module.md +43 -0
- package/doc/hooks/use-theme.md +15 -0
- package/doc/index.md +33 -0
- package/doc/plugins/drawing/overview.md +36 -0
- package/doc/plugins/drawing/persistence.md +42 -0
- package/doc/plugins/drawing/tool-registry.md +29 -0
- package/doc/plugins/hollow-candlestick.md +18 -0
- package/doc/plugins/indicators.md +28 -0
- package/doc/plugins/renko.md +17 -0
- package/doc/plugins/replay.md +21 -0
- package/doc/plugins/screenshot.md +20 -0
- package/docs/api.md +94 -0
- package/package.json +54 -0
- package/python/qlchart/__init__.py +9 -0
- package/python/qlchart/__pycache__/__init__.cpython-311.pyc +0 -0
- package/python/qlchart/__pycache__/chart.cpython-311.pyc +0 -0
- package/python/qlchart/chart.py +333 -0
- package/python/qlchart/templates/chart_template.html +304 -0
- package/python/requirements.txt +1 -0
- package/python/setup.py +18 -0
- package/python/tests/__init__.py +1 -0
- package/python/tests/__pycache__/__init__.cpython-311.pyc +0 -0
- package/python/tests/__pycache__/test_chart.cpython-311-pytest-8.3.3.pyc +0 -0
- package/python/tests/test_chart.py +114 -0
- package/quantlife-qlchart-0.0.1.tgz +0 -0
- package/src/api/chartApi.ts +30 -0
- package/src/api/indicatorApi.ts +27 -0
- package/src/components/ChartFunctionMenu.tsx +64 -0
- package/src/components/PaneChartPanel.tsx +116 -0
- package/src/components/PaneDivider.tsx +66 -0
- package/src/components/QLChartLayout.tsx +151 -0
- package/src/components/QLChartPanel.tsx +560 -0
- package/src/components/QLChartProvider.tsx +90 -0
- package/src/components/context-menu/ChartContextMenu.tsx +139 -0
- package/src/components/context-menu/index.ts +2 -0
- package/src/components/drawing/DrawingModule.tsx +36 -0
- package/src/components/drawing/DrawingPropertyPanel.tsx +347 -0
- package/src/components/drawing/DrawingToolbar.tsx +305 -0
- package/src/components/drawing/index.ts +5 -0
- package/src/components/index.ts +43 -0
- package/src/components/indicator/IndicatorListPanel.tsx +94 -0
- package/src/components/indicator/IndicatorModal.tsx +171 -0
- package/src/components/indicator/IndicatorPanel.tsx +9 -0
- package/src/components/indicator/IndicatorPropertyPanel.tsx +130 -0
- package/src/components/indicator/IndicatorTag.tsx +173 -0
- package/src/components/indicator/index.ts +4 -0
- package/src/components/replay/ReplayController.css +97 -0
- package/src/components/replay/ReplayController.tsx +138 -0
- package/src/components/timebar/TimeBarModule.tsx +30 -0
- package/src/components/timebar/TimeRangeSelector.tsx +96 -0
- package/src/components/timebar/index.ts +3 -0
- package/src/components/toolbar/GlobalToolbar.tsx +58 -0
- package/src/components/toolbar/KlineTypeSelector.tsx +123 -0
- package/src/components/toolbar/LayoutSwitcher.tsx +45 -0
- package/src/components/toolbar/PeriodSelector.tsx +35 -0
- package/src/components/toolbar/QLToolbar.tsx +71 -0
- package/src/components/toolbar/TimeRangeSelector.tsx +89 -0
- package/src/components/ui/Modal.tsx +67 -0
- package/src/core/ChartManager.ts +95 -0
- package/src/core/DataManager.ts +427 -0
- package/src/core/EventManager.ts +63 -0
- package/src/core/IndicatorDataProcessor.ts +104 -0
- package/src/core/PaneManager.ts +121 -0
- package/src/core/RealtimeDataFeed.ts +110 -0
- package/src/core/SeriesManager.ts +210 -0
- package/src/core/ThemeManager.ts +59 -0
- package/src/core/index.ts +10 -0
- package/src/css.d.ts +4 -0
- package/src/hooks/useChart.ts +62 -0
- package/src/hooks/useCrosshairSync.ts +109 -0
- package/src/hooks/useDrawingModule.ts +475 -0
- package/src/hooks/useTheme.ts +31 -0
- package/src/index.ts +170 -0
- package/src/mock/MockDataService.ts +102 -0
- package/src/mock/MockIndicatorService.ts +40 -0
- package/src/mock/index.ts +5 -0
- package/src/mock/presets.ts +16 -0
- package/src/plugins/drawing/DrawingAdapter.ts +1762 -0
- package/src/plugins/drawing/DrawingPersistence.ts +273 -0
- package/src/plugins/drawing/DrawingPropertyTemplates.ts +327 -0
- package/src/plugins/drawing/DrawingSharedService.ts +125 -0
- package/src/plugins/drawing/DrawingToolRegistry.ts +684 -0
- package/src/plugins/drawing/TextLabelOverlay.ts +101 -0
- package/src/plugins/drawing/colorUtils.ts +53 -0
- package/src/plugins/drawing/index.ts +10 -0
- package/src/plugins/drawing/lineStyleMap.ts +46 -0
- package/src/plugins/drawing/migration.ts +105 -0
- package/src/plugins/drawing/patterns/PatternDefinitions.ts +57 -0
- package/src/plugins/drawing/patterns/index.ts +2 -0
- package/src/plugins/drawing/periodUtils.ts +51 -0
- package/src/plugins/indicators/AutoIndicatorRenderer.ts +204 -0
- package/src/plugins/indicators/IndicatorRenderer.ts +350 -0
- package/src/plugins/indicators/OverlayIndicator.ts +114 -0
- package/src/plugins/indicators/PaneIndicator.ts +137 -0
- package/src/plugins/indicators/index.ts +4 -0
- package/src/plugins/replay/KlineReplay.ts +163 -0
- package/src/plugins/replay/index.ts +2 -0
- package/src/plugins/screenshot/ScreenshotUtil.ts +123 -0
- package/src/plugins/screenshot/index.ts +1 -0
- package/src/plugins/series/HollowCandlestickSeries.ts +111 -0
- package/src/plugins/series/RenkoSeries.ts +104 -0
- package/src/plugins/series/VolumeCandlestickSeries.ts +127 -0
- package/src/plugins/series/index.ts +6 -0
- package/src/standalone.ts +386 -0
- package/src/store/useChartStore.ts +101 -0
- package/src/store/useDrawingStore.ts +135 -0
- package/src/store/useIndicatorStore.ts +100 -0
- package/src/store/usePanelRegistry.ts +50 -0
- package/src/store/useReplayStore.ts +42 -0
- package/src/store/useTimeBarStore.ts +34 -0
- package/src/styles/chart.css +312 -0
- package/src/styles/components.css +184 -0
- package/src/styles/context-menu.css +60 -0
- package/src/styles/drawing.css +524 -0
- package/src/styles/indicator-modal.css +216 -0
- package/src/styles/indicator-tag.css +210 -0
- package/src/styles/pane-chart.css +9 -0
- package/src/styles/responsive.css +71 -0
- package/src/styles/themes/dark.css +63 -0
- package/src/styles/themes/light.css +61 -0
- package/src/styles/toolbar.css +129 -0
- package/src/types/api.ts +36 -0
- package/src/types/chart.ts +44 -0
- package/src/types/drawing.ts +265 -0
- package/src/types/index.ts +40 -0
- package/src/types/indicator.ts +344 -0
- package/src/types/series.ts +53 -0
- package/src/types/theme.ts +48 -0
- package/src/utils/dataTransformer.ts +63 -0
- package/src/utils/heikinAshi.ts +41 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/lineBreak.ts +88 -0
- package/src/utils/themePresets.ts +69 -0
- package/src/utils/timeFormatter.ts +29 -0
- package/src/utils/timeScaleUtils.ts +68 -0
- package/tsconfig.json +21 -0
- package/typedoc.json +10 -0
- package/vite.config.standalone.ts +31 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,1762 @@
|
|
|
1
|
+
import type { IChartApi, ISeriesApi, SeriesType as LWCSeriesType, CandlestickData, MouseEventParams, Time, HandleScrollOptions, HandleScaleOptions, Coordinate } from 'lightweight-charts';
|
|
2
|
+
import {
|
|
3
|
+
DrawingManager,
|
|
4
|
+
getToolRegistry,
|
|
5
|
+
type IDrawing,
|
|
6
|
+
type DrawingEventType,
|
|
7
|
+
type DrawingEventCallback,
|
|
8
|
+
type SerializedDrawing,
|
|
9
|
+
type DrawingStyle,
|
|
10
|
+
type DrawingOptions,
|
|
11
|
+
type Anchor,
|
|
12
|
+
} from 'lightweight-charts-drawing';
|
|
13
|
+
import type { DrawingToolType, DrawingStyleConfig, DrawingPersistData } from '../../types/index.js';
|
|
14
|
+
import { mapToolTypeToLib, mapLibTypeToOurs, getToolConfig } from './DrawingToolRegistry.js';
|
|
15
|
+
import { TextLabelOverlay } from './TextLabelOverlay.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 绘图创建状态
|
|
19
|
+
*/
|
|
20
|
+
interface CreationState {
|
|
21
|
+
phase: 'idle' | 'creating';
|
|
22
|
+
pendingDrawing: IDrawing | null;
|
|
23
|
+
anchors: Anchor[];
|
|
24
|
+
requiredCount: number;
|
|
25
|
+
activeLibTool: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 整体拖拽状态
|
|
30
|
+
*/
|
|
31
|
+
interface BodyDragState {
|
|
32
|
+
phase: 'idle' | 'dragging';
|
|
33
|
+
drawingId: string | null;
|
|
34
|
+
/** 拖拽起始鼠标位置(相对于container的像素坐标) */
|
|
35
|
+
startPixel: { x: number; y: number } | null;
|
|
36
|
+
/** 拖拽起始时所有锚点的原始位置(time/price) */
|
|
37
|
+
startAnchors: Anchor[] | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* DrawingAdapter - 绘图适配器
|
|
42
|
+
* 作为 QLChart 和 lightweight-charts-drawing DrawingManager 的桥接层
|
|
43
|
+
*
|
|
44
|
+
* Core/Plugin 层不依赖 React
|
|
45
|
+
*/
|
|
46
|
+
export class DrawingAdapter {
|
|
47
|
+
private manager: DrawingManager | null = null;
|
|
48
|
+
private chart: IChartApi | null = null;
|
|
49
|
+
private series: ISeriesApi<LWCSeriesType> | null = null;
|
|
50
|
+
private container: HTMLElement | null = null;
|
|
51
|
+
private unsubscribers: Array<() => void> = [];
|
|
52
|
+
private chartId = '';
|
|
53
|
+
|
|
54
|
+
// ── 绘图创建状态 ──
|
|
55
|
+
private creationState: CreationState = {
|
|
56
|
+
phase: 'idle',
|
|
57
|
+
pendingDrawing: null,
|
|
58
|
+
anchors: [],
|
|
59
|
+
requiredCount: 0,
|
|
60
|
+
activeLibTool: null,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ── 创建模式事件处理器引用(用于取消订阅) ──
|
|
64
|
+
private chartClickHandler: ((param: MouseEventParams) => void) | null = null;
|
|
65
|
+
private chartDblClickHandler: ((param: MouseEventParams) => void) | null = null;
|
|
66
|
+
private containerMouseMoveHandler: ((e: MouseEvent) => void) | null = null;
|
|
67
|
+
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
68
|
+
|
|
69
|
+
// ── 默认绘图样式 ──
|
|
70
|
+
private defaultDrawingStyle: Partial<DrawingStyle> = {
|
|
71
|
+
lineColor: '#2962ff',
|
|
72
|
+
lineWidth: 2,
|
|
73
|
+
lineDash: [],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ── 整体拖拽状态 ──
|
|
77
|
+
private bodyDragState: BodyDragState = {
|
|
78
|
+
phase: 'idle',
|
|
79
|
+
drawingId: null,
|
|
80
|
+
startPixel: null,
|
|
81
|
+
startAnchors: null,
|
|
82
|
+
};
|
|
83
|
+
private bodyDragStartHandler: ((e: MouseEvent) => void) | null = null;
|
|
84
|
+
private bodyDragMoveHandler: ((e: MouseEvent) => void) | null = null;
|
|
85
|
+
private bodyDragEndHandler: ((e: MouseEvent) => void) | null = null;
|
|
86
|
+
|
|
87
|
+
// ★ D10: 画笔自由绘制
|
|
88
|
+
private freeDrawStartHandler: ((e: MouseEvent) => void) | null = null;
|
|
89
|
+
private freeDrawMoveHandler: ((e: MouseEvent) => void) | null = null;
|
|
90
|
+
private freeDrawEndHandler: ((e: MouseEvent) => void) | null = null;
|
|
91
|
+
private freeDrawActive = false;
|
|
92
|
+
private freeDrawRafId: number | null = null;
|
|
93
|
+
private freeDrawPendingEvent: MouseEvent | null = null;
|
|
94
|
+
|
|
95
|
+
// ── rAF 节流 ──
|
|
96
|
+
private rafId: number | null = null;
|
|
97
|
+
private pendingDragEvent: MouseEvent | null = null;
|
|
98
|
+
|
|
99
|
+
// Magnet snap
|
|
100
|
+
private magnetEnabled = false;
|
|
101
|
+
private candleData: CandlestickData[] = [];
|
|
102
|
+
|
|
103
|
+
// Bug2修复:价格范围缓存,避免每次mousemove遍历全量数据
|
|
104
|
+
private priceRangeCache: { minLow: number; maxHigh: number } | null = null;
|
|
105
|
+
|
|
106
|
+
// Bug2修复:保存原始工具类型(pattern-* 需要特殊处理)
|
|
107
|
+
private activeToolType: DrawingToolType | null = null;
|
|
108
|
+
|
|
109
|
+
// Bug1修复:当前图表周期
|
|
110
|
+
private currentPeriod: string = '1h';
|
|
111
|
+
|
|
112
|
+
// Bug4修复:文字标签 overlay
|
|
113
|
+
private textLabelOverlay: TextLabelOverlay | null = null;
|
|
114
|
+
private textInputEditor: HTMLInputElement | null = null;
|
|
115
|
+
|
|
116
|
+
// ★ V2: 副图绘图支持
|
|
117
|
+
/** 当前绘图目标 pane index(0 = 主图) */
|
|
118
|
+
private currentPaneIndex: number = 0;
|
|
119
|
+
/** paneIndex → 该 pane 的绘图数据(绘图隔离) */
|
|
120
|
+
private paneDrawingsMap = new Map<number, DrawingPersistData[]>();
|
|
121
|
+
/** pane 切换请求回调(通知外部获取目标 pane 的 series) */
|
|
122
|
+
private onPaneSwitchNeededCb?: (paneIndex: number) => void;
|
|
123
|
+
/** ★ V5 需求1:paneIndex → series 映射(内部维护,不再依赖外部回查) */
|
|
124
|
+
private paneSeriesInternalMap = new Map<number, ISeriesApi<LWCSeriesType>>();
|
|
125
|
+
|
|
126
|
+
// 保存 attach 时 chart 的原始 scroll/scale 配置(从顶层读取)
|
|
127
|
+
private savedScrollOptions: HandleScrollOptions | null = null;
|
|
128
|
+
private savedScaleOptions: HandleScaleOptions | null = null;
|
|
129
|
+
|
|
130
|
+
// Event callbacks
|
|
131
|
+
private onDrawingCreatedCb?: (drawing: IDrawing) => void;
|
|
132
|
+
private onDrawingSelectedCb?: (drawing: IDrawing | null) => void;
|
|
133
|
+
private onDrawingModifiedCb?: (drawing: IDrawing) => void;
|
|
134
|
+
private onDrawingRemovedCb?: (id: string) => void;
|
|
135
|
+
private onToolChangedCb?: (tool: string | null) => void;
|
|
136
|
+
// Bug2修复:双击回调
|
|
137
|
+
private onDrawingDoubleClickCb?: (drawing: IDrawing | null) => void;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 绑定 DrawingManager 到 chart/series/container
|
|
141
|
+
*/
|
|
142
|
+
attach(
|
|
143
|
+
chart: IChartApi,
|
|
144
|
+
series: ISeriesApi<LWCSeriesType>,
|
|
145
|
+
container: HTMLElement,
|
|
146
|
+
chartId?: string,
|
|
147
|
+
): void {
|
|
148
|
+
if (this.manager) {
|
|
149
|
+
this.detach();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.chart = chart;
|
|
153
|
+
this.series = series;
|
|
154
|
+
this.container = container;
|
|
155
|
+
this.chartId = chartId ?? '';
|
|
156
|
+
|
|
157
|
+
this.manager = new DrawingManager();
|
|
158
|
+
this.manager.attach(chart, series as any, container);
|
|
159
|
+
this.bindEvents();
|
|
160
|
+
|
|
161
|
+
// ── 注册绘图创建模式事件 ──
|
|
162
|
+
this.chartClickHandler = (param) => this.handleChartClick(param);
|
|
163
|
+
chart.subscribeClick(this.chartClickHandler);
|
|
164
|
+
|
|
165
|
+
this.chartDblClickHandler = (param) => this.handleChartDblClick(param);
|
|
166
|
+
chart.subscribeDblClick(this.chartDblClickHandler);
|
|
167
|
+
|
|
168
|
+
this.containerMouseMoveHandler = (e) => this.handleContainerMouseMove(e);
|
|
169
|
+
container.addEventListener('mousemove', this.containerMouseMoveHandler);
|
|
170
|
+
|
|
171
|
+
this.keydownHandler = (e) => this.handleKeydown(e);
|
|
172
|
+
document.addEventListener('keydown', this.keydownHandler);
|
|
173
|
+
|
|
174
|
+
// Bug①修复:绑定拖拽交互——选中绘图时锁定主图pan
|
|
175
|
+
// 深拷贝 chart 原始 scroll/scale 配置(避免引用污染:applyOptions 会就地修改内部对象)
|
|
176
|
+
const chartOpts = this.chart.options();
|
|
177
|
+
const scrollSrc = chartOpts.handleScroll as HandleScrollOptions;
|
|
178
|
+
this.savedScrollOptions = {
|
|
179
|
+
pressedMouseMove: scrollSrc.pressedMouseMove,
|
|
180
|
+
mouseWheel: scrollSrc.mouseWheel,
|
|
181
|
+
horzTouchDrag: scrollSrc.horzTouchDrag,
|
|
182
|
+
vertTouchDrag: scrollSrc.vertTouchDrag,
|
|
183
|
+
};
|
|
184
|
+
const scaleOpts = chartOpts.handleScale as HandleScaleOptions;
|
|
185
|
+
// 处理 axisPressedMouseMove / axisDoubleClickReset 可能是 boolean 简写的情况
|
|
186
|
+
const apmm = scaleOpts.axisPressedMouseMove;
|
|
187
|
+
const adcr = scaleOpts.axisDoubleClickReset;
|
|
188
|
+
this.savedScaleOptions = {
|
|
189
|
+
mouseWheel: scaleOpts.mouseWheel,
|
|
190
|
+
pinch: scaleOpts.pinch,
|
|
191
|
+
axisPressedMouseMove: typeof apmm === 'boolean'
|
|
192
|
+
? { time: apmm, price: apmm }
|
|
193
|
+
: { time: apmm.time, price: apmm.price },
|
|
194
|
+
axisDoubleClickReset: typeof adcr === 'boolean'
|
|
195
|
+
? { time: adcr, price: adcr }
|
|
196
|
+
: { time: adcr.time, price: adcr.price },
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
this.bindDragInteraction();
|
|
200
|
+
|
|
201
|
+
// ★ 注册整体拖拽拦截器(capture 阶段,先于库的 mousedown)
|
|
202
|
+
this.bodyDragStartHandler = (e: MouseEvent) => this.handleBodyDragStart(e);
|
|
203
|
+
container.addEventListener('mousedown', this.bodyDragStartHandler, true);
|
|
204
|
+
|
|
205
|
+
// ★ D10: 注册画笔自由绘制拦截器(capture 阶段)
|
|
206
|
+
this.freeDrawStartHandler = (e: MouseEvent) => this.handleFreeDrawStart(e);
|
|
207
|
+
container.addEventListener('mousedown', this.freeDrawStartHandler, true);
|
|
208
|
+
|
|
209
|
+
// Bug4修复:初始化文字标签 overlay
|
|
210
|
+
this.textLabelOverlay = new TextLabelOverlay();
|
|
211
|
+
this.textLabelOverlay.attach(container);
|
|
212
|
+
|
|
213
|
+
// Bug4修复:订阅图表滚动/缩放事件,更新文字标签位置
|
|
214
|
+
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
|
|
215
|
+
this.updateTextLabels();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
console.log('[DrawingAdapter] attached successfully', {
|
|
219
|
+
chartId: this.chartId,
|
|
220
|
+
hasManager: !!this.manager,
|
|
221
|
+
managerAttached: this.manager?.isAttached() ?? false,
|
|
222
|
+
hasBodyDragHandler: !!this.bodyDragStartHandler,
|
|
223
|
+
hasContainer: !!this.container,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 解绑并销毁 DrawingManager
|
|
229
|
+
*/
|
|
230
|
+
detach(): void {
|
|
231
|
+
// ★ 清理整体拖拽事件
|
|
232
|
+
if (this.container && this.bodyDragStartHandler) {
|
|
233
|
+
this.container.removeEventListener('mousedown', this.bodyDragStartHandler, true);
|
|
234
|
+
}
|
|
235
|
+
// ★ D10: 清理画笔自由绘制事件
|
|
236
|
+
if (this.container && this.freeDrawStartHandler) {
|
|
237
|
+
this.container.removeEventListener('mousedown', this.freeDrawStartHandler, true);
|
|
238
|
+
}
|
|
239
|
+
if (this.freeDrawMoveHandler) {
|
|
240
|
+
document.removeEventListener('mousemove', this.freeDrawMoveHandler);
|
|
241
|
+
}
|
|
242
|
+
if (this.freeDrawEndHandler) {
|
|
243
|
+
document.removeEventListener('mouseup', this.freeDrawEndHandler);
|
|
244
|
+
}
|
|
245
|
+
if (this.bodyDragMoveHandler) {
|
|
246
|
+
document.removeEventListener('mousemove', this.bodyDragMoveHandler);
|
|
247
|
+
}
|
|
248
|
+
if (this.bodyDragEndHandler) {
|
|
249
|
+
document.removeEventListener('mouseup', this.bodyDragEndHandler);
|
|
250
|
+
}
|
|
251
|
+
this.bodyDragState = { phase: 'idle', drawingId: null, startPixel: null, startAnchors: null };
|
|
252
|
+
this.bodyDragStartHandler = null;
|
|
253
|
+
this.bodyDragMoveHandler = null;
|
|
254
|
+
this.bodyDragEndHandler = null;
|
|
255
|
+
|
|
256
|
+
// ★ 清理 rAF
|
|
257
|
+
if (this.rafId !== null) {
|
|
258
|
+
cancelAnimationFrame(this.rafId);
|
|
259
|
+
this.rafId = null;
|
|
260
|
+
}
|
|
261
|
+
this.pendingDragEvent = null;
|
|
262
|
+
|
|
263
|
+
// ── 注销绘图创建模式事件 ──
|
|
264
|
+
if (this.chart && this.chartClickHandler) {
|
|
265
|
+
this.chart.unsubscribeClick(this.chartClickHandler);
|
|
266
|
+
}
|
|
267
|
+
if (this.chart && this.chartDblClickHandler) {
|
|
268
|
+
this.chart.unsubscribeDblClick(this.chartDblClickHandler);
|
|
269
|
+
}
|
|
270
|
+
if (this.container && this.containerMouseMoveHandler) {
|
|
271
|
+
this.container.removeEventListener('mousemove', this.containerMouseMoveHandler);
|
|
272
|
+
}
|
|
273
|
+
if (this.keydownHandler) {
|
|
274
|
+
document.removeEventListener('keydown', this.keydownHandler);
|
|
275
|
+
}
|
|
276
|
+
this.cancelCreation();
|
|
277
|
+
// Bug4修复:清理文字编辑器和 overlay
|
|
278
|
+
this.hideTextInputEditor();
|
|
279
|
+
this.textLabelOverlay?.detach();
|
|
280
|
+
this.textLabelOverlay = null;
|
|
281
|
+
this.chartClickHandler = null;
|
|
282
|
+
this.chartDblClickHandler = null;
|
|
283
|
+
this.containerMouseMoveHandler = null;
|
|
284
|
+
this.keydownHandler = null;
|
|
285
|
+
|
|
286
|
+
this.unbindEvents();
|
|
287
|
+
if (this.manager) {
|
|
288
|
+
this.manager.detach();
|
|
289
|
+
this.manager = null;
|
|
290
|
+
}
|
|
291
|
+
this.chart = null;
|
|
292
|
+
this.series = null;
|
|
293
|
+
this.container = null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 是否已绑定 */
|
|
297
|
+
isAttached(): boolean {
|
|
298
|
+
return this.manager?.isAttached() ?? false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ★ F3: 检查 adapter 是否已完全 attach(manager + container 事件监听)
|
|
302
|
+
isFullyAttached(): boolean {
|
|
303
|
+
return !!(this.manager?.isAttached() && this.bodyDragStartHandler && this.container);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ★ F3: 获取 attach 诊断信息
|
|
307
|
+
getAttachDiagnostics(): Record<string, unknown> {
|
|
308
|
+
return {
|
|
309
|
+
hasManager: !!this.manager,
|
|
310
|
+
managerAttached: this.manager?.isAttached() ?? false,
|
|
311
|
+
hasChart: !!this.chart,
|
|
312
|
+
hasSeries: !!this.series,
|
|
313
|
+
hasContainer: !!this.container,
|
|
314
|
+
hasBodyDragHandler: !!this.bodyDragStartHandler,
|
|
315
|
+
chartId: this.chartId,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 设置当前绘图工具
|
|
321
|
+
* 'cursor' = 取消绘图模式
|
|
322
|
+
*/
|
|
323
|
+
setActiveTool(toolType: DrawingToolType): void {
|
|
324
|
+
if (!this.manager) return;
|
|
325
|
+
|
|
326
|
+
// 如果正在创建,先取消
|
|
327
|
+
if (this.creationState.phase === 'creating') {
|
|
328
|
+
this.cancelCreation();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Bug2修复:保存原始工具类型
|
|
332
|
+
this.activeToolType = toolType;
|
|
333
|
+
|
|
334
|
+
const libType = mapToolTypeToLib(toolType);
|
|
335
|
+
this.manager.setActiveTool(libType);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 获取当前工具类型 */
|
|
339
|
+
getActiveTool(): string | null {
|
|
340
|
+
return this.manager?.getActiveTool() ?? null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Bug1修复:设置当前图表周期
|
|
344
|
+
setPeriod(period: string): void {
|
|
345
|
+
this.currentPeriod = period;
|
|
346
|
+
this.refreshMeasureLabels();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 获取当前周期 */
|
|
350
|
+
getCurrentPeriod(): string {
|
|
351
|
+
return this.currentPeriod;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Bug1修复:刷新测量器标签 — patch 所有 date-price-range 绘图
|
|
356
|
+
*/
|
|
357
|
+
private refreshMeasureLabels(): void {
|
|
358
|
+
if (!this.manager) return;
|
|
359
|
+
const drawings = this.manager.getAllDrawings();
|
|
360
|
+
for (const d of drawings) {
|
|
361
|
+
if (d.type === 'date-price-range') {
|
|
362
|
+
this.patchMeasureDrawing(d);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
366
|
+
(this.manager as any).requestUpdate?.();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Bug1修复:Patch 测量器绘图,覆盖 getMeasureInfo 计算正确的 bars
|
|
371
|
+
*/
|
|
372
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
373
|
+
private patchMeasureDrawing(drawing: IDrawing): void {
|
|
374
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
375
|
+
if ((drawing as any)._measurePatched) {
|
|
376
|
+
// ★ Bug1修复:已 patch 过的只更新 _measurePeriod 值,然后触发重绘
|
|
377
|
+
// getMeasureInfo 的闭包会读取最新的 _measurePeriod,所以更新值后重绘即可
|
|
378
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
379
|
+
(drawing as any)._measurePeriod = this.currentPeriod;
|
|
380
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
381
|
+
(this.manager as any).requestUpdate?.();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
385
|
+
const origGetMeasureInfo = (drawing as any).getMeasureInfo?.bind(drawing);
|
|
386
|
+
if (!origGetMeasureInfo) return;
|
|
387
|
+
|
|
388
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
389
|
+
(drawing as any)._measurePeriod = this.currentPeriod;
|
|
390
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
391
|
+
(drawing as any).getMeasureInfo = () => {
|
|
392
|
+
const info = origGetMeasureInfo();
|
|
393
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
394
|
+
const period = (drawing as any)._measurePeriod ?? '1h';
|
|
395
|
+
const periodSec = this.periodToSeconds(period);
|
|
396
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
397
|
+
const anchors = (drawing as any).anchors ?? [];
|
|
398
|
+
if (anchors.length >= 2) {
|
|
399
|
+
const t0 = anchors[0].time;
|
|
400
|
+
const t1 = anchors[1].time;
|
|
401
|
+
const sec0 = typeof t0 === 'number' ? t0 : new Date(t0).getTime() / 1000;
|
|
402
|
+
const sec1 = typeof t1 === 'number' ? t1 : new Date(t1).getTime() / 1000;
|
|
403
|
+
const totalSeconds = Math.abs(sec1 - sec0);
|
|
404
|
+
info.bars = Math.max(1, Math.round(totalSeconds / periodSec));
|
|
405
|
+
}
|
|
406
|
+
return info;
|
|
407
|
+
};
|
|
408
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
409
|
+
(drawing as any)._measurePatched = true;
|
|
410
|
+
|
|
411
|
+
// 恢复库默认显示
|
|
412
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
413
|
+
(drawing as any).setMeasureOptions?.({
|
|
414
|
+
showBars: true,
|
|
415
|
+
showDays: true,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Bug1修复:将周期字符串转换为秒数
|
|
421
|
+
*/
|
|
422
|
+
private periodToSeconds(period: string): number {
|
|
423
|
+
const match = period.match(/^(\d+)([mhdwM])$/);
|
|
424
|
+
if (!match) return 3600;
|
|
425
|
+
const n = parseInt(match[1], 10);
|
|
426
|
+
switch (match[2]) {
|
|
427
|
+
case 'm': return n * 60;
|
|
428
|
+
case 'h': return n * 3600;
|
|
429
|
+
case 'd': return n * 86400;
|
|
430
|
+
case 'w': return n * 604800;
|
|
431
|
+
case 'M': return n * 2592000;
|
|
432
|
+
default: return 3600;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Bug1修复:公开方法 — patch 测量器绘图(供外部调用)
|
|
438
|
+
*/
|
|
439
|
+
patchMeasureDrawingPublic(drawing: IDrawing): void {
|
|
440
|
+
if (drawing.type === 'date-price-range') {
|
|
441
|
+
this.patchMeasureDrawing(drawing);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 获取所有绘图实例 */
|
|
446
|
+
getAllDrawings(): IDrawing[] {
|
|
447
|
+
return this.manager?.getAllDrawings() ?? [];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ★ D8: 批量操作
|
|
451
|
+
/** 设置所有绘图的可见性 */
|
|
452
|
+
setAllVisible(visible: boolean): void {
|
|
453
|
+
this.manager?.getAllDrawings().forEach(d => {
|
|
454
|
+
d.updateOptions({ visible } as any);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 获取所有绘图是否可见 */
|
|
459
|
+
getAllVisible(): boolean {
|
|
460
|
+
const drawings = this.manager?.getAllDrawings() ?? [];
|
|
461
|
+
return drawings.length > 0 && drawings.every(d => (d.options as any).visible !== false);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 选中某个绘图 */
|
|
465
|
+
selectDrawing(id: string): void {
|
|
466
|
+
this.manager?.selectDrawing(id);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 取消选中 */
|
|
470
|
+
deselectAll(): void {
|
|
471
|
+
this.manager?.deselectAll();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 获取当前选中的绘图 */
|
|
475
|
+
getSelectedDrawing(): IDrawing | null {
|
|
476
|
+
return this.manager?.getSelectedDrawing() ?? null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** 删除指定绘图 */
|
|
480
|
+
removeDrawing(id: string): void {
|
|
481
|
+
this.manager?.removeDrawing(id);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** 删除当前选中的绘图 */
|
|
485
|
+
removeSelected(): void {
|
|
486
|
+
const selected = this.getSelectedDrawing();
|
|
487
|
+
if (selected) {
|
|
488
|
+
this.removeDrawing(selected.id);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** 清空所有绘图 */
|
|
493
|
+
clearAll(): void {
|
|
494
|
+
this.manager?.clearAll();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 导出绘图数据(用于持久化)
|
|
499
|
+
*/
|
|
500
|
+
exportDrawings(): DrawingPersistData[] {
|
|
501
|
+
if (!this.manager) return [];
|
|
502
|
+
const serialized = this.manager.exportDrawings();
|
|
503
|
+
return serialized.map((s) => ({
|
|
504
|
+
id: s.id,
|
|
505
|
+
chartId: this.chartId,
|
|
506
|
+
type: s.type,
|
|
507
|
+
anchors: s.anchors.map((a: { time: unknown; price: number }) => {
|
|
508
|
+
const t = typeof a.time === 'number' ? a.time : 0;
|
|
509
|
+
return { time: t, price: a.price };
|
|
510
|
+
}),
|
|
511
|
+
style: {
|
|
512
|
+
lineColor: s.style.lineColor,
|
|
513
|
+
lineWidth: s.style.lineWidth,
|
|
514
|
+
lineDash: s.style.lineDash,
|
|
515
|
+
fillColor: s.style.fillColor,
|
|
516
|
+
fillOpacity: s.style.fillOpacity,
|
|
517
|
+
showLabels: s.style.showLabels,
|
|
518
|
+
labelFont: s.style.labelFont,
|
|
519
|
+
labelColor: s.style.labelColor,
|
|
520
|
+
},
|
|
521
|
+
options: {
|
|
522
|
+
...s.options,
|
|
523
|
+
// Bug4修复:文字标签持久化
|
|
524
|
+
labelText: this.textLabelOverlay?.getLabel(s.id) ?? '',
|
|
525
|
+
// ★ 多周期共享修复:保存原始锚点(export时的精确时间)
|
|
526
|
+
originalAnchors: s.anchors.map((a: { time: unknown; price: number }) => {
|
|
527
|
+
const t = typeof a.time === 'number' ? a.time : 0;
|
|
528
|
+
return { time: t, price: a.price };
|
|
529
|
+
}),
|
|
530
|
+
},
|
|
531
|
+
createdAt: Date.now(),
|
|
532
|
+
updatedAt: Date.now(),
|
|
533
|
+
// ★ V2: 添加 paneIndex
|
|
534
|
+
paneIndex: this.currentPaneIndex,
|
|
535
|
+
}));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 导入绘图数据(从后端加载恢复)
|
|
540
|
+
*/
|
|
541
|
+
importDrawings(data: DrawingPersistData[]): void {
|
|
542
|
+
if (!this.manager) return;
|
|
543
|
+
|
|
544
|
+
// ★ V3修复:导入前清除现有绘图(防止library追加模式导致重复)
|
|
545
|
+
this.manager.clearAll();
|
|
546
|
+
|
|
547
|
+
const serialized: SerializedDrawing[] = data.map((d) => {
|
|
548
|
+
// ★ 多周期共享修复:优先使用originalAnchors(精确时间),再对齐到当前周期
|
|
549
|
+
const originalAnchors = (d.options as any)?.originalAnchors as Array<{ time: number; price: number }> | undefined;
|
|
550
|
+
const sourceAnchors = originalAnchors ?? d.anchors;
|
|
551
|
+
const alignedAnchors = this.alignAnchorsToPeriod(
|
|
552
|
+
sourceAnchors.map(a => ({ time: a.time, price: a.price })),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
id: d.id,
|
|
557
|
+
type: d.type,
|
|
558
|
+
anchors: alignedAnchors.map(a => ({
|
|
559
|
+
time: a.time as any,
|
|
560
|
+
price: a.price,
|
|
561
|
+
})),
|
|
562
|
+
style: d.style as DrawingStyle,
|
|
563
|
+
options: d.options as DrawingOptions,
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// factory: 使用 ToolRegistry 创建对应类型的绘图实例
|
|
568
|
+
const registry = getToolRegistry();
|
|
569
|
+
this.manager.importDrawings(serialized, (type, s) => {
|
|
570
|
+
// Bug4修复:恢复文字标签
|
|
571
|
+
const labelText = (s.options as any)?.labelText as string | undefined;
|
|
572
|
+
if (labelText && this.textLabelOverlay && this.container) {
|
|
573
|
+
// 延迟设置位置,等绘图渲染后通过 updatePositions 更新
|
|
574
|
+
// 先设置一个占位位置,后续在 chart 更新时修正
|
|
575
|
+
this.textLabelOverlay.setLabel(s.id, labelText, 0, 0);
|
|
576
|
+
}
|
|
577
|
+
return registry.createDrawing(type, s.id, s.anchors as any[], s.style, s.options);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Bug1修复:patch 所有导入的测量工具
|
|
581
|
+
for (const d of this.manager!.getAllDrawings()) {
|
|
582
|
+
if (d.type === 'date-price-range') {
|
|
583
|
+
this.patchMeasureDrawing(d);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 更新主序列引用(数据重载后调用)
|
|
590
|
+
*/
|
|
591
|
+
updateSeries(series: ISeriesApi<LWCSeriesType>): void {
|
|
592
|
+
// 如果series引用没变且已完全attach,无需更新
|
|
593
|
+
if (this.series === series && this.isFullyAttached()) return;
|
|
594
|
+
this.series = series;
|
|
595
|
+
// 如果DrawingManager已attach,需要重新绑定以更新内部series引用
|
|
596
|
+
if (this.manager && this.chart && this.container) {
|
|
597
|
+
const currentTool = this.manager.getActiveTool();
|
|
598
|
+
// 导出当前绘图以防重新attach后丢失
|
|
599
|
+
const savedDrawings = this.manager.exportDrawings();
|
|
600
|
+
this.manager.detach();
|
|
601
|
+
this.manager.attach(this.chart, series as any, this.container);
|
|
602
|
+
// 恢复绘图
|
|
603
|
+
if (savedDrawings.length > 0) {
|
|
604
|
+
// ★ Bug2修复:使用 this.importDrawings 而非 this.manager.importDrawings
|
|
605
|
+
// this.importDrawings 内部会 clearAll + alignAnchorsToPeriod
|
|
606
|
+
this.importDrawings(savedDrawings.map(s => ({
|
|
607
|
+
id: s.id,
|
|
608
|
+
chartId: this.chartId,
|
|
609
|
+
type: s.type,
|
|
610
|
+
anchors: s.anchors.map(a => ({
|
|
611
|
+
time: typeof a.time === 'number' ? a.time : 0,
|
|
612
|
+
price: a.price,
|
|
613
|
+
})),
|
|
614
|
+
style: s.style as any,
|
|
615
|
+
options: s.options as any,
|
|
616
|
+
createdAt: Date.now(),
|
|
617
|
+
updatedAt: Date.now(),
|
|
618
|
+
})));
|
|
619
|
+
}
|
|
620
|
+
if (currentTool) {
|
|
621
|
+
this.manager.setActiveTool(currentTool);
|
|
622
|
+
}
|
|
623
|
+
} else if (this.chart && this.container) {
|
|
624
|
+
// ★ F4: 未 attach 时执行完整 attach(最后防线)
|
|
625
|
+
console.warn('[DrawingAdapter] updateSeries called before attach, performing full attach');
|
|
626
|
+
this.attach(this.chart, series, this.container, this.chartId);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* ★ Bug5修复:切换绘图目标series(用于在副图中绘图)
|
|
632
|
+
*/
|
|
633
|
+
setTargetSeries(series: ISeriesApi<LWCSeriesType>): void {
|
|
634
|
+
if (this.series === series) return;
|
|
635
|
+
this.updateSeries(series);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** 设置K线数据(用于磁铁吸附计算) */
|
|
639
|
+
setCandleData(data: CandlestickData[]): void {
|
|
640
|
+
this.candleData = data;
|
|
641
|
+
this.priceRangeCache = null; // ★ Bug2修复:数据变化时清除缓存
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* ★ 多周期共享修复:二分查找最接近targetTime的K线时间戳
|
|
646
|
+
* candleData按time升序排列(由transformCandlestickData保证)
|
|
647
|
+
* 时间复杂度: O(log n)
|
|
648
|
+
*/
|
|
649
|
+
private findNearestCandleTime(targetTime: number): number {
|
|
650
|
+
if (this.candleData.length === 0) return targetTime;
|
|
651
|
+
|
|
652
|
+
const data = this.candleData;
|
|
653
|
+
let lo = 0, hi = data.length - 1;
|
|
654
|
+
|
|
655
|
+
// 边界检查
|
|
656
|
+
const firstTime = data[0].time as number;
|
|
657
|
+
const lastTime = data[hi].time as number;
|
|
658
|
+
if (targetTime <= firstTime) return firstTime;
|
|
659
|
+
if (targetTime >= lastTime) return lastTime;
|
|
660
|
+
|
|
661
|
+
// 二分查找:找到第一个 >= targetTime 的位置
|
|
662
|
+
while (lo < hi) {
|
|
663
|
+
const mid = (lo + hi) >> 1;
|
|
664
|
+
const midTime = data[mid].time as number;
|
|
665
|
+
if (midTime === targetTime) return midTime;
|
|
666
|
+
if (midTime < targetTime) lo = mid + 1;
|
|
667
|
+
else hi = mid;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// lo指向第一个 >= targetTime 的位置,比较 lo 和 lo-1 哪个更近
|
|
671
|
+
const after = data[lo].time as number;
|
|
672
|
+
const before = data[lo - 1].time as number;
|
|
673
|
+
return (after - targetTime) < (targetTime - before) ? after : before;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* ★ 多周期共享修复:将锚点时间对齐到当前周期的K线时间戳
|
|
678
|
+
* 用于跨周期共享时解决时间戳不匹配问题
|
|
679
|
+
*/
|
|
680
|
+
private alignAnchorsToPeriod(
|
|
681
|
+
anchors: Array<{ time: number; price: number }>,
|
|
682
|
+
): Array<{ time: number; price: number }> {
|
|
683
|
+
if (!this.candleData || this.candleData.length === 0) return anchors;
|
|
684
|
+
|
|
685
|
+
return anchors.map(anchor => ({
|
|
686
|
+
time: this.findNearestCandleTime(anchor.time),
|
|
687
|
+
price: anchor.price, // price不需要对齐(所有周期通用)
|
|
688
|
+
}));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** 设置磁铁吸附开关 */
|
|
692
|
+
setMagnetEnabled(enabled: boolean): void {
|
|
693
|
+
this.magnetEnabled = enabled;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* 获取鼠标位置最近的K线价格(磁铁吸附)
|
|
698
|
+
*/
|
|
699
|
+
getNearestPrice(clientX: number, clientY: number): { time: number; price: number } | null {
|
|
700
|
+
if (!this.chart || !this.candleData.length) return null;
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
const timeScale = this.chart.timeScale();
|
|
704
|
+
const logicalTime = timeScale.coordinateToTime(clientX);
|
|
705
|
+
if (logicalTime === null) return null;
|
|
706
|
+
|
|
707
|
+
const timeNumber = logicalTime as number;
|
|
708
|
+
let nearest = this.candleData[0];
|
|
709
|
+
let minDist = Math.abs((nearest.time as number) - timeNumber);
|
|
710
|
+
|
|
711
|
+
for (const candle of this.candleData) {
|
|
712
|
+
const dist = Math.abs((candle.time as number) - timeNumber);
|
|
713
|
+
if (dist < minDist) {
|
|
714
|
+
minDist = dist;
|
|
715
|
+
nearest = candle;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
time: nearest.time as number,
|
|
721
|
+
price: nearest.close,
|
|
722
|
+
};
|
|
723
|
+
} catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** 设置事件回调 */
|
|
729
|
+
onDrawingCreated(cb: (drawing: IDrawing) => void): void {
|
|
730
|
+
this.onDrawingCreatedCb = cb;
|
|
731
|
+
}
|
|
732
|
+
onDrawingSelected(cb: (drawing: IDrawing | null) => void): void {
|
|
733
|
+
this.onDrawingSelectedCb = cb;
|
|
734
|
+
}
|
|
735
|
+
onDrawingModified(cb: (drawing: IDrawing) => void): void {
|
|
736
|
+
this.onDrawingModifiedCb = cb;
|
|
737
|
+
}
|
|
738
|
+
onDrawingRemoved(cb: (id: string) => void): void {
|
|
739
|
+
this.onDrawingRemovedCb = cb;
|
|
740
|
+
}
|
|
741
|
+
onToolChanged(cb: (tool: string | null) => void): void {
|
|
742
|
+
this.onToolChangedCb = cb;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Bug2修复:双击回调注册
|
|
746
|
+
onDrawingDoubleClick(cb: (drawing: IDrawing | null) => void): void {
|
|
747
|
+
this.onDrawingDoubleClickCb = cb;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Bug3修复:设置绘图文字标签(从属性面板调用) */
|
|
751
|
+
setDrawingText(drawingId: string, text: string): void {
|
|
752
|
+
if (!this.textLabelOverlay || !this.manager) return;
|
|
753
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
754
|
+
const drawing = (this.manager as any).getDrawing?.(drawingId);
|
|
755
|
+
if (!drawing) return;
|
|
756
|
+
|
|
757
|
+
// 获取绘图锚点的像素坐标
|
|
758
|
+
const anchors = drawing.anchors ?? [];
|
|
759
|
+
if (anchors.length === 0) return;
|
|
760
|
+
|
|
761
|
+
if (!this.chart || !this.series) return;
|
|
762
|
+
|
|
763
|
+
let pixel: { x: number; y: number };
|
|
764
|
+
try {
|
|
765
|
+
const x = this.chart.timeScale().timeToCoordinate(anchors[0].time as Time);
|
|
766
|
+
const y = this.series.priceToCoordinate(anchors[0].price);
|
|
767
|
+
if (x === null || y === null) return;
|
|
768
|
+
pixel = { x: x as number, y: y as number };
|
|
769
|
+
} catch {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (text) {
|
|
774
|
+
this.textLabelOverlay.setLabel(drawingId, text, pixel.x, pixel.y);
|
|
775
|
+
} else {
|
|
776
|
+
this.textLabelOverlay.removeLabel(drawingId);
|
|
777
|
+
}
|
|
778
|
+
this.onDrawingModifiedCb?.(drawing);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** 销毁 */
|
|
782
|
+
dispose(): void {
|
|
783
|
+
this.detach();
|
|
784
|
+
this.onDrawingCreatedCb = undefined;
|
|
785
|
+
this.onDrawingSelectedCb = undefined;
|
|
786
|
+
this.onDrawingModifiedCb = undefined;
|
|
787
|
+
this.onDrawingRemovedCb = undefined;
|
|
788
|
+
this.onToolChangedCb = undefined;
|
|
789
|
+
this.onDrawingDoubleClickCb = undefined;
|
|
790
|
+
// ★ V2: 清理 pane 相关状态
|
|
791
|
+
this.paneDrawingsMap.clear();
|
|
792
|
+
this.currentPaneIndex = 0;
|
|
793
|
+
this.onPaneSwitchNeededCb = undefined;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ═════════════════════════════════════════════
|
|
797
|
+
// ★ V2: 副图绘图支持方法
|
|
798
|
+
// ═════════════════════════════════════════════
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* ★ V2: 检测点击落在哪个 pane
|
|
802
|
+
* LWC v5 提供 chart.panes() API,每个 pane 有 getHeight()
|
|
803
|
+
*/
|
|
804
|
+
private detectPaneIndex(clickY: number): number {
|
|
805
|
+
if (!this.chart) return 0;
|
|
806
|
+
try {
|
|
807
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
808
|
+
const panes = (this.chart as any).panes?.();
|
|
809
|
+
if (!panes || !Array.isArray(panes)) return 0;
|
|
810
|
+
|
|
811
|
+
// 从顶部开始累加各pane高度,判断clickY落在哪个pane
|
|
812
|
+
let accumulatedHeight = 0;
|
|
813
|
+
for (let i = 0; i < panes.length; i++) {
|
|
814
|
+
const paneHeight = panes[i].getHeight?.() ?? 0;
|
|
815
|
+
if (clickY >= accumulatedHeight && clickY < accumulatedHeight + paneHeight) {
|
|
816
|
+
return i;
|
|
817
|
+
}
|
|
818
|
+
accumulatedHeight += paneHeight;
|
|
819
|
+
}
|
|
820
|
+
return 0; // fallback:主图
|
|
821
|
+
} catch {
|
|
822
|
+
return 0;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* ★ V2: 切换绘图目标 pane(带绘图隔离)
|
|
828
|
+
*/
|
|
829
|
+
switchPane(paneIndex: number, paneSeries: ISeriesApi<LWCSeriesType>): void {
|
|
830
|
+
if (this.currentPaneIndex === paneIndex) return;
|
|
831
|
+
|
|
832
|
+
// 1. 保存当前 pane 的绘图
|
|
833
|
+
const currentDrawings = this.exportDrawings();
|
|
834
|
+
this.paneDrawingsMap.set(this.currentPaneIndex, currentDrawings);
|
|
835
|
+
|
|
836
|
+
// 2. 切换 series
|
|
837
|
+
this.currentPaneIndex = paneIndex;
|
|
838
|
+
this.setTargetSeries(paneSeries);
|
|
839
|
+
|
|
840
|
+
// 3. 恢复目标 pane 的绘图
|
|
841
|
+
const targetDrawings = this.paneDrawingsMap.get(paneIndex) ?? [];
|
|
842
|
+
this.clearAll();
|
|
843
|
+
if (targetDrawings.length > 0) {
|
|
844
|
+
this.importDrawings(targetDrawings);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* ★ V2: 获取当前目标 pane index
|
|
850
|
+
*/
|
|
851
|
+
getCurrentPaneIndex(): number {
|
|
852
|
+
return this.currentPaneIndex;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* ★ V2: 重置到主图 pane
|
|
857
|
+
*/
|
|
858
|
+
resetToMainPane(): void {
|
|
859
|
+
if (this.currentPaneIndex !== 0) {
|
|
860
|
+
// 切回主图 series
|
|
861
|
+
const mainSeries = this.paneSeriesInternalMap.get(0);
|
|
862
|
+
if (mainSeries) {
|
|
863
|
+
this.switchPane(0, mainSeries);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
this.currentPaneIndex = 0;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* ★ V6 需求1:清除所有 pane series 映射(指标 clearAll 后调用)
|
|
871
|
+
*/
|
|
872
|
+
clearPaneSeriesMap(): void {
|
|
873
|
+
// 保留 pane 0(主图)的映射
|
|
874
|
+
const mainSeries = this.paneSeriesInternalMap.get(0);
|
|
875
|
+
this.paneSeriesInternalMap.clear();
|
|
876
|
+
if (mainSeries) {
|
|
877
|
+
this.paneSeriesInternalMap.set(0, mainSeries);
|
|
878
|
+
}
|
|
879
|
+
// 清除绘图隔离数据
|
|
880
|
+
this.paneDrawingsMap.clear();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* ★ V5 需求1:注册 pane series 映射(供内部 detectPaneIndex 自动切换)
|
|
885
|
+
*/
|
|
886
|
+
registerPaneSeries(paneIndex: number, series: ISeriesApi<LWCSeriesType>): void {
|
|
887
|
+
this.paneSeriesInternalMap.set(paneIndex, series);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* ★ V5 需求1:取消注册 pane series
|
|
892
|
+
*/
|
|
893
|
+
unregisterPaneSeries(paneIndex: number): void {
|
|
894
|
+
this.paneSeriesInternalMap.delete(paneIndex);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* ★ V2: 设置 pane 切换请求回调
|
|
899
|
+
*/
|
|
900
|
+
onPaneSwitchNeeded(cb: (paneIndex: number) => void): void {
|
|
901
|
+
this.onPaneSwitchNeededCb = cb;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── Private ──
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Bug①修复:绑定拖拽交互
|
|
908
|
+
* 选中绘图时禁用 chart 的平移和缩放,取消选中时恢复
|
|
909
|
+
* 关键:handleScroll/handleScale 是 ChartOptions 顶层属性,pressedMouseMove 是正确属性名
|
|
910
|
+
*/
|
|
911
|
+
private bindDragInteraction(): void {
|
|
912
|
+
if (!this.manager) return;
|
|
913
|
+
|
|
914
|
+
// 选中绘图 → 禁用 chart 的平移和缩放
|
|
915
|
+
this.manager.on('drawing:selected', () => {
|
|
916
|
+
if (!this.chart) return;
|
|
917
|
+
try {
|
|
918
|
+
this.chart.applyOptions({
|
|
919
|
+
handleScroll: {
|
|
920
|
+
pressedMouseMove: false,
|
|
921
|
+
mouseWheel: false,
|
|
922
|
+
horzTouchDrag: false,
|
|
923
|
+
vertTouchDrag: false,
|
|
924
|
+
},
|
|
925
|
+
handleScale: {
|
|
926
|
+
mouseWheel: false,
|
|
927
|
+
pinch: false,
|
|
928
|
+
axisPressedMouseMove: {
|
|
929
|
+
time: false,
|
|
930
|
+
price: false,
|
|
931
|
+
},
|
|
932
|
+
axisDoubleClickReset: {
|
|
933
|
+
time: true,
|
|
934
|
+
price: true,
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
} catch (e) {
|
|
939
|
+
console.warn('[DrawingAdapter] Failed to disable chart scroll/scale:', e);
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// 取消选中 → 恢复 chart 的平移和缩放
|
|
944
|
+
this.manager.on('drawing:deselected', () => {
|
|
945
|
+
if (!this.chart) return;
|
|
946
|
+
try {
|
|
947
|
+
const defaultScroll: HandleScrollOptions = {
|
|
948
|
+
pressedMouseMove: true,
|
|
949
|
+
mouseWheel: true,
|
|
950
|
+
horzTouchDrag: true,
|
|
951
|
+
vertTouchDrag: true,
|
|
952
|
+
};
|
|
953
|
+
const defaultScale: HandleScaleOptions = {
|
|
954
|
+
mouseWheel: true,
|
|
955
|
+
pinch: true,
|
|
956
|
+
axisPressedMouseMove: { time: true, price: true },
|
|
957
|
+
axisDoubleClickReset: { time: true, price: true },
|
|
958
|
+
};
|
|
959
|
+
// 传入深拷贝副本,确保 savedXxxOptions 不被 applyOptions 的 merge 再次污染
|
|
960
|
+
const scrollRestore = this.savedScrollOptions
|
|
961
|
+
? { ...this.savedScrollOptions }
|
|
962
|
+
: { ...defaultScroll };
|
|
963
|
+
const scaleRestore = this.savedScaleOptions
|
|
964
|
+
? {
|
|
965
|
+
mouseWheel: this.savedScaleOptions.mouseWheel,
|
|
966
|
+
pinch: this.savedScaleOptions.pinch,
|
|
967
|
+
axisPressedMouseMove: { ...this.savedScaleOptions.axisPressedMouseMove as { time: boolean; price: boolean } },
|
|
968
|
+
axisDoubleClickReset: { ...this.savedScaleOptions.axisDoubleClickReset as { time: boolean; price: boolean } },
|
|
969
|
+
}
|
|
970
|
+
: { ...defaultScale };
|
|
971
|
+
this.chart.applyOptions({
|
|
972
|
+
handleScroll: scrollRestore,
|
|
973
|
+
handleScale: scaleRestore,
|
|
974
|
+
});
|
|
975
|
+
} catch (e) {
|
|
976
|
+
console.warn('[DrawingAdapter] Failed to restore chart scroll/scale:', e);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private bindEvents(): void {
|
|
982
|
+
if (!this.manager) return;
|
|
983
|
+
|
|
984
|
+
const events: Array<{ event: DrawingEventType; cb: DrawingEventCallback }> = [
|
|
985
|
+
{ event: 'drawing:added', cb: (e) => { if (e.drawing) this.onDrawingCreatedCb?.(e.drawing); } },
|
|
986
|
+
{ event: 'drawing:selected', cb: (e) => this.onDrawingSelectedCb?.(e.drawing ?? null) },
|
|
987
|
+
{ event: 'drawing:deselected', cb: () => this.onDrawingSelectedCb?.(null) },
|
|
988
|
+
{ event: 'drawing:updated', cb: (e) => { if (e.drawing) this.onDrawingModifiedCb?.(e.drawing); } },
|
|
989
|
+
{ event: 'drawing:removed', cb: (e) => this.onDrawingRemovedCb?.(e.drawingId ?? '') },
|
|
990
|
+
{ event: 'tool:changed', cb: (e) => this.onToolChangedCb?.(e.toolType ?? null) },
|
|
991
|
+
];
|
|
992
|
+
|
|
993
|
+
for (const { event, cb } of events) {
|
|
994
|
+
const unsub = this.manager!.on(event, cb);
|
|
995
|
+
this.unsubscribers.push(unsub);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
private unbindEvents(): void {
|
|
1000
|
+
for (const unsub of this.unsubscribers) {
|
|
1001
|
+
unsub();
|
|
1002
|
+
}
|
|
1003
|
+
this.unsubscribers = [];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ── 绘图创建状态机 ──
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* 处理图表点击 — 绘图创建核心逻辑
|
|
1010
|
+
*/
|
|
1011
|
+
private handleChartClick(param: MouseEventParams): void {
|
|
1012
|
+
if (!this.manager || !this.chart || !this.series) return;
|
|
1013
|
+
if (!param.point || param.time === undefined) return;
|
|
1014
|
+
|
|
1015
|
+
const activeTool = this.manager.getActiveTool();
|
|
1016
|
+
if (!activeTool) {
|
|
1017
|
+
// 无活跃工具 → 让 DrawingManager 处理选择逻辑
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ★ V5 需求1:检测点击是否在副图区域,如果是则同步切换 pane 并继续画线
|
|
1022
|
+
const clickedPaneIndex = this.detectPaneIndex(param.point.y);
|
|
1023
|
+
if (clickedPaneIndex !== this.currentPaneIndex) {
|
|
1024
|
+
// 先尝试内部映射同步切换
|
|
1025
|
+
const paneSeries = this.paneSeriesInternalMap.get(clickedPaneIndex);
|
|
1026
|
+
if (paneSeries) {
|
|
1027
|
+
// 同步切换 pane
|
|
1028
|
+
this.switchPane(clickedPaneIndex, paneSeries);
|
|
1029
|
+
// 通知外部更新 store 状态
|
|
1030
|
+
this.onPaneSwitchNeededCb?.(clickedPaneIndex);
|
|
1031
|
+
// ★ 不 return,继续处理这次点击作为绘图的第一个锈点
|
|
1032
|
+
} else {
|
|
1033
|
+
// 内部映射没有,通过回调异步切换(fallback)
|
|
1034
|
+
this.onPaneSwitchNeededCb?.(clickedPaneIndex);
|
|
1035
|
+
return; // 外部切换后用户需再次点击
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ★ D10: brush/path 使用自由绘制模式,跳过 click 状态机
|
|
1040
|
+
// Bug2修复:pattern-* 工具虽然映射为 path,但使用预设锚点数
|
|
1041
|
+
if (this.freeDrawActive || activeTool === 'brush' ||
|
|
1042
|
+
(activeTool === 'path' && !this.activeToolType?.startsWith('pattern-'))) {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Bug2修复:pattern-* 工具使用预设锚点数,不走自由绘制模式
|
|
1047
|
+
if (this.activeToolType?.startsWith('pattern-')) {
|
|
1048
|
+
const time = param.time as Time;
|
|
1049
|
+
const price = this.series.coordinateToPrice(param.point.y);
|
|
1050
|
+
if (price === null) return;
|
|
1051
|
+
|
|
1052
|
+
// ★ Bug2修复:限制点击创建锚点价格范围
|
|
1053
|
+
const anchor: Anchor = { time, price: this.clampPriceToData(price) };
|
|
1054
|
+
const toolConfig = getToolConfig(this.activeToolType);
|
|
1055
|
+
const anchorCount = toolConfig?.anchorCount ?? 2;
|
|
1056
|
+
|
|
1057
|
+
if (this.creationState.phase === 'idle') {
|
|
1058
|
+
this.startCreation(activeTool, anchor, anchorCount);
|
|
1059
|
+
} else {
|
|
1060
|
+
this.addAnchor(anchor);
|
|
1061
|
+
}
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// 获取点击位置的 time 和 price
|
|
1066
|
+
const time = param.time as Time;
|
|
1067
|
+
const price = this.series.coordinateToPrice(param.point.y);
|
|
1068
|
+
if (price === null) return;
|
|
1069
|
+
|
|
1070
|
+
// ★ Bug2修复:限制点击创建锚点价格范围
|
|
1071
|
+
const anchor: Anchor = { time, price: this.clampPriceToData(price) };
|
|
1072
|
+
const ourType = mapLibTypeToOurs(activeTool);
|
|
1073
|
+
const toolConfig = ourType ? getToolConfig(ourType) : undefined;
|
|
1074
|
+
const anchorCount = toolConfig?.anchorCount ?? 2;
|
|
1075
|
+
|
|
1076
|
+
if (this.creationState.phase === 'idle') {
|
|
1077
|
+
// 开始新的绘图创建
|
|
1078
|
+
this.startCreation(activeTool, anchor, anchorCount);
|
|
1079
|
+
} else {
|
|
1080
|
+
// 继续添加锚点
|
|
1081
|
+
this.addAnchor(anchor);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* 开始创建绘图
|
|
1087
|
+
*/
|
|
1088
|
+
private startCreation(libTool: string, firstAnchor: Anchor, anchorCount: number): void {
|
|
1089
|
+
const registry = getToolRegistry();
|
|
1090
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
1091
|
+
|
|
1092
|
+
const drawing = registry.createDrawing(
|
|
1093
|
+
libTool, id, [firstAnchor], this.defaultDrawingStyle, {},
|
|
1094
|
+
);
|
|
1095
|
+
if (!drawing) return;
|
|
1096
|
+
|
|
1097
|
+
// 添加到 DrawingManager
|
|
1098
|
+
this.manager!.addDrawing(drawing);
|
|
1099
|
+
|
|
1100
|
+
if (anchorCount === 1) {
|
|
1101
|
+
// 单锚点工具 → 立即完成
|
|
1102
|
+
this.completeCreation();
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// 进入创建状态
|
|
1107
|
+
this.creationState = {
|
|
1108
|
+
phase: 'creating',
|
|
1109
|
+
pendingDrawing: drawing,
|
|
1110
|
+
anchors: [firstAnchor],
|
|
1111
|
+
requiredCount: anchorCount,
|
|
1112
|
+
activeLibTool: libTool,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* 添加锚点
|
|
1118
|
+
*/
|
|
1119
|
+
private addAnchor(anchor: Anchor): void {
|
|
1120
|
+
const { pendingDrawing, anchors, requiredCount } = this.creationState;
|
|
1121
|
+
if (!pendingDrawing) return;
|
|
1122
|
+
|
|
1123
|
+
anchors.push(anchor);
|
|
1124
|
+
|
|
1125
|
+
if (requiredCount === -1) {
|
|
1126
|
+
// 动态锚点(path/brush):每次点击追加一个点,双击结束
|
|
1127
|
+
pendingDrawing.setAnchors([...anchors]);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (anchors.length >= requiredCount) {
|
|
1132
|
+
// 预设锚点数已满 → 完成
|
|
1133
|
+
pendingDrawing.setAnchors([...anchors]);
|
|
1134
|
+
this.completeCreation();
|
|
1135
|
+
} else {
|
|
1136
|
+
// 预览:设置已收集的锚点
|
|
1137
|
+
pendingDrawing.setAnchors([...anchors]);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* 完成创建 — 重置状态,自动切回光标
|
|
1143
|
+
*/
|
|
1144
|
+
private completeCreation(): void {
|
|
1145
|
+
this.creationState = {
|
|
1146
|
+
phase: 'idle',
|
|
1147
|
+
pendingDrawing: null,
|
|
1148
|
+
anchors: [],
|
|
1149
|
+
requiredCount: 0,
|
|
1150
|
+
activeLibTool: null,
|
|
1151
|
+
};
|
|
1152
|
+
// 自动切回光标
|
|
1153
|
+
this.manager?.setActiveTool(null);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* 取消创建 — 移除创建中的绘图,重置状态
|
|
1158
|
+
*/
|
|
1159
|
+
private cancelCreation(): void {
|
|
1160
|
+
if (this.creationState.pendingDrawing && this.manager) {
|
|
1161
|
+
this.manager.removeDrawing(this.creationState.pendingDrawing.id);
|
|
1162
|
+
}
|
|
1163
|
+
this.creationState = {
|
|
1164
|
+
phase: 'idle',
|
|
1165
|
+
pendingDrawing: null,
|
|
1166
|
+
anchors: [],
|
|
1167
|
+
requiredCount: 0,
|
|
1168
|
+
activeLibTool: null,
|
|
1169
|
+
};
|
|
1170
|
+
this.manager?.setActiveTool(null);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* 处理双击 — 动态锚点工具结束创建
|
|
1175
|
+
*/
|
|
1176
|
+
private handleChartDblClick(param: MouseEventParams): void {
|
|
1177
|
+
// 处理动态锯点的创建结束
|
|
1178
|
+
if (this.creationState.phase === 'creating') {
|
|
1179
|
+
if (this.creationState.requiredCount !== -1) return; // 非动态工具忽略
|
|
1180
|
+
|
|
1181
|
+
// 双击前会触发两次 click,弹出最后一次重复的 anchor
|
|
1182
|
+
if (this.creationState.anchors.length > 0) {
|
|
1183
|
+
this.creationState.anchors.pop();
|
|
1184
|
+
// 同步更新绘图
|
|
1185
|
+
if (this.creationState.pendingDrawing) {
|
|
1186
|
+
this.creationState.pendingDrawing.setAnchors([...this.creationState.anchors]);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (this.creationState.anchors.length >= 2) {
|
|
1191
|
+
this.completeCreation();
|
|
1192
|
+
} else {
|
|
1193
|
+
this.cancelCreation();
|
|
1194
|
+
}
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Bug4修复:非创建模式下,双击已完成绘图显示文字输入框
|
|
1199
|
+
if (!param.point || param.time === undefined) return;
|
|
1200
|
+
if (!this.manager || !this.container) return;
|
|
1201
|
+
|
|
1202
|
+
const pixel = { x: param.point.x, y: param.point.y };
|
|
1203
|
+
|
|
1204
|
+
// hitTest 找到双击的绘图
|
|
1205
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1206
|
+
const hitDrawing = (this.manager as any).hitTest?.(pixel) as IDrawing | null;
|
|
1207
|
+
|
|
1208
|
+
// ★ Fib Bug修复:斐波那契等线类绘图的 testHit 命中范围极小(5px),
|
|
1209
|
+
// 双击时极易 miss。当 hitTest 返回 null 时做一次扩大范围的 fallback 检测。
|
|
1210
|
+
const fallbackHit = hitDrawing ?? this.hitTestFallback(pixel, 15);
|
|
1211
|
+
|
|
1212
|
+
if (fallbackHit) {
|
|
1213
|
+
// Bug2修复:双击绘图 → 触发属性面板回调
|
|
1214
|
+
this.onDrawingDoubleClickCb?.(fallbackHit);
|
|
1215
|
+
|
|
1216
|
+
// 同时保留原有的文字编辑功能
|
|
1217
|
+
const supportedTypes = [
|
|
1218
|
+
'trend-line', 'horizontal-line', 'vertical-line', 'ray',
|
|
1219
|
+
'parallel-channel', 'rectangle', 'circle',
|
|
1220
|
+
];
|
|
1221
|
+
if (supportedTypes.includes(fallbackHit.type)) {
|
|
1222
|
+
this.showTextInputEditor(fallbackHit, pixel);
|
|
1223
|
+
}
|
|
1224
|
+
} else {
|
|
1225
|
+
// Bug2修复:双击空白 → 关闭面板
|
|
1226
|
+
this.onDrawingDoubleClickCb?.(null);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* 处理鼠标移动 — 实时预览创建中的绘图
|
|
1232
|
+
*/
|
|
1233
|
+
private handleContainerMouseMove(e: MouseEvent): void {
|
|
1234
|
+
if (this.creationState.phase !== 'creating') return;
|
|
1235
|
+
if (!this.creationState.pendingDrawing || !this.chart || !this.series || !this.container) return;
|
|
1236
|
+
|
|
1237
|
+
const rect = this.container.getBoundingClientRect();
|
|
1238
|
+
const x = e.clientX - rect.left;
|
|
1239
|
+
const y = e.clientY - rect.top;
|
|
1240
|
+
|
|
1241
|
+
const time = this.chart.timeScale().coordinateToTime(x);
|
|
1242
|
+
const price = this.series.coordinateToPrice(y);
|
|
1243
|
+
if (time === null || price === null) return;
|
|
1244
|
+
|
|
1245
|
+
// ★ Bug2修复:限制预览锈点价格范围,防止纵坐标无限延伸
|
|
1246
|
+
const clampedPrice = this.clampPriceToData(price);
|
|
1247
|
+
|
|
1248
|
+
// 将当前鼠标位置作为临时锈点,附加到已收集锈点后
|
|
1249
|
+
const previewAnchor: Anchor = { time: time as Time, price: clampedPrice };
|
|
1250
|
+
const allAnchors = [...this.creationState.anchors, previewAnchor];
|
|
1251
|
+
this.creationState.pendingDrawing.setAnchors(allAnchors);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* 处理键盘 — Esc 取消创建
|
|
1256
|
+
*/
|
|
1257
|
+
private handleKeydown(e: KeyboardEvent): void {
|
|
1258
|
+
if (e.key === 'Escape' && this.creationState.phase === 'creating') {
|
|
1259
|
+
this.cancelCreation();
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// ══════════════════════════════════════════════
|
|
1264
|
+
// 整体拖拽:选中绘图后拖拽本体移动整个图形
|
|
1265
|
+
// ══════════════════════════════════════════════
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* 整体拖拽:启动检测(capture 阶段,先于库的 mousedown)
|
|
1269
|
+
*/
|
|
1270
|
+
private handleBodyDragStart(e: MouseEvent): void {
|
|
1271
|
+
if (!this.manager || !this.chart || !this.series || !this.container) return;
|
|
1272
|
+
|
|
1273
|
+
// 只处理左键
|
|
1274
|
+
if (e.button !== 0) return;
|
|
1275
|
+
|
|
1276
|
+
// 创建模式中不拦截
|
|
1277
|
+
if (this.creationState.phase === 'creating') return;
|
|
1278
|
+
|
|
1279
|
+
const rect = this.container.getBoundingClientRect();
|
|
1280
|
+
const pixel = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
1281
|
+
|
|
1282
|
+
// 获取当前选中的绘图
|
|
1283
|
+
let selected = this.manager.getSelectedDrawing();
|
|
1284
|
+
|
|
1285
|
+
// ★ Fix①:无选中绘图时,尝试 hitTest 预选中(点住即拖)
|
|
1286
|
+
if (!selected) {
|
|
1287
|
+
const hitDrawing = this.manager.hitTest(pixel);
|
|
1288
|
+
if (hitDrawing && !hitDrawing.options.locked) {
|
|
1289
|
+
this.manager.selectDrawing(hitDrawing.id);
|
|
1290
|
+
selected = hitDrawing;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (!selected || selected.options.locked) return;
|
|
1295
|
+
|
|
1296
|
+
// 构造 viewport
|
|
1297
|
+
const viewport = this.getViewport();
|
|
1298
|
+
if (!viewport) return;
|
|
1299
|
+
|
|
1300
|
+
// Step 1: 命中锚点 → 不拦截(让库处理锚点拖拽)
|
|
1301
|
+
const anchorHit = selected.hitTestAnchor(pixel, viewport);
|
|
1302
|
+
if (anchorHit !== null) return;
|
|
1303
|
+
|
|
1304
|
+
// Step 2: 命中绘图本体 → 拦截!
|
|
1305
|
+
const bodyHit = selected.testHit(pixel, viewport);
|
|
1306
|
+
if (!bodyHit) return;
|
|
1307
|
+
|
|
1308
|
+
// ★ 启动整体拖拽
|
|
1309
|
+
e.preventDefault();
|
|
1310
|
+
e.stopPropagation();
|
|
1311
|
+
|
|
1312
|
+
this.bodyDragState = {
|
|
1313
|
+
phase: 'dragging',
|
|
1314
|
+
drawingId: selected.id,
|
|
1315
|
+
startPixel: pixel,
|
|
1316
|
+
startAnchors: selected.anchors.map(a => ({ ...a })),
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
selected.setState('editing');
|
|
1320
|
+
|
|
1321
|
+
// 注册 document 级 move/up(拖拽过程中鼠标可能移出 container)
|
|
1322
|
+
this.bodyDragMoveHandler = (ev: MouseEvent) => this.handleBodyDragMove(ev);
|
|
1323
|
+
this.bodyDragEndHandler = (ev: MouseEvent) => this.handleBodyDragEnd(ev);
|
|
1324
|
+
document.addEventListener('mousemove', this.bodyDragMoveHandler);
|
|
1325
|
+
document.addEventListener('mouseup', this.bodyDragEndHandler);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* 整体拖拽:移动入口(rAF节流)
|
|
1330
|
+
*/
|
|
1331
|
+
private handleBodyDragMove(e: MouseEvent): void {
|
|
1332
|
+
if (this.bodyDragState.phase !== 'dragging') return;
|
|
1333
|
+
|
|
1334
|
+
this.pendingDragEvent = e;
|
|
1335
|
+
|
|
1336
|
+
// ★ Fix④:rAF节流,每帧最多执行一次 setAnchors
|
|
1337
|
+
if (this.rafId === null) {
|
|
1338
|
+
this.rafId = requestAnimationFrame(() => {
|
|
1339
|
+
this.rafId = null;
|
|
1340
|
+
if (this.pendingDragEvent) {
|
|
1341
|
+
this.processBodyDragMove(this.pendingDragEvent);
|
|
1342
|
+
this.pendingDragEvent = null;
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* 整体拖拽:实际移动逻辑(逐锚点像素转换方案)
|
|
1350
|
+
*/
|
|
1351
|
+
private processBodyDragMove(e: MouseEvent): void {
|
|
1352
|
+
if (this.bodyDragState.phase !== 'dragging' || !this.bodyDragState.drawingId) return;
|
|
1353
|
+
if (!this.manager || !this.chart || !this.series || !this.container) return;
|
|
1354
|
+
|
|
1355
|
+
const drawing = this.manager.getDrawing(this.bodyDragState.drawingId);
|
|
1356
|
+
if (!drawing) return;
|
|
1357
|
+
|
|
1358
|
+
const rect = this.container.getBoundingClientRect();
|
|
1359
|
+
const currentX = e.clientX - rect.left;
|
|
1360
|
+
const currentY = e.clientY - rect.top;
|
|
1361
|
+
|
|
1362
|
+
// 像素偏移量
|
|
1363
|
+
const dx = currentX - this.bodyDragState.startPixel!.x;
|
|
1364
|
+
const dy = currentY - this.bodyDragState.startPixel!.y;
|
|
1365
|
+
|
|
1366
|
+
const timeScale = this.chart.timeScale();
|
|
1367
|
+
|
|
1368
|
+
// 逐锚点转换:每个锚点的原始像素位置 + delta → 新的 time/price
|
|
1369
|
+
const newAnchors = this.bodyDragState.startAnchors!.map(a => {
|
|
1370
|
+
// 原锚点在起始时的像素位置
|
|
1371
|
+
const origPixelX = timeScale.timeToCoordinate(a.time as Time);
|
|
1372
|
+
const origPixelY = this.series!.priceToCoordinate(a.price);
|
|
1373
|
+
if (origPixelX === null || origPixelY === null) return a;
|
|
1374
|
+
|
|
1375
|
+
// 加上 delta
|
|
1376
|
+
const newPixelX = (origPixelX as number) + dx;
|
|
1377
|
+
const newPixelY = (origPixelY as number) + dy;
|
|
1378
|
+
|
|
1379
|
+
// 转回 time/price
|
|
1380
|
+
const newTime = timeScale.coordinateToTime(newPixelX as Coordinate);
|
|
1381
|
+
const newPrice = this.series!.coordinateToPrice(newPixelY as Coordinate);
|
|
1382
|
+
if (newTime === null || newPrice === null) return a;
|
|
1383
|
+
|
|
1384
|
+
// ★ Bug2修复:限制拖拽锈点价格范围
|
|
1385
|
+
return { time: newTime as Time, price: this.clampPriceToData(newPrice) };
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
drawing.setAnchors(newAnchors);
|
|
1389
|
+
this.onDrawingModifiedCb?.(drawing);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* 整体拖拽:结束
|
|
1394
|
+
*/
|
|
1395
|
+
private handleBodyDragEnd(_e: MouseEvent): void {
|
|
1396
|
+
if (this.bodyDragState.phase !== 'dragging') return;
|
|
1397
|
+
|
|
1398
|
+
const drawing = this.bodyDragState.drawingId
|
|
1399
|
+
? this.manager?.getDrawing(this.bodyDragState.drawingId)
|
|
1400
|
+
: null;
|
|
1401
|
+
|
|
1402
|
+
if (drawing) {
|
|
1403
|
+
drawing.setState('selected');
|
|
1404
|
+
this.onDrawingModifiedCb?.(drawing);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// 注销 document 级事件
|
|
1408
|
+
if (this.bodyDragMoveHandler) {
|
|
1409
|
+
document.removeEventListener('mousemove', this.bodyDragMoveHandler);
|
|
1410
|
+
}
|
|
1411
|
+
if (this.bodyDragEndHandler) {
|
|
1412
|
+
document.removeEventListener('mouseup', this.bodyDragEndHandler);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// ★ Fix③:如果有 pending 的 rAF,立即执行最终帧确保位置不丢
|
|
1416
|
+
if (this.rafId !== null) {
|
|
1417
|
+
cancelAnimationFrame(this.rafId);
|
|
1418
|
+
this.rafId = null;
|
|
1419
|
+
if (this.pendingDragEvent) {
|
|
1420
|
+
this.processBodyDragMove(this.pendingDragEvent);
|
|
1421
|
+
this.pendingDragEvent = null;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
this.bodyDragState = {
|
|
1426
|
+
phase: 'idle',
|
|
1427
|
+
drawingId: null,
|
|
1428
|
+
startPixel: null,
|
|
1429
|
+
startAnchors: null,
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// ═════════════════════════════════════════════
|
|
1434
|
+
// ★ D10: 画笔自由绘制模式
|
|
1435
|
+
// ═════════════════════════════════════════════
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* 画笔自由绘制:mousedown 拦截
|
|
1439
|
+
*/
|
|
1440
|
+
private handleFreeDrawStart(e: MouseEvent): void {
|
|
1441
|
+
if (!this.manager || !this.chart || !this.series || !this.container) return;
|
|
1442
|
+
if (e.button !== 0) return;
|
|
1443
|
+
|
|
1444
|
+
const activeTool = this.manager.getActiveTool();
|
|
1445
|
+
if (activeTool !== 'brush' && activeTool !== 'path') return;
|
|
1446
|
+
// Bug2修复:pattern-* 工具不走自由绘制
|
|
1447
|
+
if (this.activeToolType?.startsWith('pattern-')) return;
|
|
1448
|
+
if (this.creationState.phase === 'creating') return; // 已在创建中
|
|
1449
|
+
|
|
1450
|
+
const rect = this.container.getBoundingClientRect();
|
|
1451
|
+
const x = e.clientX - rect.left;
|
|
1452
|
+
const y = e.clientY - rect.top;
|
|
1453
|
+
const time = this.chart.timeScale().coordinateToTime(x as Coordinate);
|
|
1454
|
+
const price = this.series.coordinateToPrice(y as Coordinate);
|
|
1455
|
+
if (time === null || price === null) return;
|
|
1456
|
+
|
|
1457
|
+
// 拦截!阻止 chart click 和 body drag
|
|
1458
|
+
e.preventDefault();
|
|
1459
|
+
e.stopPropagation();
|
|
1460
|
+
|
|
1461
|
+
this.freeDrawActive = true;
|
|
1462
|
+
|
|
1463
|
+
// 启动创建
|
|
1464
|
+
const anchor: Anchor = { time: time as Time, price };
|
|
1465
|
+
const ourType = mapLibTypeToOurs(activeTool);
|
|
1466
|
+
const toolConfig = ourType ? getToolConfig(ourType) : undefined;
|
|
1467
|
+
const anchorCount = toolConfig?.anchorCount ?? -1; // 动态锚点
|
|
1468
|
+
this.startCreation(activeTool, anchor, anchorCount);
|
|
1469
|
+
|
|
1470
|
+
// 注册 document 级事件
|
|
1471
|
+
this.freeDrawMoveHandler = (ev) => this.handleFreeDrawMove(ev);
|
|
1472
|
+
this.freeDrawEndHandler = (ev) => this.handleFreeDrawEnd(ev);
|
|
1473
|
+
document.addEventListener('mousemove', this.freeDrawMoveHandler);
|
|
1474
|
+
document.addEventListener('mouseup', this.freeDrawEndHandler);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* 画笔自由绘制:mousemove(rAF节流)
|
|
1479
|
+
*/
|
|
1480
|
+
private handleFreeDrawMove(e: MouseEvent): void {
|
|
1481
|
+
if (!this.freeDrawActive) return;
|
|
1482
|
+
this.freeDrawPendingEvent = e;
|
|
1483
|
+
|
|
1484
|
+
if (this.freeDrawRafId === null) {
|
|
1485
|
+
this.freeDrawRafId = requestAnimationFrame(() => {
|
|
1486
|
+
this.freeDrawRafId = null;
|
|
1487
|
+
if (this.freeDrawPendingEvent && this.creationState.pendingDrawing) {
|
|
1488
|
+
const ev = this.freeDrawPendingEvent;
|
|
1489
|
+
this.freeDrawPendingEvent = null;
|
|
1490
|
+
|
|
1491
|
+
const rect = this.container!.getBoundingClientRect();
|
|
1492
|
+
const x = ev.clientX - rect.left;
|
|
1493
|
+
const y = ev.clientY - rect.top;
|
|
1494
|
+
const time = this.chart!.timeScale().coordinateToTime(x as Coordinate);
|
|
1495
|
+
const price = this.series!.coordinateToPrice(y as Coordinate);
|
|
1496
|
+
if (time !== null && price !== null) {
|
|
1497
|
+
// ★ Bug2修复:限制画笔自由绘制价格范围
|
|
1498
|
+
this.addAnchor({ time: time as Time, price: this.clampPriceToData(price) });
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* 画笔自由绘制:mouseup
|
|
1507
|
+
*/
|
|
1508
|
+
private handleFreeDrawEnd(_e: MouseEvent): void {
|
|
1509
|
+
if (!this.freeDrawActive) return;
|
|
1510
|
+
this.freeDrawActive = false;
|
|
1511
|
+
|
|
1512
|
+
// 执行最终帧
|
|
1513
|
+
if (this.freeDrawRafId !== null) {
|
|
1514
|
+
cancelAnimationFrame(this.freeDrawRafId);
|
|
1515
|
+
this.freeDrawRafId = null;
|
|
1516
|
+
if (this.freeDrawPendingEvent) {
|
|
1517
|
+
const ev = this.freeDrawPendingEvent;
|
|
1518
|
+
this.freeDrawPendingEvent = null;
|
|
1519
|
+
if (this.creationState.pendingDrawing && this.container && this.chart && this.series) {
|
|
1520
|
+
const rect = this.container.getBoundingClientRect();
|
|
1521
|
+
const x = ev.clientX - rect.left;
|
|
1522
|
+
const y = ev.clientY - rect.top;
|
|
1523
|
+
const time = this.chart.timeScale().coordinateToTime(x as Coordinate);
|
|
1524
|
+
const price = this.series.coordinateToPrice(y as Coordinate);
|
|
1525
|
+
if (time !== null && price !== null) {
|
|
1526
|
+
// ★ Bug2修复:限制画笔自由绘制价格范围
|
|
1527
|
+
this.addAnchor({ time: time as Time, price: this.clampPriceToData(price) });
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// 至少 2 个锚点才完成
|
|
1534
|
+
if (this.creationState.anchors.length >= 2) {
|
|
1535
|
+
this.completeCreation();
|
|
1536
|
+
} else {
|
|
1537
|
+
this.cancelCreation();
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// 清理 document 事件
|
|
1541
|
+
if (this.freeDrawMoveHandler) {
|
|
1542
|
+
document.removeEventListener('mousemove', this.freeDrawMoveHandler);
|
|
1543
|
+
}
|
|
1544
|
+
if (this.freeDrawEndHandler) {
|
|
1545
|
+
document.removeEventListener('mouseup', this.freeDrawEndHandler);
|
|
1546
|
+
}
|
|
1547
|
+
this.freeDrawMoveHandler = null;
|
|
1548
|
+
this.freeDrawEndHandler = null;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ═══════════════════════════════════════════
|
|
1552
|
+
// Bug4修复:文字标签编辑
|
|
1553
|
+
// ═══════════════════════════════════════════
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* 显示文字输入编辑器
|
|
1557
|
+
*/
|
|
1558
|
+
private showTextInputEditor(
|
|
1559
|
+
drawing: IDrawing,
|
|
1560
|
+
pixel: { x: number; y: number },
|
|
1561
|
+
): void {
|
|
1562
|
+
this.hideTextInputEditor();
|
|
1563
|
+
if (!this.container) return;
|
|
1564
|
+
|
|
1565
|
+
const input = document.createElement('input');
|
|
1566
|
+
input.type = 'text';
|
|
1567
|
+
input.className = 'qlchart-drawing-text-editor';
|
|
1568
|
+
|
|
1569
|
+
// 获取已有文字
|
|
1570
|
+
const existingText = this.textLabelOverlay?.getLabel(drawing.id) ?? '';
|
|
1571
|
+
input.value = existingText;
|
|
1572
|
+
input.placeholder = '输入文字标注...';
|
|
1573
|
+
|
|
1574
|
+
input.style.cssText = `
|
|
1575
|
+
position: absolute;
|
|
1576
|
+
left: ${pixel.x + 8}px;
|
|
1577
|
+
top: ${pixel.y - 12}px;
|
|
1578
|
+
z-index: 1001;
|
|
1579
|
+
padding: 4px 8px;
|
|
1580
|
+
border: 1px solid #2962ff;
|
|
1581
|
+
border-radius: 3px;
|
|
1582
|
+
font-size: 12px;
|
|
1583
|
+
background: rgba(255, 255, 255, 0.95);
|
|
1584
|
+
color: #333;
|
|
1585
|
+
outline: none;
|
|
1586
|
+
min-width: 100px;
|
|
1587
|
+
`;
|
|
1588
|
+
|
|
1589
|
+
const commit = () => {
|
|
1590
|
+
const text = input.value.trim();
|
|
1591
|
+
if (text) {
|
|
1592
|
+
this.textLabelOverlay?.setLabel(drawing.id, text, pixel.x, pixel.y);
|
|
1593
|
+
} else {
|
|
1594
|
+
this.textLabelOverlay?.removeLabel(drawing.id);
|
|
1595
|
+
}
|
|
1596
|
+
this.hideTextInputEditor();
|
|
1597
|
+
this.onDrawingModifiedCb?.(drawing);
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
input.addEventListener('blur', commit);
|
|
1601
|
+
input.addEventListener('keydown', (e) => {
|
|
1602
|
+
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
|
1603
|
+
if (e.key === 'Escape') { this.hideTextInputEditor(); }
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
this.container.appendChild(input);
|
|
1607
|
+
requestAnimationFrame(() => { input.focus(); input.select(); });
|
|
1608
|
+
this.textInputEditor = input;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* 隐藏文字输入编辑器
|
|
1613
|
+
*/
|
|
1614
|
+
private hideTextInputEditor(): void {
|
|
1615
|
+
if (this.textInputEditor) {
|
|
1616
|
+
this.textInputEditor.remove();
|
|
1617
|
+
this.textInputEditor = null;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Bug4修复:更新所有文字标签位置(图表滚动/缩放后调用)
|
|
1623
|
+
*/
|
|
1624
|
+
private updateTextLabels(): void {
|
|
1625
|
+
if (!this.textLabelOverlay || !this.manager || !this.chart || !this.series) return;
|
|
1626
|
+
|
|
1627
|
+
this.textLabelOverlay.updatePositions((id) => {
|
|
1628
|
+
const drawing = this.manager?.getAllDrawings().find(d => d.id === id);
|
|
1629
|
+
if (!drawing) return null;
|
|
1630
|
+
|
|
1631
|
+
const anchors = drawing.anchors ?? [];
|
|
1632
|
+
if (anchors.length === 0) return null;
|
|
1633
|
+
|
|
1634
|
+
// 使用第一个猫点作为定位参考
|
|
1635
|
+
const anchor = anchors[0];
|
|
1636
|
+
const x = this.chart!.timeScale().timeToCoordinate(anchor.time as Time);
|
|
1637
|
+
const y = this.series!.priceToCoordinate(anchor.price);
|
|
1638
|
+
if (x === null || y === null) return null;
|
|
1639
|
+
|
|
1640
|
+
return { x: x as number, y: y as number };
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* 辅助:构造 Viewport
|
|
1646
|
+
*/
|
|
1647
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1648
|
+
private getViewport(): { width: number; height: number; timeScale: any; priceScale: any } | null {
|
|
1649
|
+
if (!this.chart || !this.series || !this.container) return null;
|
|
1650
|
+
// ★ Fix②(v2):对齐 DrawingManager.getViewport() 的 wrapper 格式
|
|
1651
|
+
// library 的 anchorToPixel 需要 viewport.priceScale.priceToCoordinate()
|
|
1652
|
+
const ts = this.chart.timeScale();
|
|
1653
|
+
const series = this.series;
|
|
1654
|
+
return {
|
|
1655
|
+
width: ts.width(),
|
|
1656
|
+
height: this.container.clientHeight,
|
|
1657
|
+
timeScale: {
|
|
1658
|
+
coordinateToTime: (e: any) => ts.coordinateToTime(e),
|
|
1659
|
+
timeToCoordinate: (e: any) => ts.timeToCoordinate(e),
|
|
1660
|
+
logicalToCoordinate: (e: any) => ts.logicalToCoordinate(e),
|
|
1661
|
+
},
|
|
1662
|
+
priceScale: {
|
|
1663
|
+
coordinateToPrice: (e: any) => series.coordinateToPrice(e),
|
|
1664
|
+
priceToCoordinate: (e: any) => series.priceToCoordinate(e),
|
|
1665
|
+
},
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// ═════════════════════════════════════════════
|
|
1670
|
+
// ★ Bug2修复:价格范围限制(Clamp)
|
|
1671
|
+
// 防止绘图预览/拖拽锈点导致纵坐标无限延伸
|
|
1672
|
+
// ═════════════════════════════════════════════
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Bug2修复:获取K线价格范围(带缓存)
|
|
1676
|
+
*/
|
|
1677
|
+
private getPriceRange(): { minLow: number; maxHigh: number } {
|
|
1678
|
+
if (this.priceRangeCache) return this.priceRangeCache;
|
|
1679
|
+
|
|
1680
|
+
let minLow = Infinity;
|
|
1681
|
+
let maxHigh = -Infinity;
|
|
1682
|
+
for (const candle of this.candleData) {
|
|
1683
|
+
if (candle.low < minLow) minLow = candle.low;
|
|
1684
|
+
if (candle.high > maxHigh) maxHigh = candle.high;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
this.priceRangeCache = { minLow, maxHigh };
|
|
1688
|
+
return this.priceRangeCache;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* Bug2修复:限制价格在K线数据的合理范围内
|
|
1693
|
+
* 允许 20% 的超出余量,避免完全锁死影响用户体验
|
|
1694
|
+
*/
|
|
1695
|
+
private clampPriceToData(price: number): number {
|
|
1696
|
+
if (this.candleData.length === 0) return price;
|
|
1697
|
+
|
|
1698
|
+
const { minLow, maxHigh } = this.getPriceRange();
|
|
1699
|
+
const range = maxHigh - minLow;
|
|
1700
|
+
if (range === 0) return price;
|
|
1701
|
+
const margin = range * 0.2;
|
|
1702
|
+
|
|
1703
|
+
return Math.max(minLow - margin, Math.min(maxHigh + margin, price));
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ═════════════════════════════════════════════
|
|
1707
|
+
// ★ Fib Bug修复:扩大范围的 fallback 命中检测
|
|
1708
|
+
// 斐波那契等线类绘图的 testHit 命中范围极小(5px),
|
|
1709
|
+
// 双击时用更大的阈值做一次 fallback
|
|
1710
|
+
// ═════════════════════════════════════════════
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Fib Bug修复:扩大命中范围的 fallback hitTest
|
|
1714
|
+
* @param pixel 鼠标像素坐标
|
|
1715
|
+
* @param threshold 扩大的命中阈值(像素)
|
|
1716
|
+
* @return 最近的命中绘图,或 null
|
|
1717
|
+
*/
|
|
1718
|
+
private hitTestFallback(pixel: { x: number; y: number }, threshold: number): IDrawing | null {
|
|
1719
|
+
if (!this.manager) return null;
|
|
1720
|
+
|
|
1721
|
+
const viewport = this.getViewport();
|
|
1722
|
+
if (!viewport) return null;
|
|
1723
|
+
|
|
1724
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1725
|
+
const allDrawings = (this.manager as any).getAllDrawings() as IDrawing[];
|
|
1726
|
+
if (!allDrawings || allDrawings.length === 0) return null;
|
|
1727
|
+
|
|
1728
|
+
// 扩大范围重试:用更大的 threshold 判断是否命中线条
|
|
1729
|
+
let bestHit: IDrawing | null = null;
|
|
1730
|
+
let bestDist = threshold;
|
|
1731
|
+
|
|
1732
|
+
for (const drawing of allDrawings) {
|
|
1733
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1734
|
+
if (!(drawing as any).options?.visible) continue;
|
|
1735
|
+
|
|
1736
|
+
// 先尝试原生 testHit(可能命中)
|
|
1737
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1738
|
+
if (typeof (drawing as any).testHit === 'function') {
|
|
1739
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1740
|
+
if ((drawing as any).testHit(pixel, viewport)) return drawing;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// 检查锈点附近(锈点是用户可拖拽的点,应该也是双击的有效区域)
|
|
1744
|
+
const anchors = drawing.anchors ?? [];
|
|
1745
|
+
for (const anchor of anchors) {
|
|
1746
|
+
const ax = viewport.timeScale.timeToCoordinate(anchor.time as Time);
|
|
1747
|
+
const ay = viewport.priceScale.priceToCoordinate(anchor.price);
|
|
1748
|
+
if (ax === null || ay === null) continue;
|
|
1749
|
+
|
|
1750
|
+
const dist = Math.sqrt(
|
|
1751
|
+
(pixel.x - (ax as number)) ** 2 + (pixel.y - (ay as number)) ** 2,
|
|
1752
|
+
);
|
|
1753
|
+
if (dist <= bestDist) {
|
|
1754
|
+
bestDist = dist;
|
|
1755
|
+
bestHit = drawing;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
return bestHit;
|
|
1761
|
+
}
|
|
1762
|
+
}
|