radiant-charts-core 0.2.0
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/LICENSE +21 -0
- package/LICENSE.md +21 -0
- package/dist/index.d.mts +431 -0
- package/dist/index.d.ts +431 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +9 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -0
- package/src/Declarative.tsx +503 -0
- package/src/RadiantChart.tsx +446 -0
- package/src/ResponsiveContainer.tsx +128 -0
- package/src/axes/CartesianAxis.ts +305 -0
- package/src/core/Animator.ts +119 -0
- package/src/core/ChartManager.ts +1062 -0
- package/src/core/CrosshairManager.ts +334 -0
- package/src/core/Legend.ts +269 -0
- package/src/core/ThemeManager.ts +98 -0
- package/src/index.ts +31 -0
- package/src/scale/Scale.ts +99 -0
- package/src/scene/Node.ts +183 -0
- package/src/scene/Scene.ts +197 -0
- package/src/scene/Shapes.ts +446 -0
- package/src/series/AreaSeries.ts +315 -0
- package/src/series/BarSeries.ts +502 -0
- package/src/series/LineSeries.ts +284 -0
- package/src/series/PieSeries.ts +203 -0
- package/src/series/ScatterSeries.ts +305 -0
- package/src/tooltip/TooltipContext.ts +22 -0
- package/src/tooltip/TooltipStore.ts +169 -0
- package/src/tooltip/__tests__/TooltipStore.test.ts +176 -0
- package/src/tooltip/coordUtils.ts +41 -0
- package/src/tooltip/index.ts +18 -0
- package/src/tooltip/types.ts +57 -0
- package/src/tooltip/useChartTooltip.ts +43 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { default as RadiantChart } from './RadiantChart';
|
|
2
|
+
export type {
|
|
3
|
+
RadiantChartOptions,
|
|
4
|
+
AnimationOptions,
|
|
5
|
+
DataLabelOptions,
|
|
6
|
+
RadiantChartHandle,
|
|
7
|
+
DropShadow,
|
|
8
|
+
MarkerOptions,
|
|
9
|
+
MarkerShape,
|
|
10
|
+
} from './RadiantChart';
|
|
11
|
+
export { default as ResponsiveContainer } from './ResponsiveContainer';
|
|
12
|
+
|
|
13
|
+
// ── Declarative JSX API ───────────────────────────────────────────────────────
|
|
14
|
+
export {
|
|
15
|
+
Chart,
|
|
16
|
+
Bar, Line, Area, Scatter, Pie, Donut,
|
|
17
|
+
XAxis, YAxis, YAxisRight, Title, Subtitle, Legend,
|
|
18
|
+
Tooltip,
|
|
19
|
+
} from './Declarative';
|
|
20
|
+
export type { ChartProps, AxisProps, TitleProps, LegendProps, TooltipProps } from './Declarative';
|
|
21
|
+
|
|
22
|
+
export { useChartTooltip } from './tooltip/useChartTooltip';
|
|
23
|
+
export type { ChartTooltipContext } from './tooltip/useChartTooltip';
|
|
24
|
+
export type {
|
|
25
|
+
TooltipOptions,
|
|
26
|
+
TooltipState,
|
|
27
|
+
TooltipDatumEntry,
|
|
28
|
+
TooltipTrigger,
|
|
29
|
+
TooltipMode,
|
|
30
|
+
TooltipPositionMode,
|
|
31
|
+
} from './tooltip/types';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Scale interface.
|
|
3
|
+
*/
|
|
4
|
+
export interface Scale<D, R> {
|
|
5
|
+
domain: D[];
|
|
6
|
+
range: R[];
|
|
7
|
+
convert(value: D): R;
|
|
8
|
+
invert(value: R): D;
|
|
9
|
+
ticks?(count?: number): D[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* LinearScale: domain [min, max] to range [minPixel, maxPixel].
|
|
14
|
+
*/
|
|
15
|
+
export class LinearScale implements Scale<number, number> {
|
|
16
|
+
domain: number[] = [0, 1];
|
|
17
|
+
range: number[] = [0, 1];
|
|
18
|
+
|
|
19
|
+
convert(value: number): number {
|
|
20
|
+
const [d0, d1] = this.domain;
|
|
21
|
+
const [r0, r1] = this.range;
|
|
22
|
+
if (d1 === d0) return r0;
|
|
23
|
+
return r0 + ((value - d0) / (d1 - d0)) * (r1 - r0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
invert(value: number): number {
|
|
27
|
+
const [d0, d1] = this.domain;
|
|
28
|
+
const [r0, r1] = this.range;
|
|
29
|
+
if (r1 === r0) return d0;
|
|
30
|
+
return d0 + ((value - r0) / (r1 - r0)) * (d1 - d0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generates "nice" ticks for the axis.
|
|
35
|
+
*/
|
|
36
|
+
ticks(count: number = 5): number[] {
|
|
37
|
+
const [d0, d1] = this.domain;
|
|
38
|
+
const step = (d1 - d0) / count;
|
|
39
|
+
const magnitude = Math.pow(10, Math.floor(Math.log10(step)));
|
|
40
|
+
const normalizedStep = step / magnitude;
|
|
41
|
+
|
|
42
|
+
let niceStep;
|
|
43
|
+
if (normalizedStep < 1.5) niceStep = 1;
|
|
44
|
+
else if (normalizedStep < 3) niceStep = 2;
|
|
45
|
+
else if (normalizedStep < 7) niceStep = 5;
|
|
46
|
+
else niceStep = 10;
|
|
47
|
+
|
|
48
|
+
const finalStep = niceStep * magnitude;
|
|
49
|
+
const start = Math.ceil(d0 / finalStep) * finalStep;
|
|
50
|
+
const end = Math.floor(d1 / finalStep) * finalStep;
|
|
51
|
+
|
|
52
|
+
const ticks = [];
|
|
53
|
+
for (let t = start; t <= end; t += finalStep) {
|
|
54
|
+
ticks.push(t);
|
|
55
|
+
}
|
|
56
|
+
return ticks;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* BandScale: For categorical data.
|
|
62
|
+
*/
|
|
63
|
+
export class BandScale implements Scale<string, number> {
|
|
64
|
+
domain: string[] = [];
|
|
65
|
+
range: number[] = [0, 1];
|
|
66
|
+
padding: number = 0.1;
|
|
67
|
+
|
|
68
|
+
convert(value: string): number {
|
|
69
|
+
const index = this.domain.indexOf(value);
|
|
70
|
+
if (index === -1) return 0;
|
|
71
|
+
|
|
72
|
+
const [r0, r1] = this.range;
|
|
73
|
+
const step = (r1 - r0) / this.domain.length;
|
|
74
|
+
return r0 + index * step + (step * this.padding) / 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
invert(value: number): string {
|
|
78
|
+
const [r0, r1] = this.range;
|
|
79
|
+
const step = (r1 - r0) / this.domain.length;
|
|
80
|
+
const index = Math.floor((value - r0) / step);
|
|
81
|
+
return this.domain[index] || '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getBandwidth(): number {
|
|
85
|
+
const [r0, r1] = this.range;
|
|
86
|
+
const step = (r1 - r0) / this.domain.length;
|
|
87
|
+
return Math.abs(step * (1 - this.padding));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ticks(): string[] {
|
|
91
|
+
return this.domain;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function safeBandwidth(scale: Scale<any, number> | BandScale): number {
|
|
96
|
+
return typeof (scale as any).getBandwidth === 'function'
|
|
97
|
+
? (scale as BandScale).getBandwidth()
|
|
98
|
+
: 0;
|
|
99
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
export interface BBox {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Base Node class for the Radiant Chart Scene Graph.
|
|
10
|
+
*/
|
|
11
|
+
export abstract class Node {
|
|
12
|
+
id: string = Math.random().toString(36).substr(2, 9);
|
|
13
|
+
x: number = 0;
|
|
14
|
+
y: number = 0;
|
|
15
|
+
visible: boolean = true;
|
|
16
|
+
opacity: number = 1;
|
|
17
|
+
translation: { x: number; y: number } = { x: 0, y: 0 };
|
|
18
|
+
rotation: number = 0; // Rotation in radians
|
|
19
|
+
scaling: { x: number; y: number } = { x: 1, y: 1 };
|
|
20
|
+
|
|
21
|
+
parent: Node | null = null;
|
|
22
|
+
children: Node[] = [];
|
|
23
|
+
|
|
24
|
+
// Hit testing properties
|
|
25
|
+
pointerEvents: boolean = true;
|
|
26
|
+
|
|
27
|
+
// Clipping
|
|
28
|
+
clipRect: BBox | null = null;
|
|
29
|
+
|
|
30
|
+
// Data mapping
|
|
31
|
+
datum: any = null;
|
|
32
|
+
seriesId: string = '';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Optional drop shadow applied to this node before its shape paints.
|
|
36
|
+
* Maps to canvas `shadow*` properties; reset automatically by the
|
|
37
|
+
* surrounding save/restore so it never bleeds onto siblings.
|
|
38
|
+
*/
|
|
39
|
+
shadow: { color?: string; blur?: number; offsetX?: number; offsetY?: number } | null = null;
|
|
40
|
+
|
|
41
|
+
constructor() {}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Adds a child node.
|
|
45
|
+
*/
|
|
46
|
+
add(child: Node) {
|
|
47
|
+
if (child.parent) {
|
|
48
|
+
child.parent.remove(child);
|
|
49
|
+
}
|
|
50
|
+
child.parent = this;
|
|
51
|
+
this.children.push(child);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Removes a child node.
|
|
56
|
+
*/
|
|
57
|
+
remove(child: Node) {
|
|
58
|
+
const index = this.children.indexOf(child);
|
|
59
|
+
if (index !== -1) {
|
|
60
|
+
this.children.splice(index, 1);
|
|
61
|
+
child.parent = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Clears all children.
|
|
67
|
+
*/
|
|
68
|
+
clear() {
|
|
69
|
+
this.children.forEach(child => child.parent = null);
|
|
70
|
+
this.children = [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Renders the node and its children.
|
|
75
|
+
*/
|
|
76
|
+
render(ctx: CanvasRenderingContext2D) {
|
|
77
|
+
if (!this.visible || this.opacity === 0) return;
|
|
78
|
+
|
|
79
|
+
// Optimization: only save/restore if we have transformations, opacity change, or clipping.
|
|
80
|
+
// This avoids overhead for thousands of simple nodes in large datasets.
|
|
81
|
+
const hasTransform = (this.translation.x !== 0 || this.translation.y !== 0) ||
|
|
82
|
+
(this.rotation !== 0) ||
|
|
83
|
+
(this.scaling.x !== 1 || this.scaling.y !== 1);
|
|
84
|
+
const hasShadow = this.shadow !== null;
|
|
85
|
+
const hasEffect = (this.opacity !== 1) || (this.clipRect !== null) || hasShadow;
|
|
86
|
+
|
|
87
|
+
if (!hasTransform && !hasEffect) {
|
|
88
|
+
this.renderShape(ctx);
|
|
89
|
+
for (const child of this.children) {
|
|
90
|
+
child.render(ctx);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ctx.save();
|
|
96
|
+
|
|
97
|
+
// Apply transformations
|
|
98
|
+
if (this.translation.x !== 0 || this.translation.y !== 0) {
|
|
99
|
+
ctx.translate(this.translation.x, this.translation.y);
|
|
100
|
+
}
|
|
101
|
+
if (this.rotation !== 0) {
|
|
102
|
+
ctx.rotate(this.rotation);
|
|
103
|
+
}
|
|
104
|
+
if (this.scaling.x !== 1 || this.scaling.y !== 1) {
|
|
105
|
+
ctx.scale(this.scaling.x, this.scaling.y);
|
|
106
|
+
}
|
|
107
|
+
if (this.opacity !== 1) {
|
|
108
|
+
ctx.globalAlpha *= this.opacity;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Apply clipping if defined
|
|
112
|
+
if (this.clipRect) {
|
|
113
|
+
ctx.beginPath();
|
|
114
|
+
ctx.rect(this.clipRect.x, this.clipRect.y, this.clipRect.width, this.clipRect.height);
|
|
115
|
+
ctx.clip();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Apply drop shadow before painting this node. We deliberately reset
|
|
119
|
+
// before recursing into children so the shadow does not double-apply
|
|
120
|
+
// to nested shapes (the canvas shadowColor is sticky across draw calls).
|
|
121
|
+
if (this.shadow) {
|
|
122
|
+
ctx.shadowColor = this.shadow.color ?? 'rgba(0,0,0,0.25)';
|
|
123
|
+
ctx.shadowBlur = this.shadow.blur ?? 4;
|
|
124
|
+
ctx.shadowOffsetX = this.shadow.offsetX ?? 0;
|
|
125
|
+
ctx.shadowOffsetY = this.shadow.offsetY ?? 2;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Abstract method to render the actual shape
|
|
129
|
+
this.renderShape(ctx);
|
|
130
|
+
|
|
131
|
+
if (this.shadow) {
|
|
132
|
+
ctx.shadowColor = 'rgba(0,0,0,0)';
|
|
133
|
+
ctx.shadowBlur = 0;
|
|
134
|
+
ctx.shadowOffsetX = 0;
|
|
135
|
+
ctx.shadowOffsetY = 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Render children
|
|
139
|
+
for (const child of this.children) {
|
|
140
|
+
child.render(ctx);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ctx.restore();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Hit testing: Returns the topmost node at (x, y) coordinates relative to this node.
|
|
148
|
+
*/
|
|
149
|
+
pickNode(x: number, y: number): Node | null {
|
|
150
|
+
if (!this.visible || !this.pointerEvents) return null;
|
|
151
|
+
|
|
152
|
+
// Transform coordinates for children
|
|
153
|
+
const localX = (x - this.translation.x) / this.scaling.x;
|
|
154
|
+
const localY = (y - this.translation.y) / this.scaling.y;
|
|
155
|
+
// Note: Rotation not handled in coordinate transform yet
|
|
156
|
+
|
|
157
|
+
// Check children in reverse order (topmost first)
|
|
158
|
+
for (let i = this.children.length - 1; i >= 0; i--) {
|
|
159
|
+
const picked = this.children[i].pickNode(localX, localY);
|
|
160
|
+
if (picked) return picked;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check this node
|
|
164
|
+
if (this.isPointInNode(localX, localY)) {
|
|
165
|
+
return this;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
abstract renderShape(ctx: CanvasRenderingContext2D): void;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hit testing: Returns true if the (x, y) coordinates (relative to parent) are within the node's bounds.
|
|
175
|
+
*/
|
|
176
|
+
abstract isPointInNode(x: number, y: number): boolean;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Calculate bounding box (optional for now, but useful for layout).
|
|
180
|
+
*/
|
|
181
|
+
abstract getBBox(): BBox;
|
|
182
|
+
}
|
|
183
|
+
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Group } from './Shapes';
|
|
2
|
+
import { Node } from './Node';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Scene class: Manages the canvas element and the root of the scene graph.
|
|
6
|
+
*/
|
|
7
|
+
export class Scene {
|
|
8
|
+
private container: HTMLElement;
|
|
9
|
+
private canvas: HTMLCanvasElement;
|
|
10
|
+
private ctx: CanvasRenderingContext2D;
|
|
11
|
+
readonly root: Group = new Group();
|
|
12
|
+
|
|
13
|
+
private width: number = 0;
|
|
14
|
+
private height: number = 0;
|
|
15
|
+
private pixelRatio: number = 1;
|
|
16
|
+
|
|
17
|
+
onHover?: (node: any, event: MouseEvent) => void;
|
|
18
|
+
onClick?: (node: any, event: MouseEvent) => void;
|
|
19
|
+
onDblClick?: (node: any, event: MouseEvent) => void;
|
|
20
|
+
onLongPress?: (node: any, event: MouseEvent) => void;
|
|
21
|
+
|
|
22
|
+
private _longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
private _longPressFired = false;
|
|
24
|
+
|
|
25
|
+
/** Background color painted before the scene graph on every render frame. */
|
|
26
|
+
backgroundColor = '';
|
|
27
|
+
|
|
28
|
+
constructor(container: HTMLElement) {
|
|
29
|
+
this.container = container;
|
|
30
|
+
this.canvas = document.createElement('canvas');
|
|
31
|
+
this.canvas.style.position = 'absolute';
|
|
32
|
+
this.canvas.style.top = '0';
|
|
33
|
+
this.canvas.style.left = '0';
|
|
34
|
+
this.canvas.style.width = '100%';
|
|
35
|
+
this.canvas.style.height = '100%';
|
|
36
|
+
this.canvas.style.display = 'block';
|
|
37
|
+
this.canvas.style.zIndex = '1';
|
|
38
|
+
|
|
39
|
+
container.appendChild(this.canvas);
|
|
40
|
+
|
|
41
|
+
const ctx = this.canvas.getContext('2d');
|
|
42
|
+
if (!ctx) {
|
|
43
|
+
throw new Error('Could not get Canvas 2D context');
|
|
44
|
+
}
|
|
45
|
+
this.ctx = ctx;
|
|
46
|
+
|
|
47
|
+
this.resize();
|
|
48
|
+
this.setupEvents();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private setupEvents() {
|
|
52
|
+
this.canvas.addEventListener('mousemove', (e) => {
|
|
53
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
54
|
+
const x = e.clientX - rect.left;
|
|
55
|
+
const y = e.clientY - rect.top;
|
|
56
|
+
|
|
57
|
+
const node = this.root.pickNode(x, y);
|
|
58
|
+
if (this.onHover) {
|
|
59
|
+
this.onHover(node, e);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this.canvas.addEventListener('mouseleave', (e) => {
|
|
64
|
+
if (this.onHover) {
|
|
65
|
+
this.onHover(null, e);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.canvas.addEventListener('click', (e) => {
|
|
70
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
71
|
+
const x = e.clientX - rect.left;
|
|
72
|
+
const y = e.clientY - rect.top;
|
|
73
|
+
|
|
74
|
+
const node = this.root.pickNode(x, y);
|
|
75
|
+
if (this.onClick) {
|
|
76
|
+
this.onClick(node, e);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
this.canvas.addEventListener('dblclick', (e) => {
|
|
81
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
82
|
+
const x = e.clientX - rect.left;
|
|
83
|
+
const y = e.clientY - rect.top;
|
|
84
|
+
|
|
85
|
+
const node = this.root.pickNode(x, y);
|
|
86
|
+
if (this.onDblClick) {
|
|
87
|
+
this.onDblClick(node, e);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Touch events — map to hover/click for tooltip on touch devices
|
|
92
|
+
this.canvas.addEventListener('touchstart', (e) => {
|
|
93
|
+
if (e.touches.length !== 1) return;
|
|
94
|
+
const touch = e.touches[0];
|
|
95
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
96
|
+
const x = touch.clientX - rect.left;
|
|
97
|
+
const y = touch.clientY - rect.top;
|
|
98
|
+
|
|
99
|
+
const node = this.root.pickNode(x, y);
|
|
100
|
+
// Synthesize a mouse event for the hover/click handlers
|
|
101
|
+
const syntheticMouse = new MouseEvent('mousemove', {
|
|
102
|
+
clientX: touch.clientX,
|
|
103
|
+
clientY: touch.clientY,
|
|
104
|
+
});
|
|
105
|
+
if (this.onHover) this.onHover(node, syntheticMouse);
|
|
106
|
+
|
|
107
|
+
this._longPressFired = false;
|
|
108
|
+
if (this._longPressTimer) clearTimeout(this._longPressTimer);
|
|
109
|
+
this._longPressTimer = setTimeout(() => {
|
|
110
|
+
this._longPressFired = true;
|
|
111
|
+
const longPressMouse = new MouseEvent('click', {
|
|
112
|
+
clientX: touch.clientX,
|
|
113
|
+
clientY: touch.clientY,
|
|
114
|
+
});
|
|
115
|
+
if (this.onLongPress) this.onLongPress(node, longPressMouse);
|
|
116
|
+
}, 500);
|
|
117
|
+
}, { passive: true });
|
|
118
|
+
|
|
119
|
+
this.canvas.addEventListener('touchend', (e) => {
|
|
120
|
+
// Cancel long-press timer
|
|
121
|
+
if (this._longPressTimer) { clearTimeout(this._longPressTimer); this._longPressTimer = null; }
|
|
122
|
+
|
|
123
|
+
if (e.changedTouches.length !== 1) return;
|
|
124
|
+
// Skip normal click if long-press already fired
|
|
125
|
+
if (this._longPressFired) { this._longPressFired = false; return; }
|
|
126
|
+
|
|
127
|
+
const touch = e.changedTouches[0];
|
|
128
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
129
|
+
const x = touch.clientX - rect.left;
|
|
130
|
+
const y = touch.clientY - rect.top;
|
|
131
|
+
|
|
132
|
+
const node = this.root.pickNode(x, y);
|
|
133
|
+
const syntheticMouse = new MouseEvent('click', {
|
|
134
|
+
clientX: touch.clientX,
|
|
135
|
+
clientY: touch.clientY,
|
|
136
|
+
});
|
|
137
|
+
if (this.onClick) this.onClick(node, syntheticMouse);
|
|
138
|
+
}, { passive: true });
|
|
139
|
+
|
|
140
|
+
this.canvas.addEventListener('touchmove', (e) => {
|
|
141
|
+
// Cancel long-press on drag
|
|
142
|
+
if (this._longPressTimer) { clearTimeout(this._longPressTimer); this._longPressTimer = null; }
|
|
143
|
+
if (e.touches.length !== 1) return;
|
|
144
|
+
const touch = e.touches[0];
|
|
145
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
146
|
+
const x = touch.clientX - rect.left;
|
|
147
|
+
const y = touch.clientY - rect.top;
|
|
148
|
+
|
|
149
|
+
const node = this.root.pickNode(x, y);
|
|
150
|
+
const syntheticMouse = new MouseEvent('mousemove', {
|
|
151
|
+
clientX: touch.clientX,
|
|
152
|
+
clientY: touch.clientY,
|
|
153
|
+
});
|
|
154
|
+
if (this.onHover) this.onHover(node, syntheticMouse);
|
|
155
|
+
}, { passive: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
resize() {
|
|
159
|
+
const rect = this.container.getBoundingClientRect();
|
|
160
|
+
this.width = rect.width;
|
|
161
|
+
this.height = rect.height;
|
|
162
|
+
|
|
163
|
+
if (this.width === 0 || this.height === 0) return;
|
|
164
|
+
|
|
165
|
+
this.pixelRatio = window.devicePixelRatio || 1;
|
|
166
|
+
|
|
167
|
+
this.canvas.width = this.width * this.pixelRatio;
|
|
168
|
+
this.canvas.height = this.height * this.pixelRatio;
|
|
169
|
+
|
|
170
|
+
this.ctx.resetTransform();
|
|
171
|
+
this.ctx.scale(this.pixelRatio, this.pixelRatio);
|
|
172
|
+
this.render();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
render() {
|
|
176
|
+
if (this.width === 0 || this.height === 0) return;
|
|
177
|
+
|
|
178
|
+
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
179
|
+
|
|
180
|
+
if (this.backgroundColor) {
|
|
181
|
+
this.ctx.fillStyle = this.backgroundColor;
|
|
182
|
+
this.ctx.fillRect(0, 0, this.width, this.height);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.root.render(this.ctx);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getCanvas(): HTMLCanvasElement { return this.canvas; }
|
|
189
|
+
|
|
190
|
+
/** Exposes the 2D context so ChartManager can measure text before layout. */
|
|
191
|
+
getContext(): CanvasRenderingContext2D { return this.ctx; }
|
|
192
|
+
|
|
193
|
+
destroy() {
|
|
194
|
+
if (this._longPressTimer) clearTimeout(this._longPressTimer);
|
|
195
|
+
this.canvas.remove();
|
|
196
|
+
}
|
|
197
|
+
}
|