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.
@@ -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
+ }