Skip to content

Commit 68ab1e5

Browse files
committed
add basic line drawing tool
1 parent c1df53b commit 68ab1e5

File tree

7 files changed

+460
-23
lines changed

7 files changed

+460
-23
lines changed

demo/qfchart-demo.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ <h1>QFChart Library Demo</h1>
159159
// Register Measure Tool Plugin
160160
const measureTool = new QFChart.MeasureTool();
161161
chart.registerPlugin(measureTool);
162+
163+
// Register Line Tool Plugin
164+
const lineTool = new QFChart.LineTool();
165+
chart.registerPlugin(lineTool);
162166
});
163167
</script>
164168
</body>

docs/api.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,30 @@ Adds an indicator to the chart.
4242

4343
Removes an indicator by its ID and redraws the layout.
4444

45+
#### `registerPlugin(plugin: Plugin)`
46+
47+
Registers a plugin instance with the chart.
48+
49+
- **plugin**: An object implementing the `Plugin` interface (or extending `AbstractPlugin`).
50+
51+
#### `addDrawing(drawing: DrawingElement)`
52+
53+
Adds a persistent drawing (like a line or shape) to the chart. These drawings move and zoom naturally with the chart.
54+
55+
- **drawing**: Object defining the drawing type and coordinates.
56+
```typescript
57+
interface DrawingElement {
58+
id: string;
59+
type: "line";
60+
points: DataCoordinate[]; // [{ timeIndex, value }, ...]
61+
style?: { color?: string; lineWidth?: number };
62+
}
63+
```
64+
65+
#### `removeDrawing(id: string)`
66+
67+
Removes a drawing by its ID.
68+
4569
#### `resize()`
4670

4771
Manually triggers a resize of the chart. Useful if the container size changes programmatically.

docs/plugins.md

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@ export interface ChartContext {
104104

105105
// Interaction Control
106106
disableTools(): void; // To disable other active tools
107+
108+
// Drawing Management
109+
addDrawing(drawing: DrawingElement): void;
110+
removeDrawing(id: string): void;
111+
}
112+
113+
export interface DrawingElement {
114+
id: string;
115+
type: "line";
116+
points: DataCoordinate[];
117+
style?: {
118+
color?: string;
119+
lineWidth?: number;
120+
};
107121
}
108122
```
109123

@@ -115,20 +129,30 @@ The chart exposes an Event Bus via `context.events` for communication between pl
115129
- `chart:resize`, `chart:dataZoom`, `chart:updated`
116130
- `plugin:activated`, `plugin:deactivated`
117131

118-
### Coordinate Conversion
132+
### Coordinate Conversion & Native Drawings
119133

120-
Use the built-in helpers for easy coordinate mapping:
134+
For persistent drawings (lines, shapes that stick to the chart), you should use the `addDrawing` API instead of manual ZRender management.
135+
136+
1. **Use ZRender for Interaction**: While the user is drawing (dragging mouse), use ZRender graphics (via `chart.getZr()`) for smooth, high-performance feedback.
137+
2. **Use Native Drawings for Persistence**: Once drawing is finished, convert pixels to data coordinates and call `context.addDrawing()`.
138+
139+
#### Example: Creating a Line
121140

122141
```typescript
123-
const dataPoint = this.context.coordinateConversion.pixelToData({
124-
x: 100,
125-
y: 200,
126-
});
127-
// Returns { timeIndex: 45, value: 50000.50 }
128-
129-
const pixelPoint = this.context.coordinateConversion.dataToPixel({
130-
timeIndex: 45,
131-
value: 50000,
132-
});
133-
// Returns { x: 100, y: 200 }
142+
// 1. Convert pixels to data coordinates
143+
const start = this.context.coordinateConversion.pixelToData({ x: 100, y: 200 });
144+
const end = this.context.coordinateConversion.pixelToData({ x: 300, y: 400 });
145+
146+
// 2. Add persistent drawing
147+
if (start && end) {
148+
this.context.addDrawing({
149+
id: "my-line-1",
150+
type: "line",
151+
points: [start, end],
152+
style: {
153+
color: "#3b82f6",
154+
lineWidth: 2,
155+
},
156+
});
157+
}
134158
```

src/QFChart.ts

Lines changed: 190 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,53 @@ export class QFChart implements ChartContext {
2424
private pluginManager: PluginManager;
2525
public events: EventBus = new EventBus();
2626

27+
// Drawing System
28+
private drawings: import("./types").DrawingElement[] = [];
29+
2730
public coordinateConversion = {
2831
pixelToData: (point: { x: number; y: number }) => {
29-
const p = this.chart.convertFromPixel({ seriesIndex: 0 }, [
30-
point.x,
31-
point.y,
32-
]);
33-
if (p) {
34-
return { timeIndex: p[0], value: p[1] };
32+
// Find which grid/pane the point is in
33+
// We iterate through all panes (series indices usually match pane indices for base series)
34+
// Actually, we need to know how many panes there are.
35+
// We can use the layout logic or just check grid indices.
36+
// ECharts instance has getOption().
37+
const option = this.chart.getOption() as any;
38+
if (!option || !option.grid) return null;
39+
40+
const gridCount = option.grid.length;
41+
for (let i = 0; i < gridCount; i++) {
42+
if (this.chart.containPixel({ gridIndex: i }, [point.x, point.y])) {
43+
// Found the pane
44+
const p = this.chart.convertFromPixel({ seriesIndex: i }, [
45+
point.x,
46+
point.y,
47+
]);
48+
// Note: convertFromPixel might need seriesIndex or gridIndex depending on setup.
49+
// Using gridIndex in convertFromPixel is supported in newer ECharts but sometimes tricky.
50+
// Since we have one base series per pane (candlestick at 0, indicators at 1+),
51+
// assuming seriesIndex = gridIndex usually works if they are mapped 1:1.
52+
// Wait, candlestick is series 0. Indicators are subsequent series.
53+
// Series index != grid index necessarily.
54+
// BUT we can use { gridIndex: i } for convertFromPixel too!
55+
const pGrid = this.chart.convertFromPixel({ gridIndex: i }, [
56+
point.x,
57+
point.y,
58+
]);
59+
60+
if (pGrid) {
61+
return { timeIndex: pGrid[0], value: pGrid[1], paneIndex: i };
62+
}
63+
}
3564
}
3665
return null;
3766
},
38-
dataToPixel: (point: { timeIndex: number; value: number }) => {
39-
const p = this.chart.convertToPixel({ seriesIndex: 0 }, [
67+
dataToPixel: (point: {
68+
timeIndex: number;
69+
value: number;
70+
paneIndex?: number;
71+
}) => {
72+
const paneIdx = point.paneIndex || 0;
73+
const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [
4074
point.timeIndex,
4175
point.value,
4276
]);
@@ -159,6 +193,26 @@ export class QFChart implements ChartContext {
159193
this.chart = echarts.init(this.chartContainer);
160194
this.pluginManager = new PluginManager(this, this.toolbarContainer);
161195

196+
// Bind global chart/ZRender events to the EventBus
197+
this.chart.on("dataZoom", (params: any) =>
198+
this.events.emit("chart:dataZoom", params)
199+
);
200+
this.chart.on("finished", (params: any) =>
201+
this.events.emit("chart:updated", params)
202+
); // General chart update
203+
this.chart
204+
.getZr()
205+
.on("mousedown", (params: any) => this.events.emit("mouse:down", params));
206+
this.chart
207+
.getZr()
208+
.on("mousemove", (params: any) => this.events.emit("mouse:move", params));
209+
this.chart
210+
.getZr()
211+
.on("mouseup", (params: any) => this.events.emit("mouse:up", params));
212+
this.chart
213+
.getZr()
214+
.on("click", (params: any) => this.events.emit("mouse:click", params));
215+
162216
window.addEventListener("resize", this.resize.bind(this));
163217
}
164218

@@ -188,6 +242,18 @@ export class QFChart implements ChartContext {
188242
this.pluginManager.register(plugin);
189243
}
190244

245+
// --- Drawing System ---
246+
247+
public addDrawing(drawing: import("./types").DrawingElement): void {
248+
this.drawings.push(drawing);
249+
this.render(); // Re-render to show new drawing
250+
}
251+
252+
public removeDrawing(id: string): void {
253+
this.drawings = this.drawings.filter((d) => d.id !== id);
254+
this.render();
255+
}
256+
191257
// --------------------------------
192258

193259
public setMarketData(data: OHLCV[]): void {
@@ -273,6 +339,30 @@ export class QFChart implements ChartContext {
273339
private render(): void {
274340
if (this.marketData.length === 0) return;
275341

342+
// Capture current zoom state before rebuilding options
343+
let currentZoomState: { start: number; end: number } | null = null;
344+
try {
345+
const currentOption = this.chart.getOption() as any;
346+
if (
347+
currentOption &&
348+
currentOption.dataZoom &&
349+
currentOption.dataZoom.length > 0
350+
) {
351+
// Find the slider or inside zoom component that controls the x-axis
352+
const zoomComponent = currentOption.dataZoom.find(
353+
(dz: any) => dz.type === "slider" || dz.type === "inside"
354+
);
355+
if (zoomComponent) {
356+
currentZoomState = {
357+
start: zoomComponent.start,
358+
end: zoomComponent.end,
359+
};
360+
}
361+
}
362+
} catch (e) {
363+
// Chart might not be initialized yet
364+
}
365+
276366
// --- Sidebar Layout Management ---
277367
const tooltipPos = this.options.tooltip?.position || "floating";
278368
const prevLeftDisplay = this.leftSidebar.style.display;
@@ -303,6 +393,14 @@ export class QFChart implements ChartContext {
303393
this.options
304394
);
305395

396+
// Apply preserved zoom state if available
397+
if (currentZoomState && layout.dataZoom) {
398+
layout.dataZoom.forEach((dz) => {
399+
dz.start = currentZoomState!.start;
400+
dz.end = currentZoomState!.end;
401+
});
402+
}
403+
306404
// Patch X-Axis with Data and Padding
307405
layout.xAxis.forEach((axis) => {
308406
axis.data = categoryData;
@@ -338,7 +436,89 @@ export class QFChart implements ChartContext {
338436
this.toggleIndicator.bind(this)
339437
);
340438

341-
// 4. Tooltip Formatter
439+
// 4. Build Drawings Series (One Custom Series per Pane used)
440+
const drawingsByPane = new Map<
441+
number,
442+
import("./types").DrawingElement[]
443+
>();
444+
this.drawings.forEach((d) => {
445+
const paneIdx = d.paneIndex || 0;
446+
if (!drawingsByPane.has(paneIdx)) {
447+
drawingsByPane.set(paneIdx, []);
448+
}
449+
drawingsByPane.get(paneIdx)!.push(d);
450+
});
451+
452+
const drawingSeriesList: any[] = [];
453+
drawingsByPane.forEach((drawings, paneIndex) => {
454+
drawingSeriesList.push({
455+
type: "custom",
456+
name: `drawings-pane-${paneIndex}`,
457+
xAxisIndex: paneIndex,
458+
yAxisIndex: paneIndex,
459+
renderItem: (params: any, api: any) => {
460+
const drawing = drawings[params.dataIndex];
461+
if (!drawing) return;
462+
463+
const start = drawing.points[0];
464+
const end = drawing.points[1];
465+
466+
if (!start || !end) return;
467+
468+
const p1 = api.coord([start.timeIndex, start.value]);
469+
const p2 = api.coord([end.timeIndex, end.value]);
470+
471+
if (drawing.type === "line") {
472+
return {
473+
type: "group",
474+
children: [
475+
{
476+
type: "line",
477+
shape: {
478+
x1: p1[0],
479+
y1: p1[1],
480+
x2: p2[0],
481+
y2: p2[1],
482+
},
483+
style: {
484+
stroke: drawing.style?.color || "#3b82f6",
485+
lineWidth: drawing.style?.lineWidth || 2,
486+
},
487+
},
488+
{
489+
type: "circle",
490+
shape: { cx: p1[0], cy: p1[1], r: 4 },
491+
style: {
492+
fill: "#fff",
493+
stroke: drawing.style?.color || "#3b82f6",
494+
lineWidth: 1,
495+
},
496+
},
497+
{
498+
type: "circle",
499+
shape: { cx: p2[0], cy: p2[1], r: 4 },
500+
style: {
501+
fill: "#fff",
502+
stroke: drawing.style?.color || "#3b82f6",
503+
lineWidth: 1,
504+
},
505+
},
506+
],
507+
};
508+
}
509+
},
510+
data: drawings.map((d) => [
511+
d.points[0].timeIndex,
512+
d.points[0].value,
513+
d.points[1].timeIndex,
514+
d.points[1].value,
515+
]),
516+
z: 100,
517+
silent: true,
518+
});
519+
});
520+
521+
// 5. Tooltip Formatter
342522
const tooltipFormatter = (params: any[]) => {
343523
const html = TooltipFormatter.format(params, this.options);
344524
const mode = this.options.tooltip?.position || "floating";
@@ -398,7 +578,7 @@ export class QFChart implements ChartContext {
398578
xAxis: layout.xAxis,
399579
yAxis: layout.yAxis,
400580
dataZoom: layout.dataZoom,
401-
series: [candlestickSeries, ...indicatorSeries],
581+
series: [candlestickSeries, ...indicatorSeries, ...drawingSeriesList],
402582
};
403583

404584
// Note: We should preserve any extra options (like custom graphics from plugins)

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from "./types";
22
export * from "./QFChart";
33
export * from "./plugins/MeasureTool";
4+
export * from "./plugins/LineTool";
5+
export * from "./components/AbstractPlugin";

0 commit comments

Comments
 (0)