@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 +10 -7
- package/src/chart.ts +0 -796
- package/src/colors.ts +0 -33
- package/src/index.ts +0 -26
- package/src/ticks.ts +0 -37
- package/src/types.ts +0 -96
- /package/dist/{src/chart.d.ts → chart.d.ts} +0 -0
- /package/dist/{src/colors.d.ts → colors.d.ts} +0 -0
- /package/dist/{src/index.d.ts → index.d.ts} +0 -0
- /package/dist/{src/ticks.d.ts → ticks.d.ts} +0 -0
- /package/dist/{src/types.d.ts → types.d.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/charts",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.70.
|
|
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
|
-
"
|
|
33
|
-
"
|
|
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
|
-
"
|
|
37
|
-
"
|
|
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
|