@stacksjs/charts 0.70.55 → 0.70.56

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/charts",
3
3
  "type": "module",
4
- "version": "0.70.55",
4
+ "version": "0.70.56",
5
5
  "description": "Chart rendering for Stacks. Built on ts-charts (D3 in TypeScript).",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -29,20 +29,23 @@
29
29
  "exports": {
30
30
  ".": {
31
31
  "types": "./dist/index.d.ts",
32
- "bun": "./src/index.ts",
33
- "import": "./dist/index.js"
32
+ "development": "./src/index.ts",
33
+ "bun": "./dist/index.js",
34
+ "import": "./dist/index.js",
35
+ "default": "./dist/index.js"
34
36
  },
35
37
  "./*": {
36
- "bun": "./src/*",
37
- "import": "./dist/*"
38
+ "development": "./src/*",
39
+ "bun": "./dist/*",
40
+ "import": "./dist/*",
41
+ "default": "./dist/*"
38
42
  }
39
43
  },
40
44
  "module": "dist/index.js",
41
45
  "types": "dist/index.d.ts",
42
46
  "files": [
43
47
  "README.md",
44
- "dist",
45
- "src"
48
+ "dist"
46
49
  ],
47
50
  "scripts": {
48
51
  "build": "bun build.ts",
package/src/chart.ts DELETED
@@ -1,796 +0,0 @@
1
- import type { ChartConfig, ChartData, ChartDataset, ChartOptions, ChartType, TooltipCallbackContext } from './types'
2
- import { scaleBand, scaleLinear } from '@ts-charts/scale'
3
- import {
4
- arc as d3Arc,
5
- area as d3Area,
6
- curveLinear,
7
- curveMonotoneX,
8
- line as d3Line,
9
- pie as d3Pie,
10
- } from '@ts-charts/shape'
11
- import { paletteColor, withAlpha } from './colors'
12
- import { formatTick, niceTicks } from './ticks'
13
-
14
- interface Box {
15
- x: number
16
- y: number
17
- w: number
18
- h: number
19
- }
20
-
21
- interface AxisLayout {
22
- ticks: number[]
23
- min: number
24
- max: number
25
- }
26
-
27
- interface HitItem {
28
- datasetIndex: number
29
- dataIndex: number
30
- x: number
31
- y: number
32
- label: string
33
- value: number
34
- }
35
-
36
- const DEFAULT_GRID_COLOR = 'rgba(148, 163, 184, 0.18)'
37
- const DEFAULT_TEXT_COLOR = '#64748b'
38
- const DEFAULT_BG = 'rgba(15, 23, 42, 0.92)'
39
-
40
- /**
41
- * Chart.js-compatible adapter rendering to HTML5 Canvas.
42
- *
43
- * Supports the dashboard subset: line / bar / doughnut / pie, with
44
- * responsive sizing, scales (beginAtZero / stacked / grid / ticks /
45
- * y1 dual-axis), legends, and tooltip callbacks. Advanced features
46
- * (radar, animations, plugins) are no-ops rather than crashes.
47
- */
48
- export class Chart {
49
- static instances = new WeakMap<HTMLCanvasElement, Chart>()
50
-
51
- /**
52
- * Compatibility no-op: chart.js requires `Chart.register(...registerables)`
53
- * before usage. We auto-register everything, so this is intentionally inert.
54
- */
55
- static register(..._items: unknown[]): void {
56
- // no-op
57
- }
58
-
59
- type: ChartType
60
- data: ChartData
61
- options: ChartOptions
62
- canvas: HTMLCanvasElement
63
- ctx: CanvasRenderingContext2D
64
-
65
- private resizeObserver: ResizeObserver | null = null
66
- private hitItems: HitItem[] = []
67
- private tooltipEl: HTMLDivElement | null = null
68
- private boundMove = (e: MouseEvent) => this.handleMouseMove(e)
69
- private boundLeave = () => this.hideTooltip()
70
-
71
- constructor(target: HTMLCanvasElement | CanvasRenderingContext2D, config: ChartConfig) {
72
- this.canvas = target instanceof HTMLCanvasElement ? target : target.canvas
73
- const ctx = this.canvas.getContext('2d')
74
- if (!ctx)
75
- throw new Error('Chart: 2D canvas context unavailable')
76
- this.ctx = ctx
77
- this.type = config.type
78
- this.data = config.data
79
- this.options = config.options ?? {}
80
-
81
- Chart.instances.set(this.canvas, this)
82
- this.attachInteractions()
83
- this.observeResize()
84
- this.render()
85
- }
86
-
87
- update(): void {
88
- this.render()
89
- }
90
-
91
- resize(): void {
92
- this.render()
93
- }
94
-
95
- destroy(): void {
96
- if (this.resizeObserver) {
97
- this.resizeObserver.disconnect()
98
- this.resizeObserver = null
99
- }
100
- this.canvas.removeEventListener('mousemove', this.boundMove)
101
- this.canvas.removeEventListener('mouseleave', this.boundLeave)
102
- if (this.tooltipEl?.parentNode)
103
- this.tooltipEl.parentNode.removeChild(this.tooltipEl)
104
- this.tooltipEl = null
105
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
106
- Chart.instances.delete(this.canvas)
107
- }
108
-
109
- // ---------- internal ---------- //
110
-
111
- private observeResize(): void {
112
- if (this.options.responsive === false || typeof ResizeObserver === 'undefined')
113
- return
114
- this.resizeObserver = new ResizeObserver(() => this.render())
115
- this.resizeObserver.observe(this.canvas.parentElement ?? this.canvas)
116
- }
117
-
118
- private attachInteractions(): void {
119
- if (this.options.plugins?.tooltip?.enabled === false)
120
- return
121
- this.canvas.addEventListener('mousemove', this.boundMove)
122
- this.canvas.addEventListener('mouseleave', this.boundLeave)
123
- }
124
-
125
- private syncCanvasSize(): { width: number, height: number } {
126
- const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1
127
- const rect = this.canvas.getBoundingClientRect()
128
- const cssW = rect.width || this.canvas.clientWidth || this.canvas.width
129
- const cssH = rect.height || this.canvas.clientHeight || this.canvas.height
130
- const w = Math.max(1, Math.floor(cssW * dpr))
131
- const h = Math.max(1, Math.floor(cssH * dpr))
132
- if (this.canvas.width !== w)
133
- this.canvas.width = w
134
- if (this.canvas.height !== h)
135
- this.canvas.height = h
136
- this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
137
- return { width: cssW, height: cssH }
138
- }
139
-
140
- private render(): void {
141
- const { width, height } = this.syncCanvasSize()
142
- this.ctx.clearRect(0, 0, width, height)
143
- this.hitItems = []
144
-
145
- if (this.type === 'doughnut' || this.type === 'pie') {
146
- this.renderRadial(width, height)
147
- return
148
- }
149
-
150
- if (this.type === 'radar') {
151
- // Best-effort: fall back to a simple line chart layout for now.
152
- this.renderCartesian(width, height, 'line')
153
- return
154
- }
155
-
156
- this.renderCartesian(width, height, this.type)
157
- }
158
-
159
- // ---------- cartesian (line/bar) ---------- //
160
-
161
- private renderCartesian(width: number, height: number, type: ChartType): void {
162
- const labels = this.data.labels ?? []
163
- const datasets = this.data.datasets
164
-
165
- const legend = this.layoutLegend(width, height)
166
- const titleH = this.options.plugins?.title?.display ? 24 : 0
167
- const padTop = 8 + titleH + (legend.position === 'top' ? legend.h : 0)
168
- const padBottom = 28 + (legend.position === 'bottom' ? legend.h : 0)
169
- const padLeft = 48 + (legend.position === 'left' ? legend.w : 0)
170
- const padRight = 16 + (legend.position === 'right' ? legend.w : 0) + (this.hasRightAxis() ? 40 : 0)
171
-
172
- const plot: Box = {
173
- x: padLeft,
174
- y: padTop,
175
- w: Math.max(1, width - padLeft - padRight),
176
- h: Math.max(1, height - padTop - padBottom),
177
- }
178
-
179
- if (this.options.plugins?.title?.display && this.options.plugins.title.text) {
180
- const t = this.options.plugins.title
181
- this.ctx.fillStyle = t.color ?? DEFAULT_TEXT_COLOR
182
- this.ctx.font = `${t.font?.size ?? 14}px system-ui, sans-serif`
183
- this.ctx.textAlign = 'center'
184
- this.ctx.textBaseline = 'top'
185
- this.ctx.fillText(t.text ?? '', width / 2, 8)
186
- }
187
-
188
- this.drawLegend(legend, datasets)
189
-
190
- const yAxis = this.computeAxis(datasets.filter(d => (d.yAxisID ?? 'y') === 'y'), 'y')
191
- const yAxis1 = this.hasRightAxis() ? this.computeAxis(datasets.filter(d => d.yAxisID === 'y1'), 'y1') : null
192
-
193
- this.drawGridAndYAxis(plot, yAxis, 'left')
194
- if (yAxis1)
195
- this.drawYAxis(plot, yAxis1, 'right')
196
- this.drawXAxis(plot, labels)
197
-
198
- if (type === 'bar')
199
- this.drawBars(plot, labels, datasets, yAxis, yAxis1)
200
- else this.drawLines(plot, labels, datasets, yAxis, yAxis1)
201
- }
202
-
203
- private hasRightAxis(): boolean {
204
- return this.data.datasets.some(d => d.yAxisID === 'y1')
205
- }
206
-
207
- private computeAxis(datasets: ChartDataset[], axisId: string): AxisLayout {
208
- const cfg = this.options.scales?.[axisId] ?? {}
209
- let min = Number.POSITIVE_INFINITY
210
- let max = Number.NEGATIVE_INFINITY
211
-
212
- if (cfg.stacked) {
213
- const labels = this.data.labels ?? []
214
- for (let i = 0; i < labels.length; i++) {
215
- let pos = 0
216
- let neg = 0
217
- for (const ds of datasets) {
218
- const v = ds.data[i] ?? 0
219
- if (v >= 0)
220
- pos += v
221
- else neg += v
222
- }
223
- if (pos > max)
224
- max = pos
225
- if (neg < min)
226
- min = neg
227
- }
228
- }
229
- else {
230
- for (const ds of datasets) {
231
- for (const v of ds.data) {
232
- if (v < min)
233
- min = v
234
- if (v > max)
235
- max = v
236
- }
237
- }
238
- }
239
-
240
- if (!Number.isFinite(min))
241
- min = 0
242
- if (!Number.isFinite(max))
243
- max = 1
244
- if (cfg.beginAtZero !== false && min > 0)
245
- min = 0
246
- if (cfg.min !== undefined)
247
- min = cfg.min
248
- if (cfg.max !== undefined)
249
- max = cfg.max
250
- if (min === max) {
251
- max = min + 1
252
- }
253
-
254
- const ticks = niceTicks(min, max, cfg.ticks?.maxTicksLimit ?? 6)
255
- // niceTicks always returns at least one element, but the type
256
- // system can't see that — fall back to the resolved domain so
257
- // downstream consumers always get concrete numbers.
258
- return { ticks, min: ticks[0] ?? min, max: ticks[ticks.length - 1] ?? max }
259
- }
260
-
261
- private drawGridAndYAxis(plot: Box, axis: AxisLayout, side: 'left' | 'right'): void {
262
- const cfg = this.options.scales?.y ?? {}
263
- const showGrid = cfg.grid?.display !== false
264
- const tickColor = cfg.ticks?.color ?? DEFAULT_TEXT_COLOR
265
- const gridColor = cfg.grid?.color ?? DEFAULT_GRID_COLOR
266
-
267
- this.ctx.save()
268
- this.ctx.font = `${cfg.ticks?.font?.size ?? 11}px system-ui, sans-serif`
269
- this.ctx.textBaseline = 'middle'
270
-
271
- for (const tick of axis.ticks) {
272
- const y = this.scaleY(plot, axis, tick)
273
- if (showGrid) {
274
- this.ctx.strokeStyle = gridColor
275
- this.ctx.lineWidth = 1
276
- this.ctx.beginPath()
277
- this.ctx.moveTo(plot.x, y)
278
- this.ctx.lineTo(plot.x + plot.w, y)
279
- this.ctx.stroke()
280
- }
281
- const label = cfg.ticks?.callback
282
- ? cfg.ticks.callback(tick, axis.ticks.indexOf(tick), axis.ticks)
283
- : formatTick(tick)
284
- this.ctx.fillStyle = tickColor
285
- this.ctx.textAlign = side === 'left' ? 'right' : 'left'
286
- const tx = side === 'left' ? plot.x - 8 : plot.x + plot.w + 8
287
- this.ctx.fillText(String(label), tx, y)
288
- }
289
- this.ctx.restore()
290
- }
291
-
292
- private drawYAxis(plot: Box, axis: AxisLayout, side: 'left' | 'right'): void {
293
- const cfg = this.options.scales?.y1 ?? {}
294
- const tickColor = cfg.ticks?.color ?? DEFAULT_TEXT_COLOR
295
- this.ctx.save()
296
- this.ctx.font = `${cfg.ticks?.font?.size ?? 11}px system-ui, sans-serif`
297
- this.ctx.textBaseline = 'middle'
298
- for (const tick of axis.ticks) {
299
- const y = this.scaleY(plot, axis, tick)
300
- const label = cfg.ticks?.callback
301
- ? cfg.ticks.callback(tick, axis.ticks.indexOf(tick), axis.ticks)
302
- : formatTick(tick)
303
- this.ctx.fillStyle = tickColor
304
- this.ctx.textAlign = side === 'left' ? 'right' : 'left'
305
- const tx = side === 'left' ? plot.x - 8 : plot.x + plot.w + 8
306
- this.ctx.fillText(String(label), tx, y)
307
- }
308
- this.ctx.restore()
309
- }
310
-
311
- private drawXAxis(plot: Box, labels: string[]): void {
312
- const cfg = this.options.scales?.x ?? {}
313
- const tickColor = cfg.ticks?.color ?? DEFAULT_TEXT_COLOR
314
- if (cfg.display === false || labels.length === 0)
315
- return
316
-
317
- this.ctx.save()
318
- this.ctx.font = `${cfg.ticks?.font?.size ?? 11}px system-ui, sans-serif`
319
- this.ctx.textBaseline = 'top'
320
- this.ctx.textAlign = 'center'
321
- this.ctx.fillStyle = tickColor
322
-
323
- const maxLabels = Math.min(labels.length, Math.max(2, Math.floor(plot.w / 60)))
324
- const step = Math.max(1, Math.floor(labels.length / maxLabels))
325
-
326
- for (let i = 0; i < labels.length; i += step) {
327
- const x = this.scaleX(plot, labels.length, i)
328
- this.ctx.fillText(labels[i] ?? '', x, plot.y + plot.h + 8)
329
- }
330
- this.ctx.restore()
331
- }
332
-
333
- /**
334
- * X-axis pixel mapping. We delegate the math to `@ts-charts/scale`'s
335
- * `scaleLinear` so categorical points distribute evenly across the plot
336
- * the same way d3-scale would, including the degenerate single-point
337
- * case (collapses to plot center).
338
- */
339
- private scaleX(plot: Box, count: number, i: number): number {
340
- if (count <= 1)
341
- return plot.x + plot.w / 2
342
- const s = scaleLinear().domain([0, count - 1]).range([plot.x, plot.x + plot.w])
343
- return s(i)
344
- }
345
-
346
- /**
347
- * Y-axis pixel mapping built on `@ts-charts/scale`'s `scaleLinear`.
348
- * Inverts the canvas Y axis by giving the larger pixel value to the
349
- * smaller domain value, so the visual direction matches an SVG plot.
350
- */
351
- private scaleY(plot: Box, axis: AxisLayout, value: number): number {
352
- if (axis.max === axis.min)
353
- return plot.y + plot.h / 2
354
- const s = scaleLinear().domain([axis.min, axis.max]).range([plot.y + plot.h, plot.y])
355
- return s(value)
356
- }
357
-
358
- private drawLines(plot: Box, labels: string[], datasets: ChartDataset[], yAxis: AxisLayout, yAxis1: AxisLayout | null): void {
359
- datasets.forEach((ds, idx) => {
360
- const axis = ds.yAxisID === 'y1' && yAxis1 ? yAxis1 : yAxis
361
- if (ds.type === 'bar') {
362
- this.drawBarSeries(plot, labels, [ds], yAxis, yAxis1)
363
- return
364
- }
365
-
366
- const color = (typeof ds.borderColor === 'string' ? ds.borderColor : null) ?? paletteColor(idx)
367
- const fillColor = ds.backgroundColor && typeof ds.backgroundColor === 'string'
368
- ? ds.backgroundColor
369
- : ds.fill ? withAlpha(color, 0.15) : null
370
-
371
- const points: Array<{ x: number, y: number, value: number }> = ds.data.map((v, i) => ({
372
- x: this.scaleX(plot, labels.length || ds.data.length, i),
373
- y: this.scaleY(plot, axis, v),
374
- value: v,
375
- }))
376
-
377
- if (points.length === 0)
378
- return
379
-
380
- // Chart.js's `tension` field controls smoothing. We map any
381
- // non-zero tension to d3-shape's `curveMonotoneX`, which is
382
- // visually closer to the Chart.js look than `curveCardinal`
383
- // and never overshoots, so axis grids stay tight.
384
- const curve = (ds.tension ?? 0) > 0 ? curveMonotoneX : curveLinear
385
-
386
- // Filled area under the line (Chart.js semantic: `fill: true`
387
- // floods between the line and the x-axis baseline).
388
- if (fillColor) {
389
- const baseline = plot.y + plot.h
390
- const areaGen = (d3Area() as any)
391
- .x((p: { x: number }) => p.x)
392
- .y0(() => baseline)
393
- .y1((p: { y: number }) => p.y)
394
- .curve(curve)
395
- .context(this.ctx)
396
- this.ctx.beginPath()
397
- areaGen(points)
398
- this.ctx.fillStyle = fillColor
399
- this.ctx.fill()
400
- areaGen.context(null)
401
- }
402
-
403
- // Line stroke through the same point set.
404
- const lineGen = (d3Line() as any)
405
- .x((p: { x: number }) => p.x)
406
- .y((p: { y: number }) => p.y)
407
- .curve(curve)
408
- .context(this.ctx)
409
- this.ctx.beginPath()
410
- lineGen(points)
411
- this.ctx.lineWidth = ds.borderWidth ?? 2
412
- this.ctx.strokeStyle = color
413
- this.ctx.lineJoin = 'round'
414
- this.ctx.lineCap = 'round'
415
- this.ctx.stroke()
416
- lineGen.context(null)
417
-
418
- const r = ds.pointRadius ?? 0
419
- if (r > 0) {
420
- this.ctx.fillStyle = ds.pointBackgroundColor ?? color
421
- for (const p of points) {
422
- this.ctx.beginPath()
423
- this.ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
424
- this.ctx.fill()
425
- }
426
- }
427
-
428
- points.forEach((p, i) => {
429
- this.hitItems.push({
430
- datasetIndex: idx,
431
- dataIndex: i,
432
- x: p.x,
433
- y: p.y,
434
- label: labels[i] ?? String(i),
435
- value: p.value,
436
- })
437
- })
438
- })
439
- }
440
-
441
- private drawBars(plot: Box, labels: string[], datasets: ChartDataset[], yAxis: AxisLayout, yAxis1: AxisLayout | null): void {
442
- this.drawBarSeries(plot, labels, datasets, yAxis, yAxis1)
443
- }
444
-
445
- private drawBarSeries(plot: Box, labels: string[], datasets: ChartDataset[], yAxis: AxisLayout, yAxis1: AxisLayout | null): void {
446
- const count = labels.length || (datasets[0]?.data.length ?? 0)
447
- if (count === 0)
448
- return
449
-
450
- const cfg = this.options.scales?.y
451
- const stacked = !!cfg?.stacked
452
-
453
- // Use `@ts-charts/scale`'s `scaleBand` to compute the per-category
454
- // band so the bar group width and gutter respect the same 0.3
455
- // padding ratio d3-scale uses by default. Each band is then split
456
- // among datasets in unstacked mode.
457
- const indices = Array.from({ length: count }, (_, i) => i)
458
- const xBand = scaleBand().domain(indices).range([plot.x, plot.x + plot.w]).paddingInner(0.3)
459
- const groupWidth = xBand.bandwidth() as number
460
- const barWidth = stacked ? groupWidth : groupWidth / Math.max(1, datasets.length)
461
-
462
- for (let i = 0; i < count; i++) {
463
- const cx = xBand(i) as number
464
- let posStack = 0
465
- let negStack = 0
466
-
467
- datasets.forEach((ds, dsIdx) => {
468
- const axis = ds.yAxisID === 'y1' && yAxis1 ? yAxis1 : yAxis
469
- const value = ds.data[i] ?? 0
470
- const baseValue = stacked ? (value >= 0 ? posStack : negStack) : 0
471
- const topValue = baseValue + value
472
-
473
- const yBase = this.scaleY(plot, axis, baseValue)
474
- const yTop = this.scaleY(plot, axis, topValue)
475
- const x = stacked ? cx : cx + dsIdx * barWidth
476
- const y = Math.min(yBase, yTop)
477
- const h = Math.abs(yTop - yBase)
478
-
479
- const color = Array.isArray(ds.backgroundColor)
480
- ? ds.backgroundColor[i] ?? paletteColor(dsIdx)
481
- : (typeof ds.backgroundColor === 'string' ? ds.backgroundColor : paletteColor(dsIdx))
482
- this.ctx.fillStyle = color
483
- this.ctx.fillRect(x, y, Math.max(1, barWidth - 2), h)
484
-
485
- if (ds.borderWidth && ds.borderColor && typeof ds.borderColor === 'string') {
486
- this.ctx.lineWidth = ds.borderWidth
487
- this.ctx.strokeStyle = ds.borderColor
488
- this.ctx.strokeRect(x, y, Math.max(1, barWidth - 2), h)
489
- }
490
-
491
- if (stacked) {
492
- if (value >= 0)
493
- posStack = topValue
494
- else negStack = topValue
495
- }
496
-
497
- this.hitItems.push({
498
- datasetIndex: dsIdx,
499
- dataIndex: i,
500
- x: x + barWidth / 2,
501
- y,
502
- label: labels[i] ?? String(i),
503
- value,
504
- })
505
- })
506
- }
507
- }
508
-
509
- // ---------- radial (pie/doughnut) ---------- //
510
-
511
- private renderRadial(width: number, height: number): void {
512
- const datasets = this.data.datasets
513
- if (datasets.length === 0)
514
- return
515
-
516
- const ds = datasets[0]
517
- if (!ds)
518
- return
519
- const labels = this.data.labels ?? []
520
-
521
- const legend = this.layoutLegend(width, height)
522
- let plotW = width
523
- let plotH = height
524
- let offsetX = 0
525
- let offsetY = 0
526
- if (legend.position === 'right') {
527
- plotW = width - legend.w
528
- }
529
- else if (legend.position === 'left') {
530
- plotW = width - legend.w
531
- offsetX = legend.w
532
- }
533
- else if (legend.position === 'top') {
534
- plotH = height - legend.h
535
- offsetY = legend.h
536
- }
537
- else if (legend.position === 'bottom') {
538
- plotH = height - legend.h
539
- }
540
-
541
- this.drawLegend(legend, datasets.map((d, i): ChartDataset => ({
542
- ...d,
543
- label: labels[i] ?? d.label,
544
- })), labels)
545
-
546
- const cx = offsetX + plotW / 2
547
- const cy = offsetY + plotH / 2
548
- const radius = Math.min(plotW, plotH) / 2 - 8
549
- const cutoutRaw = this.options.cutout
550
- const cutoutPct = typeof cutoutRaw === 'string' && cutoutRaw.endsWith('%')
551
- ? Number.parseFloat(cutoutRaw) / 100
552
- : (this.type === 'doughnut' ? 0.6 : 0)
553
- const inner = radius * cutoutPct
554
-
555
- // Layout each slice via d3-shape's `pie()` generator (from
556
- // `@ts-charts/shape`). The generator handles ordering, value
557
- // normalisation, and the start/end angle math we used to inline.
558
- // We then render with `arc()` against the canvas context so the
559
- // existing canvas-only API stays unchanged from the caller's POV.
560
- const pieLayout = (d3Pie() as any)
561
- .value((d: number) => Math.max(0, d || 0))
562
- .startAngle(-Math.PI / 2)
563
- .endAngle(-Math.PI / 2 + Math.PI * 2)
564
- .sort(null)
565
- const slices = pieLayout(ds.data) as Array<{ startAngle: number, endAngle: number, value: number, index: number }>
566
-
567
- const slicePath = (d3Arc() as any)
568
- .innerRadius(inner)
569
- .outerRadius(radius)
570
-
571
- this.ctx.save()
572
- this.ctx.translate(cx, cy)
573
- for (const s of slices) {
574
- const i = s.index
575
- const value = ds.data[i] ?? 0
576
- const color = Array.isArray(ds.backgroundColor)
577
- ? ds.backgroundColor[i] ?? paletteColor(i)
578
- : (typeof ds.backgroundColor === 'string' ? ds.backgroundColor : paletteColor(i))
579
-
580
- this.ctx.beginPath()
581
- slicePath.context(this.ctx)(s)
582
- this.ctx.fillStyle = color
583
- this.ctx.fill()
584
-
585
- const mid = (s.startAngle + s.endAngle) / 2 - Math.PI / 2
586
- const hitR = (radius + inner) / 2
587
- this.hitItems.push({
588
- datasetIndex: 0,
589
- dataIndex: i,
590
- x: cx + Math.cos(mid) * hitR,
591
- y: cy + Math.sin(mid) * hitR,
592
- label: labels[i] ?? String(i),
593
- value,
594
- })
595
- }
596
- this.ctx.restore()
597
- // Reset the path's bound context so the generator instance doesn't
598
- // hold a reference to a destroyed canvas across re-renders.
599
- slicePath.context(null)
600
- }
601
-
602
- // ---------- legend ---------- //
603
-
604
- private layoutLegend(width: number, _height: number): { w: number, h: number, position: 'top' | 'right' | 'bottom' | 'left' | 'none' } {
605
- const cfg = this.options.plugins?.legend
606
- if (cfg?.display === false || !this.data.datasets.some(d => d.label))
607
- return { w: 0, h: 0, position: 'none' }
608
- const position = cfg?.position ?? 'top'
609
- if (position === 'left' || position === 'right')
610
- return { w: 100, h: 0, position }
611
- return { w: 0, h: Math.max(24, Math.ceil(this.data.datasets.length / 4) * 18 + 8), position }
612
- }
613
-
614
- private drawLegend(legend: ReturnType<Chart['layoutLegend']>, datasets: ChartDataset[], labels?: string[]): void {
615
- if (legend.position === 'none')
616
- return
617
- const cfg = this.options.plugins?.legend
618
- const items = labels && (this.type === 'pie' || this.type === 'doughnut')
619
- ? labels.map((label, i): { label: string, color: string } => {
620
- const ds = datasets[0]
621
- const color = Array.isArray(ds?.backgroundColor)
622
- ? ds.backgroundColor[i] ?? paletteColor(i)
623
- : (typeof ds?.backgroundColor === 'string' ? ds.backgroundColor : paletteColor(i))
624
- return { label, color }
625
- })
626
- : datasets.map((ds, i): { label: string, color: string } => ({
627
- label: ds.label ?? `Dataset ${i + 1}`,
628
- color: typeof ds.borderColor === 'string'
629
- ? ds.borderColor
630
- : Array.isArray(ds.backgroundColor)
631
- ? (ds.backgroundColor[0] ?? paletteColor(i))
632
- : (typeof ds.backgroundColor === 'string' ? ds.backgroundColor : paletteColor(i)),
633
- }))
634
-
635
- if (items.length === 0)
636
- return
637
-
638
- this.ctx.save()
639
- this.ctx.font = `${cfg?.labels?.font?.size ?? 12}px system-ui, sans-serif`
640
- this.ctx.fillStyle = cfg?.labels?.color ?? DEFAULT_TEXT_COLOR
641
- this.ctx.textBaseline = 'middle'
642
-
643
- const boxW = cfg?.labels?.boxWidth ?? 12
644
- const padding = cfg?.labels?.padding ?? 8
645
-
646
- if (legend.position === 'top' || legend.position === 'bottom') {
647
- const y = legend.position === 'top' ? 12 : (this.canvas.clientHeight || 0) - 12
648
- let x = padding
649
- this.ctx.textAlign = 'left'
650
- for (const it of items) {
651
- this.ctx.fillStyle = it.color
652
- this.ctx.fillRect(x, y - boxW / 2, boxW, boxW)
653
- x += boxW + 4
654
- this.ctx.fillStyle = cfg?.labels?.color ?? DEFAULT_TEXT_COLOR
655
- this.ctx.fillText(it.label, x, y)
656
- x += this.ctx.measureText(it.label).width + padding * 2
657
- }
658
- }
659
- else {
660
- const x = legend.position === 'right' ? (this.canvas.clientWidth || 0) - 110 : 8
661
- let y = padding + 6
662
- for (const it of items) {
663
- this.ctx.fillStyle = it.color
664
- this.ctx.fillRect(x, y - boxW / 2, boxW, boxW)
665
- this.ctx.fillStyle = cfg?.labels?.color ?? DEFAULT_TEXT_COLOR
666
- this.ctx.textAlign = 'left'
667
- this.ctx.fillText(it.label, x + boxW + 4, y)
668
- y += 18
669
- }
670
- }
671
- this.ctx.restore()
672
- }
673
-
674
- // ---------- tooltip ---------- //
675
-
676
- private handleMouseMove(event: MouseEvent): void {
677
- if (this.hitItems.length === 0)
678
- return
679
- const rect = this.canvas.getBoundingClientRect()
680
- const mx = event.clientX - rect.left
681
- const my = event.clientY - rect.top
682
-
683
- const mode = this.options.interaction?.mode ?? this.options.plugins?.tooltip?.mode ?? 'nearest'
684
-
685
- if (mode === 'index') {
686
- const closest = this.findClosestByIndex(mx)
687
- if (closest)
688
- this.showTooltip(event.clientX, event.clientY, closest)
689
- else this.hideTooltip()
690
- return
691
- }
692
-
693
- let nearest: HitItem | null = null
694
- let bestDist = Number.POSITIVE_INFINITY
695
- for (const h of this.hitItems) {
696
- const dx = h.x - mx
697
- const dy = h.y - my
698
- const dist = dx * dx + dy * dy
699
- if (dist < bestDist) {
700
- bestDist = dist
701
- nearest = h
702
- }
703
- }
704
- if (nearest && bestDist < 60 * 60)
705
- this.showTooltip(event.clientX, event.clientY, [nearest])
706
- else this.hideTooltip()
707
- }
708
-
709
- private findClosestByIndex(mx: number): HitItem[] | null {
710
- let bestIndex = -1
711
- let bestDist = Number.POSITIVE_INFINITY
712
- for (const h of this.hitItems) {
713
- const dist = Math.abs(h.x - mx)
714
- if (dist < bestDist) {
715
- bestDist = dist
716
- bestIndex = h.dataIndex
717
- }
718
- }
719
- if (bestIndex < 0)
720
- return null
721
- return this.hitItems.filter(h => h.dataIndex === bestIndex)
722
- }
723
-
724
- private showTooltip(clientX: number, clientY: number, items: HitItem[]): void {
725
- if (!this.tooltipEl) {
726
- this.tooltipEl = document.createElement('div')
727
- Object.assign(this.tooltipEl.style, {
728
- position: 'fixed',
729
- pointerEvents: 'none',
730
- background: this.options.plugins?.tooltip?.backgroundColor ?? DEFAULT_BG,
731
- color: this.options.plugins?.tooltip?.bodyColor ?? '#fff',
732
- padding: '6px 10px',
733
- borderRadius: '6px',
734
- font: '12px system-ui, sans-serif',
735
- zIndex: '9999',
736
- whiteSpace: 'pre-line',
737
- transform: 'translate(8px, 8px)',
738
- })
739
- document.body.appendChild(this.tooltipEl)
740
- }
741
-
742
- const cb = this.options.plugins?.tooltip?.callbacks
743
- const lines: string[] = []
744
- items.forEach((it) => {
745
- const ds = this.data.datasets[it.datasetIndex]
746
- if (!ds) return
747
- const ctx: TooltipCallbackContext = {
748
- dataset: ds,
749
- datasetIndex: it.datasetIndex,
750
- dataIndex: it.dataIndex,
751
- parsed: { y: it.value },
752
- raw: it.value,
753
- label: it.label,
754
- formattedValue: formatTick(it.value),
755
- }
756
- const labelOut = cb?.label ? cb.label(ctx) : `${ds.label ?? ''}: ${formatTick(it.value)}`
757
- const text = Array.isArray(labelOut) ? labelOut.join('\n') : labelOut
758
- lines.push(text)
759
- })
760
-
761
- const titleOut = cb?.title
762
- ? cb.title(items.flatMap((it) => {
763
- const ds = this.data.datasets[it.datasetIndex]
764
- if (!ds) return []
765
- return [{
766
- dataset: ds,
767
- datasetIndex: it.datasetIndex,
768
- dataIndex: it.dataIndex,
769
- parsed: { y: it.value },
770
- raw: it.value,
771
- label: it.label,
772
- formattedValue: formatTick(it.value),
773
- }]
774
- }))
775
- : items[0]?.label
776
- const titleText = Array.isArray(titleOut) ? titleOut.join('\n') : titleOut
777
-
778
- this.tooltipEl.textContent = ''
779
- if (titleText) {
780
- const t = document.createElement('strong')
781
- t.textContent = titleText
782
- this.tooltipEl.appendChild(t)
783
- this.tooltipEl.appendChild(document.createElement('br'))
784
- }
785
- const body = document.createTextNode(lines.join('\n'))
786
- this.tooltipEl.appendChild(body)
787
- this.tooltipEl.style.left = `${clientX}px`
788
- this.tooltipEl.style.top = `${clientY}px`
789
- this.tooltipEl.style.display = 'block'
790
- }
791
-
792
- private hideTooltip(): void {
793
- if (this.tooltipEl)
794
- this.tooltipEl.style.display = 'none'
795
- }
796
- }
package/src/colors.ts DELETED
@@ -1,33 +0,0 @@
1
- export const DEFAULT_PALETTE = [
2
- '#3b82f6',
3
- '#10b981',
4
- '#f59e0b',
5
- '#ef4444',
6
- '#8b5cf6',
7
- '#ec4899',
8
- '#14b8a6',
9
- '#f97316',
10
- '#6366f1',
11
- '#84cc16',
12
- ]
13
-
14
- export function paletteColor(index: number): string {
15
- // The palette is a non-empty literal-typed const, so the modulo
16
- // index is always in bounds — but the strict-null compiler flag
17
- // can't see that. Fall back to the first color defensively.
18
- return DEFAULT_PALETTE[index % DEFAULT_PALETTE.length] ?? DEFAULT_PALETTE[0]!
19
- }
20
-
21
- export function withAlpha(color: string, alpha: number): string {
22
- if (color.startsWith('#')) {
23
- const hex = color.slice(1)
24
- const r = Number.parseInt(hex.slice(0, 2), 16)
25
- const g = Number.parseInt(hex.slice(2, 4), 16)
26
- const b = Number.parseInt(hex.slice(4, 6), 16)
27
- return `rgba(${r}, ${g}, ${b}, ${alpha})`
28
- }
29
- if (color.startsWith('rgb(')) {
30
- return color.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
31
- }
32
- return color
33
- }
package/src/index.ts DELETED
@@ -1,26 +0,0 @@
1
- import { Chart } from './chart'
2
-
3
- export { Chart }
4
- export default Chart
5
- export { DEFAULT_PALETTE, paletteColor, withAlpha } from './colors'
6
- export { formatTick, niceTicks } from './ticks'
7
- export type {
8
- ChartConfig,
9
- ChartData,
10
- ChartDataset,
11
- ChartOptions,
12
- ChartType,
13
- LegendConfig,
14
- ScaleConfig,
15
- TooltipCallbackContext,
16
- TooltipConfig,
17
- } from './types'
18
-
19
- /**
20
- * Chart.js compatibility shim — `chart.js/auto` exposes a `registerables`
21
- * array used to opt-in to all chart types and plugins. We auto-register
22
- * everything by default, so the shim is intentionally empty; export it
23
- * so existing call sites that destructure `{ Chart, registerables }` and
24
- * call `Chart.register(...registerables)` keep working unchanged.
25
- */
26
- export const registerables: unknown[] = []
package/src/ticks.ts DELETED
@@ -1,37 +0,0 @@
1
- import { format } from '@ts-charts/format'
2
- import { scaleLinear } from '@ts-charts/scale'
3
-
4
- /**
5
- * Compute "nice" tick values for a numeric range.
6
- *
7
- * Delegates to `@ts-charts/scale`'s `scaleLinear().nice().ticks()` so we
8
- * inherit the same 1/2/5×10ⁿ progression D3 has battle-tested for a decade.
9
- */
10
- export function niceTicks(min: number, max: number, count: number = 8): number[] {
11
- if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min)
12
- return [Number.isFinite(min) ? min : 0]
13
-
14
- const scale = scaleLinear().domain([min, max]).nice(count)
15
- const ticks = scale.ticks(count)
16
- if (ticks.length === 0)
17
- return [min, max]
18
- return ticks
19
- }
20
-
21
- const SI = format('~s')
22
-
23
- /**
24
- * Format a tick value compactly. Uses ts-charts SI notation (`1.5k`, `2.5M`)
25
- * which is what the dashboards expect, falling back to integer/2dp for
26
- * values under 1000.
27
- */
28
- export function formatTick(value: number): string {
29
- if (!Number.isFinite(value))
30
- return ''
31
- const abs = Math.abs(value)
32
- if (abs >= 1000)
33
- return SI(value).replace('G', 'B').toUpperCase()
34
- if (Number.isInteger(value))
35
- return value.toString()
36
- return value.toFixed(2)
37
- }
package/src/types.ts DELETED
@@ -1,96 +0,0 @@
1
- export type ChartType = 'line' | 'bar' | 'doughnut' | 'pie' | 'radar'
2
-
3
- export interface ChartDataset {
4
- label?: string
5
- data: number[]
6
- backgroundColor?: string | string[] | CanvasGradient
7
- borderColor?: string | string[] | CanvasGradient
8
- borderWidth?: number
9
- fill?: boolean
10
- tension?: number
11
- pointRadius?: number
12
- pointBackgroundColor?: string
13
- pointBorderColor?: string
14
- stack?: string
15
- yAxisID?: string
16
- type?: ChartType
17
- }
18
-
19
- export interface ChartData {
20
- labels?: string[]
21
- datasets: ChartDataset[]
22
- }
23
-
24
- export interface ScaleConfig {
25
- beginAtZero?: boolean
26
- display?: boolean
27
- stacked?: boolean
28
- grid?: { display?: boolean, color?: string, drawBorder?: boolean }
29
- ticks?: {
30
- color?: string
31
- font?: { size?: number, family?: string }
32
- callback?: (value: number, index: number, ticks: any[]) => string
33
- stepSize?: number
34
- maxTicksLimit?: number
35
- }
36
- min?: number
37
- max?: number
38
- position?: 'left' | 'right' | 'top' | 'bottom'
39
- title?: { display?: boolean, text?: string, color?: string }
40
- }
41
-
42
- export interface LegendConfig {
43
- display?: boolean
44
- position?: 'top' | 'right' | 'bottom' | 'left'
45
- align?: 'start' | 'center' | 'end'
46
- labels?: { color?: string, font?: { size?: number }, boxWidth?: number, padding?: number }
47
- }
48
-
49
- export interface TooltipCallbackContext {
50
- dataset: ChartDataset
51
- datasetIndex: number
52
- dataIndex: number
53
- parsed: { x?: number, y: number }
54
- raw: number
55
- label: string
56
- formattedValue: string
57
- }
58
-
59
- export interface TooltipConfig {
60
- enabled?: boolean
61
- mode?: 'index' | 'point' | 'nearest' | 'dataset'
62
- intersect?: boolean
63
- callbacks?: {
64
- label?: (ctx: TooltipCallbackContext) => string | string[]
65
- title?: (ctx: TooltipCallbackContext[]) => string | string[]
66
- }
67
- backgroundColor?: string
68
- titleColor?: string
69
- bodyColor?: string
70
- borderColor?: string
71
- borderWidth?: number
72
- padding?: number
73
- }
74
-
75
- export interface ChartOptions {
76
- responsive?: boolean
77
- maintainAspectRatio?: boolean
78
- cutout?: string | number
79
- interaction?: { mode?: 'index' | 'point' | 'nearest' | 'dataset', intersect?: boolean }
80
- scales?: Record<string, ScaleConfig>
81
- plugins?: {
82
- legend?: LegendConfig
83
- tooltip?: TooltipConfig
84
- title?: { display?: boolean, text?: string, color?: string, font?: { size?: number } }
85
- }
86
- animation?: false | { duration?: number }
87
- layout?: { padding?: number | { top?: number, right?: number, bottom?: number, left?: number } }
88
- }
89
-
90
- export interface ChartConfig {
91
- type: ChartType
92
- data: ChartData
93
- options?: ChartOptions
94
- }
95
-
96
- export type CanvasContext = HTMLCanvasElement | CanvasRenderingContext2D
File without changes
File without changes
File without changes
File without changes
File without changes