@aeriz/chart 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +488 -0
- package/dist/aeriz.cjs +2609 -0
- package/dist/aeriz.esm.js +2597 -0
- package/dist/aeriz.esm.min.js +1 -0
- package/dist/aeriz.js +2609 -0
- package/dist/aeriz.min.cjs +1 -0
- package/dist/aeriz.min.js +1 -0
- package/package.json +89 -0
- package/src/base.js +562 -0
- package/src/charts.js +1365 -0
- package/src/composite.js +187 -0
- package/src/engine.js +287 -0
- package/src/index.d.ts +517 -0
- package/src/index.js +18 -0
- package/src/math.js +178 -0
package/src/composite.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// composite.js — Composite (mixed) chart. Stack any combination of bars,
|
|
2
|
+
// lines, areas, scatter on a shared cartesian axis with optional dual y axes.
|
|
3
|
+
//
|
|
4
|
+
// Layer config: { type: 'bar'|'line'|'area'|'scatter', name, values, axis: 'left'|'right', color?, ... }
|
|
5
|
+
|
|
6
|
+
import { Chart } from './base.js';
|
|
7
|
+
import * as M from './math.js';
|
|
8
|
+
|
|
9
|
+
export class CompositeChart extends Chart {
|
|
10
|
+
_render() {
|
|
11
|
+
const layers = this.options.layers || [];
|
|
12
|
+
const cats = this.options.categories || [];
|
|
13
|
+
if (!layers.length || !cats.length) { this.setEmpty('No data'); return; }
|
|
14
|
+
const e = this.engine;
|
|
15
|
+
const t = this.theme;
|
|
16
|
+
|
|
17
|
+
// Compute domain per axis.
|
|
18
|
+
const leftLayers = layers.filter(l => l.axis !== 'right');
|
|
19
|
+
const rightLayers = layers.filter(l => l.axis === 'right');
|
|
20
|
+
const domain = (ll) => {
|
|
21
|
+
let mn = Infinity, mx = -Infinity;
|
|
22
|
+
for (const l of ll) {
|
|
23
|
+
if (l.type === 'scatter') {
|
|
24
|
+
for (const p of l.values) {
|
|
25
|
+
if (p.y < mn) mn = p.y; if (p.y > mx) mx = p.y;
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
for (const v of l.values) {
|
|
29
|
+
if (+v < mn) mn = +v; if (+v > mx) mx = +v;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (mn === Infinity) return [0, 1];
|
|
34
|
+
return M.niceDomain(Math.min(0, mn), mx, 5);
|
|
35
|
+
};
|
|
36
|
+
const [yLLo, yLHi] = domain(leftLayers);
|
|
37
|
+
const [yRLo, yRHi] = rightLayers.length ? domain(rightLayers) : [0, 1];
|
|
38
|
+
|
|
39
|
+
// Padding accommodates right axis when present.
|
|
40
|
+
const pad = { l: 50, r: rightLayers.length ? 50 : 16, t: 30, b: 32 };
|
|
41
|
+
const px = pad.l, py = pad.t;
|
|
42
|
+
const pw = e.w - pad.l - pad.r;
|
|
43
|
+
const ph = e.h - pad.t - pad.b;
|
|
44
|
+
const font = `${t.fontSize}px ${t.font}`;
|
|
45
|
+
|
|
46
|
+
// Y axes
|
|
47
|
+
const yLPx = v => py + ph * (1 - (v - yLLo) / (yLHi - yLLo));
|
|
48
|
+
const yRPx = v => py + ph * (1 - (v - yRLo) / (yRHi - yRLo));
|
|
49
|
+
const yLTicks = M.niceTicks(yLLo, yLHi, 5);
|
|
50
|
+
const yRTicks = rightLayers.length ? M.niceTicks(yRLo, yRHi, 5) : [];
|
|
51
|
+
|
|
52
|
+
// Grid
|
|
53
|
+
e.ctx.strokeStyle = t.grid;
|
|
54
|
+
e.ctx.lineWidth = 1;
|
|
55
|
+
e.ctx.beginPath();
|
|
56
|
+
for (const v of yLTicks) {
|
|
57
|
+
const y = Math.round(yLPx(v)) + 0.5;
|
|
58
|
+
e.ctx.moveTo(px, y); e.ctx.lineTo(px + pw, y);
|
|
59
|
+
}
|
|
60
|
+
e.ctx.stroke();
|
|
61
|
+
|
|
62
|
+
// Y labels
|
|
63
|
+
const yLFmt = this.options.yLeftTickFormat || (v => String(v));
|
|
64
|
+
const yRFmt = this.options.yRightTickFormat || (v => String(v));
|
|
65
|
+
for (const v of yLTicks) {
|
|
66
|
+
e.text(yLFmt(v), px - 6, yLPx(v), {
|
|
67
|
+
fill: t.muted, font, align: 'right', baseline: 'middle',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
for (const v of yRTicks) {
|
|
71
|
+
e.text(yRFmt(v), px + pw + 6, yRPx(v), {
|
|
72
|
+
fill: t.muted, font, align: 'left', baseline: 'middle',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// X axis (categorical or time)
|
|
77
|
+
const useTime = this.options.xScale === 'time';
|
|
78
|
+
const xToPx = useTime
|
|
79
|
+
? (i) => px + ((+cats[i] - +cats[0]) / (+cats[cats.length - 1] - +cats[0] || 1)) * pw
|
|
80
|
+
: (i) => px + ((i + 0.5) / cats.length) * pw;
|
|
81
|
+
|
|
82
|
+
// X labels (collision-aware)
|
|
83
|
+
let lastEnd = -Infinity;
|
|
84
|
+
for (let i = 0; i < cats.length; i++) {
|
|
85
|
+
const x = xToPx(i);
|
|
86
|
+
const txt = useTime
|
|
87
|
+
? new Date(+cats[i]).toISOString().slice(0, 10)
|
|
88
|
+
: String(cats[i]);
|
|
89
|
+
const w = e.measure(txt, font);
|
|
90
|
+
if (x - w / 2 < lastEnd + 4) continue;
|
|
91
|
+
lastEnd = x + w / 2;
|
|
92
|
+
e.text(txt, x, py + ph + 6, {
|
|
93
|
+
fill: t.muted, font, align: 'center', baseline: 'top',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
e.poly([{x: px, y: py + ph}, {x: px + pw, y: py + ph}], { stroke: t.axis, lineWidth: 1 });
|
|
97
|
+
|
|
98
|
+
const prog = this._anim.progress;
|
|
99
|
+
const lineW = t.lineWidth;
|
|
100
|
+
|
|
101
|
+
// Render layers in order (back-to-front).
|
|
102
|
+
// Bars first (so lines/scatter can sit on top), grouped in clusters.
|
|
103
|
+
const barLayers = layers.filter(l => l.type === 'bar');
|
|
104
|
+
const slot = pw / cats.length;
|
|
105
|
+
const innerSlot = slot * 0.7;
|
|
106
|
+
const barW = barLayers.length ? Math.max(2, innerSlot / barLayers.length) : 0;
|
|
107
|
+
|
|
108
|
+
barLayers.forEach((layer, bi) => {
|
|
109
|
+
const yPx = layer.axis === 'right' ? yRPx : yLPx;
|
|
110
|
+
const yBase = yPx(0);
|
|
111
|
+
const color = layer.color || this._colorOf(layers.indexOf(layer), layer);
|
|
112
|
+
const items = [];
|
|
113
|
+
for (let i = 0; i < cats.length; i++) {
|
|
114
|
+
const v = +layer.values[i] || 0;
|
|
115
|
+
const xC = xToPx(i);
|
|
116
|
+
const x = xC - innerSlot / 2 + bi * barW;
|
|
117
|
+
const y0 = yBase;
|
|
118
|
+
const y1 = yPx(v);
|
|
119
|
+
const top = Math.min(y0, y1);
|
|
120
|
+
const h = Math.abs(y1 - y0) * prog;
|
|
121
|
+
const animTop = v >= 0 ? y0 - h : y0;
|
|
122
|
+
items.push({ x, y: animTop, w: barW - 1, h, radius: t.barRadius });
|
|
123
|
+
this.hits.add({
|
|
124
|
+
_id: `${layer.name || bi}-${i}`,
|
|
125
|
+
x, y: animTop, w: barW - 1, h,
|
|
126
|
+
tipX: x + barW / 2, tipY: animTop,
|
|
127
|
+
tooltip: `${layer.name || ''} · ${cats[i]}: ${v}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
e.rects(items, color);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Areas
|
|
134
|
+
e.clip(px, py, pw * prog, ph);
|
|
135
|
+
layers.filter(l => l.type === 'area').forEach((layer) => {
|
|
136
|
+
const yPx = layer.axis === 'right' ? yRPx : yLPx;
|
|
137
|
+
const color = layer.color || this._colorOf(layers.indexOf(layer), layer);
|
|
138
|
+
const pts = layer.values.map((v, i) => ({ x: xToPx(i), y: yPx(+v) }));
|
|
139
|
+
const cps = M.bezierControlPoints(pts);
|
|
140
|
+
const grad = e.vGradient(`area-${layer.name}-${color}`, px, py, py + ph, [
|
|
141
|
+
[0, M.colorAlpha(color, 0.4)],
|
|
142
|
+
[1, M.colorAlpha(color, 0)],
|
|
143
|
+
]);
|
|
144
|
+
e.smoothArea(pts, cps, py + ph, grad);
|
|
145
|
+
e.smooth(pts, cps, { stroke: color, lineWidth: lineW });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Lines
|
|
149
|
+
layers.filter(l => l.type === 'line').forEach((layer) => {
|
|
150
|
+
const yPx = layer.axis === 'right' ? yRPx : yLPx;
|
|
151
|
+
const color = layer.color || this._colorOf(layers.indexOf(layer), layer);
|
|
152
|
+
const pts = layer.values.map((v, i) => ({ x: xToPx(i), y: yPx(+v), i, v }));
|
|
153
|
+
const cps = M.bezierControlPoints(pts);
|
|
154
|
+
e.smooth(pts, cps, { stroke: color, lineWidth: lineW });
|
|
155
|
+
if (pts.length <= 50) e.circles(pts, color, t.pointRadius);
|
|
156
|
+
// Hits per point
|
|
157
|
+
for (const p of pts) {
|
|
158
|
+
this.hits.add({
|
|
159
|
+
_id: `${layer.name || 'line'}-${p.i}`,
|
|
160
|
+
x: p.x - 6, y: p.y - 6, w: 12, h: 12,
|
|
161
|
+
tipX: p.x, tipY: p.y - t.pointRadius,
|
|
162
|
+
tooltip: `${layer.name || ''} · ${cats[p.i]}: ${p.v}`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
e.unclip();
|
|
167
|
+
|
|
168
|
+
// Scatter
|
|
169
|
+
layers.filter(l => l.type === 'scatter').forEach((layer) => {
|
|
170
|
+
const yPx = layer.axis === 'right' ? yRPx : yLPx;
|
|
171
|
+
const color = layer.color || this._colorOf(layers.indexOf(layer), layer);
|
|
172
|
+
const items = layer.values.map((p) => ({
|
|
173
|
+
x: xToPx(typeof p.x === 'number' ? p.x : cats.indexOf(p.x)),
|
|
174
|
+
y: yPx(p.y),
|
|
175
|
+
}));
|
|
176
|
+
e.withAlpha(0.7, () => e.circles(items, color, layer.radius || t.pointRadius + 1));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Legend
|
|
180
|
+
this._drawLegend(layers.map((l, i) => ({
|
|
181
|
+
label: (l.name || l.type) + (l.axis === 'right' ? ' (R)' : ''),
|
|
182
|
+
color: l.color || this._colorOf(i, l),
|
|
183
|
+
idx: i,
|
|
184
|
+
hidden: false,
|
|
185
|
+
})), pw);
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// engine.js — Canvas 2D rendering primitives.
|
|
2
|
+
// All public coordinates are CSS pixels. DPR is handled internally.
|
|
3
|
+
|
|
4
|
+
import { TAU } from './math.js';
|
|
5
|
+
|
|
6
|
+
export class Engine {
|
|
7
|
+
constructor(canvas) {
|
|
8
|
+
this.canvas = canvas;
|
|
9
|
+
this.ctx = canvas.getContext('2d');
|
|
10
|
+
this.w = 0;
|
|
11
|
+
this.h = 0;
|
|
12
|
+
this.dpr = 1;
|
|
13
|
+
this._gradCache = new Map();
|
|
14
|
+
this._gradCacheMax = 32;
|
|
15
|
+
this._snap = true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Resize the backing store to (w × h) CSS pixels at the current DPR.
|
|
19
|
+
// The internal coordinate system is reset so all subsequent draw calls
|
|
20
|
+
// use CSS pixels.
|
|
21
|
+
resize(w, h) {
|
|
22
|
+
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
|
|
23
|
+
this.canvas.width = Math.round(w * dpr);
|
|
24
|
+
this.canvas.height = Math.round(h * dpr);
|
|
25
|
+
this.canvas.style.width = w + 'px';
|
|
26
|
+
this.canvas.style.height = h + 'px';
|
|
27
|
+
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
28
|
+
this.w = w;
|
|
29
|
+
this.h = h;
|
|
30
|
+
this.dpr = dpr;
|
|
31
|
+
this._gradCache.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
clear(color) {
|
|
35
|
+
if (color) {
|
|
36
|
+
this.ctx.fillStyle = color;
|
|
37
|
+
this.ctx.fillRect(0, 0, this.w, this.h);
|
|
38
|
+
} else {
|
|
39
|
+
this.ctx.clearRect(0, 0, this.w, this.h);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Primitives ─────────────────────────────────────────
|
|
44
|
+
// All draw methods take CSS-pixel coordinates and apply pixel snapping
|
|
45
|
+
// for crisp 1px lines. Snap is intentionally simple: x.5 alignment for
|
|
46
|
+
// odd-width strokes, integer alignment for fills.
|
|
47
|
+
|
|
48
|
+
rect(x, y, w, h, opts = {}) {
|
|
49
|
+
// Guard against negative/zero dims that animations can momentarily produce.
|
|
50
|
+
if (!(w > 0) || !(h > 0)) return;
|
|
51
|
+
const ctx = this.ctx;
|
|
52
|
+
if (opts.radius && opts.radius > 0) {
|
|
53
|
+
const r = Math.max(0, Math.min(opts.radius, w / 2, h / 2));
|
|
54
|
+
ctx.beginPath();
|
|
55
|
+
ctx.moveTo(x + r, y);
|
|
56
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
57
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
58
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
59
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
60
|
+
ctx.closePath();
|
|
61
|
+
} else {
|
|
62
|
+
ctx.beginPath();
|
|
63
|
+
ctx.rect(x, y, w, h);
|
|
64
|
+
}
|
|
65
|
+
if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
|
|
66
|
+
if (opts.stroke) {
|
|
67
|
+
ctx.strokeStyle = opts.stroke;
|
|
68
|
+
ctx.lineWidth = opts.lineWidth || 1;
|
|
69
|
+
ctx.stroke();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Batch many rectangles of the same color in a single fill.
|
|
74
|
+
rects(items, fill) {
|
|
75
|
+
const ctx = this.ctx;
|
|
76
|
+
ctx.fillStyle = fill;
|
|
77
|
+
ctx.beginPath();
|
|
78
|
+
for (const r of items) {
|
|
79
|
+
// Skip degenerate items that would crash arcTo / rect.
|
|
80
|
+
if (!(r.w > 0) || !(r.h > 0)) continue;
|
|
81
|
+
if (r.radius && r.radius > 0) {
|
|
82
|
+
const rad = Math.max(0, Math.min(r.radius, r.w / 2, r.h / 2));
|
|
83
|
+
ctx.moveTo(r.x + rad, r.y);
|
|
84
|
+
ctx.arcTo(r.x + r.w, r.y, r.x + r.w, r.y + r.h, rad);
|
|
85
|
+
ctx.arcTo(r.x + r.w, r.y + r.h, r.x, r.y + r.h, rad);
|
|
86
|
+
ctx.arcTo(r.x, r.y + r.h, r.x, r.y, rad);
|
|
87
|
+
ctx.arcTo(r.x, r.y, r.x + r.w, r.y, rad);
|
|
88
|
+
ctx.closePath();
|
|
89
|
+
} else {
|
|
90
|
+
ctx.rect(r.x, r.y, r.w, r.h);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
ctx.fill();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
circle(x, y, r, opts = {}) {
|
|
97
|
+
// arc() throws on negative radii; bail silently instead.
|
|
98
|
+
if (!(r > 0)) return;
|
|
99
|
+
const ctx = this.ctx;
|
|
100
|
+
ctx.beginPath();
|
|
101
|
+
ctx.arc(x, y, r, 0, TAU);
|
|
102
|
+
if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
|
|
103
|
+
if (opts.stroke) {
|
|
104
|
+
ctx.strokeStyle = opts.stroke;
|
|
105
|
+
ctx.lineWidth = opts.lineWidth || 1;
|
|
106
|
+
ctx.stroke();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Batch many circles of the same color and radius.
|
|
111
|
+
circles(items, fill, fixedRadius) {
|
|
112
|
+
const ctx = this.ctx;
|
|
113
|
+
ctx.fillStyle = fill;
|
|
114
|
+
ctx.beginPath();
|
|
115
|
+
for (const c of items) {
|
|
116
|
+
const r = fixedRadius != null ? fixedRadius : c.r;
|
|
117
|
+
if (!(r > 0)) continue;
|
|
118
|
+
ctx.moveTo(c.x + r, c.y);
|
|
119
|
+
ctx.arc(c.x, c.y, r, 0, TAU);
|
|
120
|
+
}
|
|
121
|
+
ctx.fill();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Path of straight segments, optionally closed and filled.
|
|
125
|
+
poly(points, opts = {}) {
|
|
126
|
+
if (points.length < 2) return;
|
|
127
|
+
const ctx = this.ctx;
|
|
128
|
+
const snap = this._snap && opts.lineWidth === 1;
|
|
129
|
+
const adj = snap ? 0.5 : 0;
|
|
130
|
+
ctx.beginPath();
|
|
131
|
+
ctx.moveTo(points[0].x + adj, points[0].y + adj);
|
|
132
|
+
for (let i = 1; i < points.length; i++) {
|
|
133
|
+
ctx.lineTo(points[i].x + adj, points[i].y + adj);
|
|
134
|
+
}
|
|
135
|
+
if (opts.close) ctx.closePath();
|
|
136
|
+
if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
|
|
137
|
+
if (opts.stroke) {
|
|
138
|
+
ctx.strokeStyle = opts.stroke;
|
|
139
|
+
ctx.lineWidth = opts.lineWidth || 1.5;
|
|
140
|
+
ctx.lineCap = opts.cap || 'butt';
|
|
141
|
+
ctx.lineJoin = opts.join || 'miter';
|
|
142
|
+
if (opts.dash) ctx.setLineDash(opts.dash);
|
|
143
|
+
ctx.stroke();
|
|
144
|
+
if (opts.dash) ctx.setLineDash([]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Smooth path drawn through `points` using bezier control points `cps`.
|
|
149
|
+
smooth(points, cps, opts = {}) {
|
|
150
|
+
if (points.length < 2) return;
|
|
151
|
+
const ctx = this.ctx;
|
|
152
|
+
ctx.beginPath();
|
|
153
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
154
|
+
for (let i = 0; i < cps.length; i++) {
|
|
155
|
+
const c = cps[i];
|
|
156
|
+
const p = points[i + 1];
|
|
157
|
+
ctx.bezierCurveTo(c.c1x, c.c1y, c.c2x, c.c2y, p.x, p.y);
|
|
158
|
+
}
|
|
159
|
+
if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
|
|
160
|
+
if (opts.stroke) {
|
|
161
|
+
ctx.strokeStyle = opts.stroke;
|
|
162
|
+
ctx.lineWidth = opts.lineWidth || 2;
|
|
163
|
+
ctx.lineCap = opts.cap || 'round';
|
|
164
|
+
ctx.lineJoin = opts.join || 'round';
|
|
165
|
+
ctx.stroke();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Smooth-filled area from `points` down to baseline `y0`.
|
|
170
|
+
smoothArea(points, cps, y0, fill) {
|
|
171
|
+
if (points.length < 2) return;
|
|
172
|
+
const ctx = this.ctx;
|
|
173
|
+
ctx.beginPath();
|
|
174
|
+
ctx.moveTo(points[0].x, y0);
|
|
175
|
+
ctx.lineTo(points[0].x, points[0].y);
|
|
176
|
+
for (let i = 0; i < cps.length; i++) {
|
|
177
|
+
const c = cps[i];
|
|
178
|
+
const p = points[i + 1];
|
|
179
|
+
ctx.bezierCurveTo(c.c1x, c.c1y, c.c2x, c.c2y, p.x, p.y);
|
|
180
|
+
}
|
|
181
|
+
ctx.lineTo(points[points.length - 1].x, y0);
|
|
182
|
+
ctx.closePath();
|
|
183
|
+
ctx.fillStyle = fill;
|
|
184
|
+
ctx.fill();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Stepped path (3 modes).
|
|
188
|
+
step(points, mode, opts = {}) {
|
|
189
|
+
if (points.length < 2) return;
|
|
190
|
+
const ctx = this.ctx;
|
|
191
|
+
ctx.beginPath();
|
|
192
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
193
|
+
for (let i = 1; i < points.length; i++) {
|
|
194
|
+
const a = points[i - 1], b = points[i];
|
|
195
|
+
if (mode === 'after') {
|
|
196
|
+
ctx.lineTo(b.x, a.y);
|
|
197
|
+
ctx.lineTo(b.x, b.y);
|
|
198
|
+
} else if (mode === 'middle') {
|
|
199
|
+
const mx = (a.x + b.x) / 2;
|
|
200
|
+
ctx.lineTo(mx, a.y);
|
|
201
|
+
ctx.lineTo(mx, b.y);
|
|
202
|
+
ctx.lineTo(b.x, b.y);
|
|
203
|
+
} else {
|
|
204
|
+
ctx.lineTo(a.x, b.y);
|
|
205
|
+
ctx.lineTo(b.x, b.y);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (opts.stroke) {
|
|
209
|
+
ctx.strokeStyle = opts.stroke;
|
|
210
|
+
ctx.lineWidth = opts.lineWidth || 1.5;
|
|
211
|
+
ctx.stroke();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
text(str, x, y, opts = {}) {
|
|
216
|
+
const ctx = this.ctx;
|
|
217
|
+
ctx.font = opts.font || '11px ui-monospace, monospace';
|
|
218
|
+
ctx.fillStyle = opts.fill || '#000';
|
|
219
|
+
ctx.textAlign = opts.align || 'left';
|
|
220
|
+
ctx.textBaseline = opts.baseline || 'alphabetic';
|
|
221
|
+
if (opts.rotate) {
|
|
222
|
+
ctx.save();
|
|
223
|
+
ctx.translate(x, y);
|
|
224
|
+
ctx.rotate(opts.rotate);
|
|
225
|
+
ctx.fillText(str, 0, 0);
|
|
226
|
+
ctx.restore();
|
|
227
|
+
} else {
|
|
228
|
+
ctx.fillText(str, x, y);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
measure(str, font) {
|
|
233
|
+
this.ctx.font = font || '11px ui-monospace, monospace';
|
|
234
|
+
return this.ctx.measureText(str).width;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Arc segment for donut/polar charts.
|
|
238
|
+
arc(cx, cy, rIn, rOut, a0, a1, opts = {}) {
|
|
239
|
+
// Negative radii crash ctx.arc; clamp at 0 and bail when both collapse.
|
|
240
|
+
const ri = Math.max(0, rIn);
|
|
241
|
+
const ro = Math.max(0, rOut);
|
|
242
|
+
if (ro <= 0) return;
|
|
243
|
+
const ctx = this.ctx;
|
|
244
|
+
ctx.beginPath();
|
|
245
|
+
ctx.arc(cx, cy, ro, a0, a1);
|
|
246
|
+
ctx.arc(cx, cy, ri, a1, a0, true);
|
|
247
|
+
ctx.closePath();
|
|
248
|
+
if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
|
|
249
|
+
if (opts.stroke) {
|
|
250
|
+
ctx.strokeStyle = opts.stroke;
|
|
251
|
+
ctx.lineWidth = opts.lineWidth || 1;
|
|
252
|
+
ctx.stroke();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Gradient cache ─────────────────────────────────────
|
|
257
|
+
// Vertical gradient between two colors. Cached by key.
|
|
258
|
+
vGradient(key, x0, y0, y1, stops) {
|
|
259
|
+
if (this._gradCache.has(key)) return this._gradCache.get(key);
|
|
260
|
+
const g = this.ctx.createLinearGradient(x0, y0, x0, y1);
|
|
261
|
+
for (const [pos, color] of stops) g.addColorStop(pos, color);
|
|
262
|
+
if (this._gradCache.size >= this._gradCacheMax) {
|
|
263
|
+
const first = this._gradCache.keys().next().value;
|
|
264
|
+
this._gradCache.delete(first);
|
|
265
|
+
}
|
|
266
|
+
this._gradCache.set(key, g);
|
|
267
|
+
return g;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── Clip region for plot area ──────────────────────────
|
|
271
|
+
clip(x, y, w, h) {
|
|
272
|
+
const ctx = this.ctx;
|
|
273
|
+
ctx.save();
|
|
274
|
+
ctx.beginPath();
|
|
275
|
+
ctx.rect(x, y, w, h);
|
|
276
|
+
ctx.clip();
|
|
277
|
+
}
|
|
278
|
+
unclip() { this.ctx.restore(); }
|
|
279
|
+
|
|
280
|
+
withAlpha(a, fn) {
|
|
281
|
+
const ctx = this.ctx;
|
|
282
|
+
const prev = ctx.globalAlpha;
|
|
283
|
+
ctx.globalAlpha = prev * a;
|
|
284
|
+
fn();
|
|
285
|
+
ctx.globalAlpha = prev;
|
|
286
|
+
}
|
|
287
|
+
}
|