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
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { Node, BBox } from './Node';
|
|
2
|
+
|
|
3
|
+
// ─── Group ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export class Group extends Node {
|
|
6
|
+
renderShape(_ctx: CanvasRenderingContext2D) {}
|
|
7
|
+
|
|
8
|
+
isPointInNode(_x: number, _y: number): boolean {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
getBBox(): BBox {
|
|
13
|
+
if (this.children.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
|
|
14
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
15
|
+
for (const child of this.children) {
|
|
16
|
+
const b = child.getBBox();
|
|
17
|
+
minX = Math.min(minX, b.x + child.translation.x);
|
|
18
|
+
minY = Math.min(minY, b.y + child.translation.y);
|
|
19
|
+
maxX = Math.max(maxX, b.x + b.width + child.translation.x);
|
|
20
|
+
maxY = Math.max(maxY, b.y + b.height + child.translation.y);
|
|
21
|
+
}
|
|
22
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Rect ─────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export class Rect extends Node {
|
|
29
|
+
width: number = 0;
|
|
30
|
+
height: number = 0;
|
|
31
|
+
fill: string = 'transparent';
|
|
32
|
+
stroke: string = 'transparent';
|
|
33
|
+
strokeWidth: number = 1;
|
|
34
|
+
cornerRadius: number = 0;
|
|
35
|
+
lineDash: number[] = [];
|
|
36
|
+
|
|
37
|
+
renderShape(ctx: CanvasRenderingContext2D) {
|
|
38
|
+
if (this.width <= 0 || this.height <= 0) return;
|
|
39
|
+
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
if (this.cornerRadius > 0) {
|
|
42
|
+
const r = Math.min(this.cornerRadius, this.width / 2, this.height / 2);
|
|
43
|
+
ctx.moveTo(this.x + r, this.y);
|
|
44
|
+
ctx.arcTo(this.x + this.width, this.y, this.x + this.width, this.y + this.height, r);
|
|
45
|
+
ctx.arcTo(this.x + this.width, this.y + this.height, this.x, this.y + this.height, r);
|
|
46
|
+
ctx.arcTo(this.x, this.y + this.height, this.x, this.y, r);
|
|
47
|
+
ctx.arcTo(this.x, this.y, this.x + this.width, this.y, r);
|
|
48
|
+
} else {
|
|
49
|
+
ctx.rect(this.x, this.y, this.width, this.height);
|
|
50
|
+
}
|
|
51
|
+
ctx.closePath();
|
|
52
|
+
|
|
53
|
+
if (this.fill !== 'transparent') {
|
|
54
|
+
ctx.fillStyle = this.fill;
|
|
55
|
+
ctx.fill();
|
|
56
|
+
}
|
|
57
|
+
if (this.stroke !== 'transparent' && this.strokeWidth > 0) {
|
|
58
|
+
ctx.strokeStyle = this.stroke;
|
|
59
|
+
ctx.lineWidth = this.strokeWidth;
|
|
60
|
+
if (this.lineDash.length > 0) ctx.setLineDash(this.lineDash);
|
|
61
|
+
ctx.stroke();
|
|
62
|
+
if (this.lineDash.length > 0) ctx.setLineDash([]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
isPointInNode(x: number, y: number): boolean {
|
|
67
|
+
const minX = Math.min(this.x, this.x + this.width);
|
|
68
|
+
const maxX = Math.max(this.x, this.x + this.width);
|
|
69
|
+
const minY = Math.min(this.y, this.y + this.height);
|
|
70
|
+
const maxY = Math.max(this.y, this.y + this.height);
|
|
71
|
+
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getBBox(): BBox {
|
|
75
|
+
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Line ─────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export class Line extends Node {
|
|
82
|
+
x1: number = 0;
|
|
83
|
+
y1: number = 0;
|
|
84
|
+
x2: number = 0;
|
|
85
|
+
y2: number = 0;
|
|
86
|
+
stroke: string = 'black';
|
|
87
|
+
strokeWidth: number = 1;
|
|
88
|
+
lineDash?: number[];
|
|
89
|
+
|
|
90
|
+
renderShape(ctx: CanvasRenderingContext2D) {
|
|
91
|
+
ctx.save();
|
|
92
|
+
if (this.lineDash) ctx.setLineDash(this.lineDash);
|
|
93
|
+
ctx.strokeStyle = this.stroke;
|
|
94
|
+
ctx.lineWidth = this.strokeWidth;
|
|
95
|
+
ctx.beginPath();
|
|
96
|
+
ctx.moveTo(this.x1, this.y1);
|
|
97
|
+
ctx.lineTo(this.x2, this.y2);
|
|
98
|
+
ctx.stroke();
|
|
99
|
+
ctx.restore();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
isPointInNode(_x: number, _y: number): boolean { return false; }
|
|
103
|
+
|
|
104
|
+
getBBox(): BBox {
|
|
105
|
+
return {
|
|
106
|
+
x: Math.min(this.x1, this.x2),
|
|
107
|
+
y: Math.min(this.y1, this.y2),
|
|
108
|
+
width: Math.abs(this.x2 - this.x1),
|
|
109
|
+
height: Math.abs(this.y2 - this.y1),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Circle ───────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export class Circle extends Node {
|
|
117
|
+
centerX: number = 0;
|
|
118
|
+
centerY: number = 0;
|
|
119
|
+
radius: number = 5;
|
|
120
|
+
fill: string = 'blue';
|
|
121
|
+
stroke: string = 'transparent';
|
|
122
|
+
strokeWidth: number = 1;
|
|
123
|
+
|
|
124
|
+
renderShape(ctx: CanvasRenderingContext2D) {
|
|
125
|
+
ctx.beginPath();
|
|
126
|
+
ctx.arc(this.centerX, this.centerY, this.radius, 0, Math.PI * 2);
|
|
127
|
+
ctx.closePath();
|
|
128
|
+
if (this.fill !== 'transparent') { ctx.fillStyle = this.fill; ctx.fill(); }
|
|
129
|
+
if (this.stroke !== 'transparent' && this.strokeWidth > 0) {
|
|
130
|
+
ctx.strokeStyle = this.stroke;
|
|
131
|
+
ctx.lineWidth = this.strokeWidth;
|
|
132
|
+
ctx.stroke();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
isPointInNode(x: number, y: number): boolean {
|
|
137
|
+
const dx = x - this.centerX, dy = y - this.centerY;
|
|
138
|
+
return (dx * dx + dy * dy) <= this.radius * this.radius;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getBBox(): BBox {
|
|
142
|
+
return { x: this.centerX - this.radius, y: this.centerY - this.radius, width: this.radius * 2, height: this.radius * 2 };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Path ─────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export interface PathPoint {
|
|
149
|
+
x: number;
|
|
150
|
+
y: number;
|
|
151
|
+
command: 'M' | 'L' | 'C';
|
|
152
|
+
/** Bezier control point 1 (used when command === 'C') */
|
|
153
|
+
cp1x?: number;
|
|
154
|
+
cp1y?: number;
|
|
155
|
+
/** Bezier control point 2 (used when command === 'C') */
|
|
156
|
+
cp2x?: number;
|
|
157
|
+
cp2y?: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class Path extends Node {
|
|
161
|
+
pathData: PathPoint[] = [];
|
|
162
|
+
fill: string = 'transparent';
|
|
163
|
+
stroke: string = 'transparent';
|
|
164
|
+
strokeWidth: number = 1;
|
|
165
|
+
closed: boolean = false;
|
|
166
|
+
lineDash?: number[];
|
|
167
|
+
lineDashOffset: number = 0;
|
|
168
|
+
|
|
169
|
+
renderShape(ctx: CanvasRenderingContext2D) {
|
|
170
|
+
if (this.pathData.length === 0) return;
|
|
171
|
+
|
|
172
|
+
ctx.save();
|
|
173
|
+
if (this.lineDash) {
|
|
174
|
+
ctx.setLineDash(this.lineDash);
|
|
175
|
+
ctx.lineDashOffset = this.lineDashOffset;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
ctx.beginPath();
|
|
179
|
+
for (const p of this.pathData) {
|
|
180
|
+
if (p.command === 'M') {
|
|
181
|
+
ctx.moveTo(p.x, p.y);
|
|
182
|
+
} else if (p.command === 'L') {
|
|
183
|
+
ctx.lineTo(p.x, p.y);
|
|
184
|
+
} else if (p.command === 'C' && p.cp1x !== undefined && p.cp2x !== undefined) {
|
|
185
|
+
ctx.bezierCurveTo(p.cp1x, p.cp1y!, p.cp2x, p.cp2y!, p.x, p.y);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (this.closed) ctx.closePath();
|
|
190
|
+
|
|
191
|
+
if (this.fill !== 'transparent') { ctx.fillStyle = this.fill; ctx.fill(); }
|
|
192
|
+
if (this.stroke !== 'transparent' && this.strokeWidth > 0) {
|
|
193
|
+
ctx.strokeStyle = this.stroke;
|
|
194
|
+
ctx.lineWidth = this.strokeWidth;
|
|
195
|
+
ctx.stroke();
|
|
196
|
+
}
|
|
197
|
+
ctx.restore();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
isPointInNode(_x: number, _y: number): boolean { return false; }
|
|
201
|
+
|
|
202
|
+
getBBox(): BBox {
|
|
203
|
+
if (this.pathData.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
|
|
204
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
205
|
+
for (const p of this.pathData) {
|
|
206
|
+
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
|
|
207
|
+
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
|
|
208
|
+
if (p.cp1x !== undefined) {
|
|
209
|
+
minX = Math.min(minX, p.cp1x, p.cp2x!); minY = Math.min(minY, p.cp1y!, p.cp2y!);
|
|
210
|
+
maxX = Math.max(maxX, p.cp1x, p.cp2x!); maxY = Math.max(maxY, p.cp1y!, p.cp2y!);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Marker ───────────────────────────────────────────────────────────────────
|
|
218
|
+
// A polymorphic point marker. Drop-in replacement for Circle in series that
|
|
219
|
+
// want to expose a `markerShape` option. Hit-testing uses the bounding box of
|
|
220
|
+
// the marker square, which is plenty accurate for marker-sized targets.
|
|
221
|
+
|
|
222
|
+
export type MarkerShape = 'circle' | 'square' | 'cross' | 'diamond' | 'triangle' | 'plus';
|
|
223
|
+
|
|
224
|
+
export class Marker extends Node {
|
|
225
|
+
centerX: number = 0;
|
|
226
|
+
centerY: number = 0;
|
|
227
|
+
radius: number = 5;
|
|
228
|
+
fill: string = 'blue';
|
|
229
|
+
stroke: string = 'transparent';
|
|
230
|
+
strokeWidth: number = 1;
|
|
231
|
+
shape: MarkerShape = 'circle';
|
|
232
|
+
|
|
233
|
+
renderShape(ctx: CanvasRenderingContext2D) {
|
|
234
|
+
const cx = this.centerX, cy = this.centerY, r = this.radius;
|
|
235
|
+
if (r <= 0) return;
|
|
236
|
+
|
|
237
|
+
ctx.beginPath();
|
|
238
|
+
switch (this.shape) {
|
|
239
|
+
case 'circle':
|
|
240
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
241
|
+
ctx.closePath();
|
|
242
|
+
break;
|
|
243
|
+
case 'square':
|
|
244
|
+
ctx.rect(cx - r, cy - r, r * 2, r * 2);
|
|
245
|
+
ctx.closePath();
|
|
246
|
+
break;
|
|
247
|
+
case 'diamond':
|
|
248
|
+
ctx.moveTo(cx, cy - r);
|
|
249
|
+
ctx.lineTo(cx + r, cy);
|
|
250
|
+
ctx.lineTo(cx, cy + r);
|
|
251
|
+
ctx.lineTo(cx - r, cy);
|
|
252
|
+
ctx.closePath();
|
|
253
|
+
break;
|
|
254
|
+
case 'triangle':
|
|
255
|
+
// Equilateral, point-up. Centred on (cx, cy).
|
|
256
|
+
ctx.moveTo(cx, cy - r);
|
|
257
|
+
ctx.lineTo(cx + r * 0.866, cy + r * 0.5);
|
|
258
|
+
ctx.lineTo(cx - r * 0.866, cy + r * 0.5);
|
|
259
|
+
ctx.closePath();
|
|
260
|
+
break;
|
|
261
|
+
case 'cross': {
|
|
262
|
+
// Diagonal X — drawn with two thick lines fused into one path so the
|
|
263
|
+
// shadow paints once. Width scales with radius.
|
|
264
|
+
const w = r * 0.4;
|
|
265
|
+
ctx.moveTo(cx - r, cy - r + w); ctx.lineTo(cx - r + w, cy - r);
|
|
266
|
+
ctx.lineTo(cx, cy - w); ctx.lineTo(cx + r - w, cy - r);
|
|
267
|
+
ctx.lineTo(cx + r, cy - r + w); ctx.lineTo(cx + w, cy);
|
|
268
|
+
ctx.lineTo(cx + r, cy + r - w); ctx.lineTo(cx + r - w, cy + r);
|
|
269
|
+
ctx.lineTo(cx, cy + w); ctx.lineTo(cx - r + w, cy + r);
|
|
270
|
+
ctx.lineTo(cx - r, cy + r - w); ctx.lineTo(cx - w, cy);
|
|
271
|
+
ctx.closePath();
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case 'plus': {
|
|
275
|
+
// Axis-aligned plus sign with arm thickness w.
|
|
276
|
+
const w = r * 0.4;
|
|
277
|
+
ctx.moveTo(cx - w, cy - r); ctx.lineTo(cx + w, cy - r);
|
|
278
|
+
ctx.lineTo(cx + w, cy - w); ctx.lineTo(cx + r, cy - w);
|
|
279
|
+
ctx.lineTo(cx + r, cy + w); ctx.lineTo(cx + w, cy + w);
|
|
280
|
+
ctx.lineTo(cx + w, cy + r); ctx.lineTo(cx - w, cy + r);
|
|
281
|
+
ctx.lineTo(cx - w, cy + w); ctx.lineTo(cx - r, cy + w);
|
|
282
|
+
ctx.lineTo(cx - r, cy - w); ctx.lineTo(cx - w, cy - w);
|
|
283
|
+
ctx.closePath();
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (this.fill !== 'transparent') { ctx.fillStyle = this.fill; ctx.fill(); }
|
|
289
|
+
if (this.stroke !== 'transparent' && this.strokeWidth > 0) {
|
|
290
|
+
ctx.strokeStyle = this.stroke;
|
|
291
|
+
ctx.lineWidth = this.strokeWidth;
|
|
292
|
+
ctx.stroke();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
isPointInNode(x: number, y: number): boolean {
|
|
297
|
+
const dx = x - this.centerX, dy = y - this.centerY;
|
|
298
|
+
// Bounding-square hit test — sufficient for finger/cursor-sized markers.
|
|
299
|
+
return Math.abs(dx) <= this.radius && Math.abs(dy) <= this.radius;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
getBBox(): BBox {
|
|
303
|
+
return {
|
|
304
|
+
x: this.centerX - this.radius,
|
|
305
|
+
y: this.centerY - this.radius,
|
|
306
|
+
width: this.radius * 2,
|
|
307
|
+
height: this.radius * 2,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── Arc ──────────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export class Arc extends Node {
|
|
315
|
+
centerX: number = 0;
|
|
316
|
+
centerY: number = 0;
|
|
317
|
+
innerRadius: number = 0;
|
|
318
|
+
outerRadius: number = 0;
|
|
319
|
+
startAngle: number = 0;
|
|
320
|
+
endAngle: number = 0;
|
|
321
|
+
fill: string = 'blue';
|
|
322
|
+
stroke: string = 'transparent';
|
|
323
|
+
strokeWidth: number = 1;
|
|
324
|
+
|
|
325
|
+
renderShape(ctx: CanvasRenderingContext2D) {
|
|
326
|
+
ctx.beginPath();
|
|
327
|
+
ctx.arc(this.centerX, this.centerY, this.outerRadius, this.startAngle, this.endAngle);
|
|
328
|
+
if (this.innerRadius > 0) {
|
|
329
|
+
ctx.arc(this.centerX, this.centerY, this.innerRadius, this.endAngle, this.startAngle, true);
|
|
330
|
+
} else {
|
|
331
|
+
ctx.lineTo(this.centerX, this.centerY);
|
|
332
|
+
}
|
|
333
|
+
ctx.closePath();
|
|
334
|
+
if (this.fill !== 'transparent') { ctx.fillStyle = this.fill; ctx.fill(); }
|
|
335
|
+
if (this.stroke !== 'transparent' && this.strokeWidth > 0) {
|
|
336
|
+
ctx.strokeStyle = this.stroke; ctx.lineWidth = this.strokeWidth; ctx.stroke();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
isPointInNode(x: number, y: number): boolean {
|
|
341
|
+
const dx = x - this.centerX, dy = y - this.centerY;
|
|
342
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
343
|
+
if (dist < this.innerRadius || dist > this.outerRadius) return false;
|
|
344
|
+
let angle = Math.atan2(dy, dx);
|
|
345
|
+
if (angle < 0) angle += Math.PI * 2;
|
|
346
|
+
let s = this.startAngle % (Math.PI * 2); if (s < 0) s += Math.PI * 2;
|
|
347
|
+
let e = this.endAngle % (Math.PI * 2); if (e < 0) e += Math.PI * 2;
|
|
348
|
+
return s < e ? (angle >= s && angle <= e) : (angle >= s || angle <= e);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
getBBox(): BBox {
|
|
352
|
+
return { x: this.centerX - this.outerRadius, y: this.centerY - this.outerRadius, width: this.outerRadius * 2, height: this.outerRadius * 2 };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─── Text ─────────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
export class Text extends Node {
|
|
359
|
+
text: string = '';
|
|
360
|
+
fontSize: number = 12;
|
|
361
|
+
fontFamily: string = 'sans-serif';
|
|
362
|
+
fontWeight: string = 'normal';
|
|
363
|
+
fill: string = 'black';
|
|
364
|
+
textAlign: CanvasTextAlign = 'start';
|
|
365
|
+
textBaseline: CanvasTextBaseline = 'alphabetic';
|
|
366
|
+
maxWidth?: number;
|
|
367
|
+
shadowColor?: string;
|
|
368
|
+
shadowBlur?: number;
|
|
369
|
+
|
|
370
|
+
renderShape(ctx: CanvasRenderingContext2D) {
|
|
371
|
+
ctx.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`;
|
|
372
|
+
ctx.fillStyle = this.fill;
|
|
373
|
+
ctx.textAlign = this.textAlign;
|
|
374
|
+
ctx.textBaseline = this.textBaseline;
|
|
375
|
+
if (this.shadowColor) ctx.shadowColor = this.shadowColor;
|
|
376
|
+
if (this.shadowBlur !== undefined) ctx.shadowBlur = this.shadowBlur;
|
|
377
|
+
|
|
378
|
+
if (this.maxWidth !== undefined && this.maxWidth > 0) {
|
|
379
|
+
ctx.fillText(this.text, this.x, this.y, this.maxWidth);
|
|
380
|
+
} else {
|
|
381
|
+
ctx.fillText(this.text, this.x, this.y);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (this.shadowColor) ctx.shadowColor = 'transparent';
|
|
385
|
+
if (this.shadowBlur !== undefined) ctx.shadowBlur = 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
isPointInNode(_x: number, _y: number): boolean { return false; }
|
|
389
|
+
|
|
390
|
+
getBBox(): BBox {
|
|
391
|
+
return { x: this.x, y: this.y - this.fontSize, width: this.text.length * (this.fontSize * 0.55), height: this.fontSize };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── Polygon ──────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
export class Polygon extends Node {
|
|
398
|
+
points: { x: number; y: number }[] = [];
|
|
399
|
+
fill: string = 'transparent';
|
|
400
|
+
stroke: string = 'transparent';
|
|
401
|
+
strokeWidth: number = 1;
|
|
402
|
+
closed: boolean = true;
|
|
403
|
+
|
|
404
|
+
renderShape(ctx: CanvasRenderingContext2D) {
|
|
405
|
+
if (this.points.length < 2) return;
|
|
406
|
+
|
|
407
|
+
ctx.beginPath();
|
|
408
|
+
ctx.moveTo(this.points[0].x, this.points[0].y);
|
|
409
|
+
for (let i = 1; i < this.points.length; i++) {
|
|
410
|
+
ctx.lineTo(this.points[i].x, this.points[i].y);
|
|
411
|
+
}
|
|
412
|
+
if (this.closed) ctx.closePath();
|
|
413
|
+
|
|
414
|
+
if (this.fill !== 'transparent') {
|
|
415
|
+
ctx.fillStyle = this.fill;
|
|
416
|
+
ctx.fill();
|
|
417
|
+
}
|
|
418
|
+
if (this.stroke !== 'transparent' && this.strokeWidth > 0) {
|
|
419
|
+
ctx.strokeStyle = this.stroke;
|
|
420
|
+
ctx.lineWidth = this.strokeWidth;
|
|
421
|
+
ctx.stroke();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
isPointInNode(x: number, y: number): boolean {
|
|
426
|
+
// Basic ray-casting algorithm for point-in-polygon
|
|
427
|
+
let inside = false;
|
|
428
|
+
for (let i = 0, j = this.points.length - 1; i < this.points.length; j = i++) {
|
|
429
|
+
const xi = this.points[i].x, yi = this.points[i].y;
|
|
430
|
+
const xj = this.points[j].x, yj = this.points[j].y;
|
|
431
|
+
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
|
|
432
|
+
if (intersect) inside = !inside;
|
|
433
|
+
}
|
|
434
|
+
return inside;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
getBBox(): BBox {
|
|
438
|
+
if (this.points.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
|
|
439
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
440
|
+
for (const p of this.points) {
|
|
441
|
+
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
|
|
442
|
+
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
|
|
443
|
+
}
|
|
444
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
445
|
+
}
|
|
446
|
+
}
|