|
|
@@ -0,0 +1,518 @@
|
|
|
+import type { EChartsType, EChartsOption } from 'echarts';
|
|
|
+import {Decimal} from 'decimal.js';
|
|
|
+
|
|
|
+interface DrawingLine {
|
|
|
+ id: string;
|
|
|
+ type: 'horizontal' | 'trend' | 'trendExtended';
|
|
|
+ points: {
|
|
|
+ xRatio: number;
|
|
|
+ yValue: number;
|
|
|
+ dataIndex?: number;
|
|
|
+ date?: string;
|
|
|
+ }[];
|
|
|
+ style?: {
|
|
|
+ color?: string;
|
|
|
+ width?: number;
|
|
|
+ type?: 'solid' | 'dashed';
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 添加 ECharts X轴配置的类型定义
|
|
|
+interface XAxisOption {
|
|
|
+ data: string[];
|
|
|
+}
|
|
|
+
|
|
|
+// 在文件顶部添加 dataZoom 的类型定义
|
|
|
+interface DataZoomOption {
|
|
|
+ start?: number;
|
|
|
+ end?: number;
|
|
|
+ startValue?: number;
|
|
|
+ endValue?: number;
|
|
|
+}
|
|
|
+
|
|
|
+// 首先修复类型定义
|
|
|
+interface PreviewPoint {
|
|
|
+ xRatio: number;
|
|
|
+ yValue: number;
|
|
|
+ dataIndex?: number;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+export class ChartDrawingTools {
|
|
|
+ private readonly chart: EChartsType;
|
|
|
+ private readonly lines: Map<string, DrawingLine>;
|
|
|
+ private isDrawing: boolean;
|
|
|
+ private currentLineType: 'horizontal' | 'trend' | 'trendExtended' | null;
|
|
|
+ private tempLine: DrawingLine | null;
|
|
|
+ private canStartNewLine: boolean = true;
|
|
|
+ private isTrendFirstPoint: boolean = false;
|
|
|
+
|
|
|
+ constructor(chart: EChartsType) {
|
|
|
+ this.chart = chart;
|
|
|
+ this.lines = new Map();
|
|
|
+ this.isDrawing = false;
|
|
|
+ this.currentLineType = null;
|
|
|
+ this.tempLine = null;
|
|
|
+
|
|
|
+ this.bindEvents();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 开始绘制
|
|
|
+ public startDrawing(type: 'horizontal' | 'trend' | 'trendExtended'): void {
|
|
|
+ this.isDrawing = true;
|
|
|
+ this.currentLineType = type;
|
|
|
+ this.canStartNewLine = true;
|
|
|
+ this.isTrendFirstPoint = type === 'trend' || type === 'trendExtended';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 停止绘制
|
|
|
+ public stopDrawing(): void {
|
|
|
+ this.isDrawing = false;
|
|
|
+ this.currentLineType = null;
|
|
|
+ this.tempLine = null;
|
|
|
+ this.canStartNewLine = true;
|
|
|
+ this.isTrendFirstPoint = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清除所有线条
|
|
|
+ public clearAllLines(): void {
|
|
|
+ this.lines.clear();
|
|
|
+ this.updateChart();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 删除指定线条
|
|
|
+ public deleteLine(id: string): void {
|
|
|
+ this.lines.delete(id);
|
|
|
+ this.updateChart();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新图表
|
|
|
+ private updateChart(): void {
|
|
|
+ const option = this.chart.getOption() as EChartsOption;
|
|
|
+ const markLineData: any[] = [];
|
|
|
+ const xAxis = (option.xAxis as XAxisOption[])[0];
|
|
|
+ const dataZoom = (option.dataZoom as DataZoomOption[]) || [];
|
|
|
+ const viewRange = {
|
|
|
+ start: dataZoom[0]?.startValue ?? 0,
|
|
|
+ end: dataZoom[0]?.endValue ?? (xAxis.data.length - 1)
|
|
|
+ };
|
|
|
+ const yRange = this.getYAxisRange();
|
|
|
+
|
|
|
+ this.lines.forEach(line => {
|
|
|
+ if (line.type === 'horizontal') {
|
|
|
+ markLineData.push({
|
|
|
+ yAxis: line.points[0].yValue,
|
|
|
+ lineStyle: {
|
|
|
+ ...line.style,
|
|
|
+ type: line.style?.type || 'solid'
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } else if (line.type === 'trend' && line.points.length === 2) {
|
|
|
+ // 查找日期对应的索引
|
|
|
+ const startIndex = xAxis.data.indexOf(line.points[0].date!);
|
|
|
+ const endIndex = xAxis.data.indexOf(line.points[1].date!);
|
|
|
+
|
|
|
+ // 只有当两个点的日期都能找到对应索引时才显示线条
|
|
|
+ if (startIndex !== -1 && endIndex !== -1) {
|
|
|
+ markLineData.push([{
|
|
|
+ coord: [startIndex, line.points[0].yValue]
|
|
|
+ }, {
|
|
|
+ coord: [endIndex, line.points[1].yValue]
|
|
|
+ }]);
|
|
|
+ }
|
|
|
+ } else if (line.type === 'trendExtended' && line.points.length === 2) {
|
|
|
+ const startIndex = xAxis.data.indexOf(line.points[0].date!);
|
|
|
+ const endIndex = xAxis.data.indexOf(line.points[1].date!);
|
|
|
+
|
|
|
+ if (startIndex !== -1 && endIndex !== -1) {
|
|
|
+ // 使用抽取的方法计算延伸线坐标
|
|
|
+ const coords = this.calculateExtendedTrendLineCoords(
|
|
|
+ { x: startIndex, y: line.points[0].yValue },
|
|
|
+ { x: endIndex, y: line.points[1].yValue },
|
|
|
+ viewRange,
|
|
|
+ yRange
|
|
|
+ );
|
|
|
+
|
|
|
+ markLineData.push([{
|
|
|
+ coord: [coords.left.x, coords.left.y],
|
|
|
+ symbol: 'none'
|
|
|
+ }, {
|
|
|
+ coord: [coords.right.x, coords.right.y],
|
|
|
+ symbol: 'none'
|
|
|
+ }]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const series = (option.series as any[]) || [];
|
|
|
+
|
|
|
+ if (series[0]) {
|
|
|
+ series[0].markLine = {
|
|
|
+ animation: false,
|
|
|
+ symbol: ['none', 'none'],
|
|
|
+ lineStyle: {
|
|
|
+ width: 1,
|
|
|
+ type: 'solid'
|
|
|
+ },
|
|
|
+ data: markLineData
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ this.chart.setOption({
|
|
|
+ series: series
|
|
|
+ }, { replaceMerge: ['series'] });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取实际X轴坐标
|
|
|
+ private getActualX(xRatio: number, xAxis: XAxisOption): number {
|
|
|
+ const dataCount = xAxis.data.length;
|
|
|
+ return Math.floor(xRatio * dataCount);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取相对X轴位置
|
|
|
+ private getXRatio(x: number, xAxis: XAxisOption): number {
|
|
|
+ const dataCount = xAxis.data.length;
|
|
|
+ return x / dataCount;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绑定事件处理器
|
|
|
+ private bindEvents(): void {
|
|
|
+ const zr = this.chart.getZr();
|
|
|
+
|
|
|
+ zr.on('mousedown', (params: { offsetX: number; offsetY: number }) => {
|
|
|
+ if (!this.isDrawing || !this.currentLineType || !this.canStartNewLine) return;
|
|
|
+
|
|
|
+ // 如果是趋势线的第二个点,不创建新的 tempLine
|
|
|
+ if (this.tempLine && !this.isTrendFirstPoint) return;
|
|
|
+
|
|
|
+ const point = this.chart.convertFromPixel({ seriesIndex: 0 }, [
|
|
|
+ params.offsetX,
|
|
|
+ params.offsetY
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (!point) return;
|
|
|
+
|
|
|
+ const option = this.chart.getOption() as EChartsOption;
|
|
|
+ const xAxis = (option.xAxis as XAxisOption[])[0];
|
|
|
+ const xRatio = this.getXRatio(point[0], xAxis);
|
|
|
+
|
|
|
+ // 记录日期信息
|
|
|
+ const dataIndex = Math.floor(point[0]);
|
|
|
+ const date = xAxis.data[dataIndex];
|
|
|
+
|
|
|
+ this.tempLine = {
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ type: this.currentLineType,
|
|
|
+ points: [{
|
|
|
+ xRatio,
|
|
|
+ yValue: point[1],
|
|
|
+ dataIndex,
|
|
|
+ date
|
|
|
+ }]
|
|
|
+ };
|
|
|
+
|
|
|
+ if (this.currentLineType === 'horizontal') {
|
|
|
+ this.updatePreview();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ zr.on('mousemove', (params: { offsetX: number; offsetY: number }) => {
|
|
|
+ if (!this.isDrawing || !this.tempLine) return;
|
|
|
+
|
|
|
+ const point = this.chart.convertFromPixel({ seriesIndex: 0 }, [
|
|
|
+ params.offsetX,
|
|
|
+ params.offsetY
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (!point) return;
|
|
|
+
|
|
|
+ const option = this.chart.getOption() as EChartsOption;
|
|
|
+ const xAxis = (option.xAxis as XAxisOption[])[0];
|
|
|
+ const xRatio = this.getXRatio(point[0], xAxis);
|
|
|
+ const dataIndex = Math.floor(point[0]); // 计算当前点的索引
|
|
|
+
|
|
|
+ if (this.tempLine.type === 'horizontal') {
|
|
|
+ this.tempLine.points = [{
|
|
|
+ xRatio: 0,
|
|
|
+ yValue: point[1]
|
|
|
+ }];
|
|
|
+ this.updatePreview();
|
|
|
+ } else if (this.tempLine.type === 'trend' || this.tempLine.type === 'trendExtended') {
|
|
|
+ if (this.tempLine.points.length > 0) {
|
|
|
+ const previewPoints = [
|
|
|
+ this.tempLine.points[0],
|
|
|
+ {
|
|
|
+ xRatio,
|
|
|
+ yValue: point[1],
|
|
|
+ dataIndex // 添加 dataIndex
|
|
|
+ }
|
|
|
+ ];
|
|
|
+
|
|
|
+ this.updatePreviewWithPoints(previewPoints);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ zr.on('mouseup', (params: { offsetX: number; offsetY: number }) => {
|
|
|
+ if (!this.isDrawing || !this.tempLine) return;
|
|
|
+
|
|
|
+ if (this.tempLine.type === 'trend' || this.tempLine.type === 'trendExtended') {
|
|
|
+ if (this.isTrendFirstPoint) {
|
|
|
+ this.isTrendFirstPoint = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const point = this.chart.convertFromPixel({ seriesIndex: 0 }, [
|
|
|
+ params.offsetX,
|
|
|
+ params.offsetY
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (!point) return;
|
|
|
+
|
|
|
+ const option = this.chart.getOption() as EChartsOption;
|
|
|
+ const xAxis = (option.xAxis as XAxisOption[])[0];
|
|
|
+ const xRatio = this.getXRatio(point[0], xAxis);
|
|
|
+
|
|
|
+ // 记录第二个点的信息
|
|
|
+ const dataIndex = Math.floor(point[0]);
|
|
|
+ const date = xAxis.data[dataIndex];
|
|
|
+
|
|
|
+ // 确保两个点不重合
|
|
|
+ if (this.tempLine.points[0].xRatio === xRatio) {
|
|
|
+ this.tempLine = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.tempLine.points.push({
|
|
|
+ xRatio,
|
|
|
+ yValue: point[1],
|
|
|
+ dataIndex,
|
|
|
+ date
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ this.lines.set(this.tempLine.id, this.tempLine);
|
|
|
+ this.updateChart();
|
|
|
+ const currentType = this.tempLine.type;
|
|
|
+ this.tempLine = null;
|
|
|
+ this.canStartNewLine = true;
|
|
|
+ if (currentType === 'trend' || currentType === 'trendExtended') {
|
|
|
+ this.isTrendFirstPoint = true;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ this.chart.on('datazoom', () => {
|
|
|
+ this.updateChart();
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ private getYAxisRange(): { min: number; max: number } {
|
|
|
+ const option = this.chart.getOption();
|
|
|
+ const dataZoom = (option.dataZoom as DataZoomOption[]) || [];
|
|
|
+ const series = (option.series as any[])[0];
|
|
|
+
|
|
|
+ // 获取当前视图范围
|
|
|
+ const startIndex = dataZoom[0]?.startValue ?? 0;
|
|
|
+ const endIndex = dataZoom[0]?.endValue ?? (series.data.length - 1);
|
|
|
+
|
|
|
+ // 获取可见区域的数据
|
|
|
+ const visibleData = series.data.slice(startIndex, endIndex + 1);
|
|
|
+
|
|
|
+ // 计算可见区域的最大最小值
|
|
|
+ let yMin = Infinity;
|
|
|
+ let yMax = -Infinity;
|
|
|
+
|
|
|
+ visibleData.forEach((item: any) => {
|
|
|
+ const values = item.value || item;
|
|
|
+ // K线数据格式为 [open, close, low, high]
|
|
|
+ const low = parseFloat(values[2]); // low
|
|
|
+ const high = parseFloat(values[3]); // high
|
|
|
+
|
|
|
+ if (!isNaN(low)) yMin = Math.min(yMin, low);
|
|
|
+ if (!isNaN(high)) yMax = Math.max(yMax, high);
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ min: yMin,
|
|
|
+ max: yMax
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ // 添加新方法用于预览时的点更新
|
|
|
+ private updatePreviewWithPoints(points: PreviewPoint[]): void {
|
|
|
+ if (!this.tempLine) return;
|
|
|
+
|
|
|
+ const option = this.chart.getOption() as EChartsOption;
|
|
|
+ const xAxis = (option.xAxis as XAxisOption[])[0];
|
|
|
+ const series = (option.series as any[]) || [];
|
|
|
+ const currentSeries = series[0] || {};
|
|
|
+
|
|
|
+ let previewData;
|
|
|
+
|
|
|
+ if (this.tempLine.type === 'trend') {
|
|
|
+ // 保持原有趋势线预览逻辑
|
|
|
+ previewData = [
|
|
|
+ {
|
|
|
+ coord: [
|
|
|
+ this.getActualX(points[0].xRatio, xAxis),
|
|
|
+ points[0].yValue
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ coord: [
|
|
|
+ this.getActualX(points[1].xRatio, xAxis),
|
|
|
+ points[1].yValue
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ } else if (this.tempLine.type === 'trendExtended') {
|
|
|
+ const chartOption = this.chart.getOption();
|
|
|
+ const dataZoom = (chartOption.dataZoom as DataZoomOption[]) || [];
|
|
|
+
|
|
|
+ const viewStartIndex = dataZoom[0]?.startValue ?? 0;
|
|
|
+ const viewEndIndex = dataZoom[0]?.endValue ?? (xAxis.data.length - 1);
|
|
|
+
|
|
|
+ const actualStartX = this.getActualX(points[0].xRatio, xAxis);
|
|
|
+ const actualStartY = points[0].yValue;
|
|
|
+ const actualEndX = this.getActualX(points[1].xRatio, xAxis);
|
|
|
+ const actualEndY = points[1].yValue;
|
|
|
+
|
|
|
+ const { min, max } = this.getYAxisRange();
|
|
|
+
|
|
|
+ // 使用抽取的方法计算延伸线坐标
|
|
|
+ const coords = this.calculateExtendedTrendLineCoords(
|
|
|
+ { x: actualStartX, y: actualStartY },
|
|
|
+ { x: actualEndX, y: actualEndY },
|
|
|
+ { start: viewStartIndex, end: viewEndIndex },
|
|
|
+ { min, max }
|
|
|
+ );
|
|
|
+
|
|
|
+ previewData = [
|
|
|
+ {
|
|
|
+ coord: [coords.left.x, coords.left.y]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ coord: [coords.right.x, coords.right.y]
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (previewData) { // 只在有预览数据时更新
|
|
|
+ this.chart.setOption({
|
|
|
+ series: [{
|
|
|
+ ...currentSeries,
|
|
|
+ markLine: {
|
|
|
+ animation: false,
|
|
|
+ symbol: ['none', 'none'],
|
|
|
+ lineStyle: {
|
|
|
+ width: 1,
|
|
|
+ type: 'dashed',
|
|
|
+ color: '#999'
|
|
|
+ },
|
|
|
+ data: [previewData]
|
|
|
+ }
|
|
|
+ }]
|
|
|
+ }, { replaceMerge: ['series'] });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新预览线
|
|
|
+ private updatePreview(): void {
|
|
|
+ if (!this.tempLine) return;
|
|
|
+
|
|
|
+ const option = this.chart.getOption() as EChartsOption;
|
|
|
+ const previewData: any[] = [];
|
|
|
+
|
|
|
+ if (this.tempLine.type === 'horizontal') {
|
|
|
+ previewData.push({
|
|
|
+ yAxis: this.tempLine.points[0].yValue,
|
|
|
+ lineStyle: {
|
|
|
+ type: 'dashed',
|
|
|
+ color: '#999'
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } else if (this.tempLine.points.length === 2) {
|
|
|
+ const xAxis = (option.xAxis as XAxisOption[])[0];
|
|
|
+ const start = this.getActualX(this.tempLine.points[0].xRatio, xAxis);
|
|
|
+ const end = this.getActualX(this.tempLine.points[1].xRatio, xAxis);
|
|
|
+
|
|
|
+ previewData.push([{
|
|
|
+ coord: [start, this.tempLine.points[0].yValue]
|
|
|
+ }, {
|
|
|
+ coord: [end, this.tempLine.points[1].yValue]
|
|
|
+ }]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取当前的系列配置
|
|
|
+ const series = (option.series as any[]) || [];
|
|
|
+ const currentSeries = series[0] || {};
|
|
|
+
|
|
|
+ // 更新或添加 markLine 到现有系列
|
|
|
+ this.chart.setOption({
|
|
|
+ series: [{
|
|
|
+ ...currentSeries, // 保留现有系列的配置
|
|
|
+ markLine: {
|
|
|
+ animation: false,
|
|
|
+ symbol: ['none', 'none'],
|
|
|
+ lineStyle: {
|
|
|
+ width: 1,
|
|
|
+ type: 'dashed',
|
|
|
+ color: '#999'
|
|
|
+ },
|
|
|
+ data: previewData
|
|
|
+ }
|
|
|
+ }]
|
|
|
+ }, { replaceMerge: ['series'] });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加重绘线条的方法
|
|
|
+ public redrawLines(): void {
|
|
|
+ if (this.lines.size > 0) {
|
|
|
+ this.updateChart();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加计算延伸趋势线坐标的方法
|
|
|
+ private calculateExtendedTrendLineCoords(
|
|
|
+ startPoint: { x: number; y: number },
|
|
|
+ endPoint: { x: number; y: number },
|
|
|
+ viewRange: { start: number; end: number },
|
|
|
+ yRange: { min: number; max: number }
|
|
|
+ ): { left: { x: number; y: number }; right: { x: number; y: number } } {
|
|
|
+ // 计算斜率
|
|
|
+ const slope = (endPoint.y - startPoint.y) / (endPoint.x - startPoint.x);
|
|
|
+
|
|
|
+ // 计算左边延伸点
|
|
|
+ let leftX = viewRange.start;
|
|
|
+ let leftY = startPoint.y - slope * (startPoint.x - leftX);
|
|
|
+
|
|
|
+ // 如果y值超出范围,锁定y到边界值并反推x
|
|
|
+ if (leftY < yRange.min || leftY > yRange.max) {
|
|
|
+ leftY = leftY < yRange.min ? yRange.min : yRange.max;
|
|
|
+ leftX = startPoint.x - (startPoint.y - leftY) / slope;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算右边延伸点
|
|
|
+ let rightX = viewRange.end;
|
|
|
+ let rightY = endPoint.y + slope * (rightX - endPoint.x);
|
|
|
+
|
|
|
+ // 如果y值超出范围,锁定y到边界值并反推x
|
|
|
+ if (rightY < yRange.min || rightY > yRange.max) {
|
|
|
+ rightY = rightY < yRange.min ? yRange.min : yRange.max;
|
|
|
+ rightX = endPoint.x + (rightY - endPoint.y) / slope;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ left: {
|
|
|
+ x: Math.ceil(leftX),
|
|
|
+ y: leftY
|
|
|
+ },
|
|
|
+ right: {
|
|
|
+ x: Math.ceil(rightX),
|
|
|
+ y: rightY
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|