@peter.prib/renderengine 0.1.0 → 0.2.1
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/README.md +10 -1
- package/REFERENCE.md +42 -3
- package/dist/renderengine-workspace.umd.js +1079 -124
- package/dist/renderengine.umd.js +1079 -124
- package/examples/diagram-editor.html +9 -4
- package/package.json +3 -2
- package/src/chartCatalog.js +49 -4
- package/src/charting/Gauge.js +6 -0
- package/src/charting/charts/ArcGauge.js +55 -0
- package/src/charting/charts/BarGauge.js +32 -11
- package/src/charting/charts/BulletGauge.js +52 -56
- package/src/charting/charts/DialGauge.js +54 -26
- package/src/charting/charts/GaugeChart.js +141 -13
- package/src/charting/charts/KpiTile.js +72 -0
- package/src/charting/charts/MultiRingGauge.js +54 -0
- package/src/charting/charts/RingGauge.js +23 -0
- package/src/charting/charts/ThermometerGauge.js +43 -0
- package/src/charting/gauges/ArcTrack.js +51 -0
- package/src/charting/gauges/GaugeAnimation.js +51 -0
- package/src/charting/gauges/GaugeBands.js +48 -0
- package/src/charting/gauges/GaugeScale.js +87 -0
- package/src/charting/gauges/GaugeTrack.js +44 -0
- package/src/charting/gauges/GaugeValue.js +61 -0
- package/src/charting/gauges/HorizontalTrack.js +14 -0
- package/src/charting/gauges/LinearTrack.js +29 -0
- package/src/charting/gauges/Sparkline.js +42 -0
- package/src/charting/gauges/VerticalTrack.js +17 -0
- package/src/charting/templates/Template.js +0 -6
- package/src/index.js +84 -12
|
@@ -1,22 +1,150 @@
|
|
|
1
1
|
import { Chart } from '../Chart.js';
|
|
2
2
|
import { Gauge } from '../Gauge.js';
|
|
3
|
+
import { GaugeScale } from '../gauges/GaugeScale.js';
|
|
4
|
+
import { GaugeBands } from '../gauges/GaugeBands.js';
|
|
5
|
+
import { GaugeValue } from '../gauges/GaugeValue.js';
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
|
-
* GaugeChart — abstract base for
|
|
8
|
+
* GaugeChart — abstract base for gauges: one value shown along a track.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
10
|
+
* It reads what every gauge has in common from its data and options (a
|
|
11
|
+
* `GaugeScale`, `GaugeBands`, a `GaugeValue`) and draws the parts every gauge
|
|
12
|
+
* has on the subclass's `track()`: the background or the bands, the value, a
|
|
13
|
+
* target marker, ticks and the labels. A subclass picks the track and its
|
|
14
|
+
* layout, and adds its own parts (the dial's needle).
|
|
15
|
+
*
|
|
16
|
+
* Options every gauge takes:
|
|
17
|
+
* min, max (0, 100), format, units — the scale (see GaugeScale)
|
|
18
|
+
* bands: [{to, color?, label?}] — ranges drawn behind the value; the value takes its band's colour
|
|
19
|
+
* bandColors: string[] — colours for bands without their own
|
|
20
|
+
* color — the value's colour: one, a gradient array, or (percent) => colour
|
|
21
|
+
* target — a marker at a value (or data.target)
|
|
22
|
+
* targetColor ('#333')
|
|
23
|
+
* ticks: number — that many equal steps, marked and labelled
|
|
24
|
+
* segments: number, segmentGap (2) — the track as that many blocks (LED style), lit up to the value;
|
|
25
|
+
* with bands, each lit block takes its own band's colour
|
|
26
|
+
* title
|
|
9
27
|
*/
|
|
10
28
|
export class GaugeChart extends Chart {
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
29
|
+
/** The value's colour when neither `color` nor a band gives one. */
|
|
30
|
+
static defaultColor = '#5352ed';
|
|
31
|
+
|
|
32
|
+
/** The track behind the value when there are no bands. */
|
|
33
|
+
static trackColor = '#f1f2f6';
|
|
34
|
+
|
|
35
|
+
/** Where and how big this type draws when the options don't say (cx, cy, r, ...). */
|
|
36
|
+
static LAYOUT = {};
|
|
37
|
+
|
|
38
|
+
/** The layout: the options' values for this type's LAYOUT keys, over its own. */
|
|
39
|
+
get layout() {
|
|
40
|
+
const layout = { ...this.constructor.LAYOUT };
|
|
41
|
+
for (const key of Object.keys(layout)) if (this.options[key] != null) layout[key] = this.options[key];
|
|
42
|
+
return layout;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get scale() {
|
|
46
|
+
const { min, max, format, units } = this.options;
|
|
47
|
+
return (this._scale ??= new GaugeScale({ min, max, format: format ?? this.defaultFormat(), units }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get bands() {
|
|
51
|
+
// `ranges` and `rangeColors`: the bullet graph's older names for them.
|
|
52
|
+
const { bands, ranges, bandColors = this.options.rangeColors } = this.options;
|
|
53
|
+
return (this._bands ??= new GaugeBands(bands ?? ranges ?? this.defaultBands(), this.scale, bandColors ? { colors: bandColors } : {}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get gaugeValue() { return (this._value ??= GaugeValue.from(this.data, this.options)); }
|
|
57
|
+
|
|
58
|
+
/** The value, held to the scale. */
|
|
59
|
+
get value() { return this.scale.clamp(this.gaugeValue.value); }
|
|
60
|
+
|
|
61
|
+
/** A format this gauge type reads its numbers with when none is given (null: the scale's). */
|
|
62
|
+
defaultFormat() { return null; }
|
|
63
|
+
|
|
64
|
+
/** Bands this gauge type draws when none are given. */
|
|
65
|
+
defaultBands() { return []; }
|
|
66
|
+
|
|
67
|
+
/** The track the value moves along. Subclasses implement. @returns {import('../gauges/GaugeTrack.js').GaugeTrack} */
|
|
68
|
+
track() { throw new Error(`${this.constructor.name} must implement track()`); }
|
|
69
|
+
|
|
70
|
+
/** The value's colour: the `color` option, else its band's, else the type's own. */
|
|
71
|
+
valueColor(value = this.value) {
|
|
72
|
+
const { color } = this.options;
|
|
73
|
+
if (color != null) return Gauge.resolveColor(color, this.scale.fraction(value) * 100);
|
|
74
|
+
return this.bands.at(value)?.color ?? this.constructor.defaultColor;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* With `segments`, the track as that many blocks a `segmentGap` (pixels)
|
|
79
|
+
* apart: [{from, to, mid}] as fractions; null without (fewer than 2).
|
|
80
|
+
*/
|
|
81
|
+
segmentsOf(track) {
|
|
82
|
+
const n = Math.round(this.options.segments ?? 0);
|
|
83
|
+
if (n < 2) return null;
|
|
84
|
+
const gap = Math.min((this.options.segmentGap ?? 2) / (track.length || 1), 0.5 / n);
|
|
85
|
+
return Array.from({ length: n }, (_, i) => ({ from: i / n + gap / 2, to: (i + 1) / n - gap / 2, mid: (i + 0.5) / n }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The bands, lighter, along the whole track; or the plain track without any. */
|
|
89
|
+
drawBackground(track, options = {}) {
|
|
90
|
+
const blocks = this.segmentsOf(track);
|
|
91
|
+
if (blocks) {
|
|
92
|
+
for (const block of blocks) {
|
|
93
|
+
const band = this.bands.at(this.scale.valueAt(block.mid));
|
|
94
|
+
track.draw(this.renderer, block.from, block.to, band ? GaugeBands.tint(band.color) : this.constructor.trackColor, { ...options, cap: undefined });
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!this.bands.length) {
|
|
99
|
+
track.draw(this.renderer, 0, 1, this.constructor.trackColor, options);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const band of this.bands.list) {
|
|
103
|
+
track.draw(this.renderer, this.scale.fraction(band.from), this.scale.fraction(band.to), GaugeBands.tint(band.color), { ...options, cap: undefined });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A value: the track from the scale's start to it. This gauge's value and
|
|
109
|
+
* scale unless given (a gauge of several values draws each on its own).
|
|
110
|
+
*/
|
|
111
|
+
drawValue(track, { value = this.value, scale = this.scale, color, ...options } = {}) {
|
|
112
|
+
const blocks = this.segmentsOf(track);
|
|
113
|
+
if (!blocks) {
|
|
114
|
+
track.draw(this.renderer, 0, scale.fraction(value), color ?? this.valueColor(value), options);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// The blocks up to the value are lit: each in its band's colour (a level meter), unless a colour is given.
|
|
118
|
+
const lit = Math.round(scale.fraction(value) * blocks.length);
|
|
119
|
+
const own = color ?? (this.options.color != null || !this.bands.length ? this.valueColor(value) : null);
|
|
120
|
+
for (const block of blocks.slice(0, lit)) {
|
|
121
|
+
const blockColor = own ?? this.bands.at(scale.valueAt(block.mid))?.color ?? this.constructor.defaultColor;
|
|
122
|
+
track.draw(this.renderer, block.from, block.to, blockColor, { ...options, cap: undefined });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A line across the track at the target, a little past both edges. */
|
|
127
|
+
drawTarget(track, { overhang = 3, width = 2 } = {}) {
|
|
128
|
+
const { target } = this.gaugeValue;
|
|
129
|
+
if (target == null) return;
|
|
130
|
+
const half = track.thickness / 2 + overhang;
|
|
131
|
+
track.line(this.renderer, this.scale.fraction(target), -half, half, { color: this.options.targetColor ?? '#333', width });
|
|
15
132
|
}
|
|
16
133
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
134
|
+
/** `ticks` marks outside the track, each labelled with its value. */
|
|
135
|
+
drawTicks(track) {
|
|
136
|
+
const { ticks } = this.options;
|
|
137
|
+
if (!ticks) return;
|
|
138
|
+
const count = Math.max(1, Math.round(ticks));
|
|
139
|
+
const edge = track.thickness / 2;
|
|
140
|
+
for (const value of this.scale.ticks(count)) {
|
|
141
|
+
const f = this.scale.fraction(value);
|
|
142
|
+
track.line(this.renderer, f, edge + 2, edge + 6, { color: '#999', width: 1 });
|
|
143
|
+
const at = track.point(f, edge + 14);
|
|
144
|
+
this.renderer.text(this.scale.tickLabel(value, count), at.x, at.y, {
|
|
145
|
+
textAnchor: track.anchorAt(f), verticalAlign: 'middle', fontSize: '10px', fill: '#747d8c',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
20
148
|
}
|
|
21
149
|
|
|
22
150
|
/** Draw a gauge title. */
|
|
@@ -30,9 +158,9 @@ export class GaugeChart extends Chart {
|
|
|
30
158
|
});
|
|
31
159
|
}
|
|
32
160
|
|
|
33
|
-
/** Draw
|
|
34
|
-
|
|
35
|
-
this.renderer.text(
|
|
161
|
+
/** Draw the value's label, centred at a point. */
|
|
162
|
+
_renderValueLabel(x, y, options = {}) {
|
|
163
|
+
this.renderer.text(this.scale.label(this.value), x, y, {
|
|
36
164
|
textAnchor: options.textAnchor || 'middle',
|
|
37
165
|
verticalAlign: options.verticalAlign || 'middle',
|
|
38
166
|
fontSize: options.fontSize || '12px',
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { GaugeChart } from './GaugeChart.js';
|
|
2
|
+
import { Sparkline } from '../gauges/Sparkline.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* KpiTile — a key figure on a card: its title, its value large, the change
|
|
6
|
+
* since the previous value (▲ / ▼, coloured good or bad), and a sparkline of
|
|
7
|
+
* its history.
|
|
8
|
+
*
|
|
9
|
+
* Data: a number, or `{ value, previous?, history?, target? }` (`history` ends
|
|
10
|
+
* with the current value; without `previous` the one before it is used).
|
|
11
|
+
* Options: x, y, width, height (10, 10, 220, 120), format and units (a
|
|
12
|
+
* number with thousands grouped by default), change ('percent', or 'value'
|
|
13
|
+
* for the difference), upIsGood (true: up is green; false for costs, errors,
|
|
14
|
+
* ...), bands (the value takes its band's colour), sparkColor, background,
|
|
15
|
+
* and title.
|
|
16
|
+
*/
|
|
17
|
+
export class KpiTile extends GaugeChart {
|
|
18
|
+
static chartType = 'kpiTile';
|
|
19
|
+
static LAYOUT = { x: 10, y: 10, width: 220, height: 120 };
|
|
20
|
+
static GOOD = '#2e9e5b';
|
|
21
|
+
static BAD = '#d64545';
|
|
22
|
+
|
|
23
|
+
/** A figure reads as it is, its thousands grouped ("12,345"). */
|
|
24
|
+
defaultFormat() { return { grouping: true }; }
|
|
25
|
+
|
|
26
|
+
/** The value as it is: a tile has no range to hold it to. */
|
|
27
|
+
get value() { return this.gaugeValue.value; }
|
|
28
|
+
|
|
29
|
+
/** The value's colour: its band's when there are bands, else the text colour. */
|
|
30
|
+
valueColor(value = this.value) {
|
|
31
|
+
if (this.options.color != null || this.bands.length) return super.valueColor(value);
|
|
32
|
+
return '#2f3542';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The change line: "▲ 10.0%" or "▼ 3", and whether it is good; null without a previous value. */
|
|
36
|
+
get changeText() {
|
|
37
|
+
const { change, changeRatio } = this.gaugeValue;
|
|
38
|
+
if (change == null) return null;
|
|
39
|
+
const { change: as = 'percent', upIsGood = true } = this.options;
|
|
40
|
+
const arrow = change > 0 ? '▲' : change < 0 ? '▼' : '■';
|
|
41
|
+
const amount = as === 'value' || changeRatio == null
|
|
42
|
+
? this.scale.label(Math.abs(change))
|
|
43
|
+
: `${(Math.abs(changeRatio) * 100).toFixed(1)}%`;
|
|
44
|
+
const good = change === 0 ? null : (change > 0) === upIsGood;
|
|
45
|
+
return { text: `${arrow} ${amount}`, good };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_render() {
|
|
49
|
+
const { x, y, width, height } = this.layout;
|
|
50
|
+
const { title, background = '#ffffff', sparkColor = '#5352ed' } = this.options;
|
|
51
|
+
const pad = 12;
|
|
52
|
+
this.renderer.rect(x, y, width, height, background, { color: '#e1e4e8', width: 1 }, { rx: 8, ry: 8 });
|
|
53
|
+
if (title) {
|
|
54
|
+
this.renderer.text(title, x + pad, y + pad + 6, { textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#747d8c' });
|
|
55
|
+
}
|
|
56
|
+
const valueSize = Math.max(14, Math.round(height * 0.26));
|
|
57
|
+
const valueY = y + pad + 18 + valueSize / 2;
|
|
58
|
+
this.renderer.text(this.scale.label(this.value), x + pad, valueY, {
|
|
59
|
+
textAnchor: 'start', verticalAlign: 'middle', fontSize: `${valueSize}px`, fontWeight: 'bold', fill: this.valueColor(),
|
|
60
|
+
});
|
|
61
|
+
const change = this.changeText;
|
|
62
|
+
if (change) {
|
|
63
|
+
const color = change.good == null ? '#747d8c' : change.good ? KpiTile.GOOD : KpiTile.BAD;
|
|
64
|
+
this.renderer.text(change.text, x + width - pad, valueY, { textAnchor: 'end', verticalAlign: 'middle', fontSize: '12px', fontWeight: 'bold', fill: color });
|
|
65
|
+
}
|
|
66
|
+
const sparkTop = valueY + valueSize / 2 + 8;
|
|
67
|
+
const sparkHeight = y + height - pad - sparkTop;
|
|
68
|
+
if (sparkHeight > 4) {
|
|
69
|
+
new Sparkline(this.gaugeValue.history, { x: x + pad, y: sparkTop, width: width - 2 * pad, height: sparkHeight }).draw(this.renderer, { color: sparkColor });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { GaugeChart } from './GaugeChart.js';
|
|
2
|
+
import { ArcTrack } from '../gauges/ArcTrack.js';
|
|
3
|
+
import { GaugeScale } from '../gauges/GaugeScale.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MultiRingGauge — several values as rings one inside the other ("activity
|
|
7
|
+
* rings"), each filled to its value on its own scale, with a legend.
|
|
8
|
+
*
|
|
9
|
+
* Data: `[{ value, label?, color?, min?, max? }]` (or plain numbers), outer
|
|
10
|
+
* ring first; each ring's min and max default to the options'.
|
|
11
|
+
* Options: cx, cy, r (the outer ring: 100, 100, 70), thickness (12), gap (4),
|
|
12
|
+
* startAngle (-90) and sweep (360), colors (a colour per ring without its
|
|
13
|
+
* own), legend (true: each ring's label and value to the right), and the
|
|
14
|
+
* GaugeChart options min, max, format, units.
|
|
15
|
+
*/
|
|
16
|
+
export class MultiRingGauge extends GaugeChart {
|
|
17
|
+
static chartType = 'multiRingGauge';
|
|
18
|
+
static LAYOUT = { cx: 100, cy: 100, r: 70, thickness: 12, gap: 4, startAngle: -90, sweep: 360 };
|
|
19
|
+
static COLORS = ['#5352ed', '#2ed573', '#ffa502', '#ff4757', '#1e90ff'];
|
|
20
|
+
|
|
21
|
+
/** One per value: its track, scale and colour. */
|
|
22
|
+
get rings() {
|
|
23
|
+
const { cx, cy, r, thickness, gap, startAngle, sweep } = this.layout;
|
|
24
|
+
const { min, max, format, units, colors = MultiRingGauge.COLORS } = this.options;
|
|
25
|
+
return this.gaugeValue.values.map((v, i) => ({
|
|
26
|
+
...v,
|
|
27
|
+
track: new ArcTrack({ cx, cy, r: r - i * (thickness + gap), thickness, startAngle, sweep }),
|
|
28
|
+
scale: new GaugeScale({ min: v.min ?? min, max: v.max ?? max, format: format ?? null, units }),
|
|
29
|
+
color: v.color ?? colors[i % colors.length],
|
|
30
|
+
})).filter(ring => ring.track.r > ring.track.thickness / 2);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_render() {
|
|
34
|
+
const { title, legend = true } = this.options;
|
|
35
|
+
const { cx, cy, r, thickness } = this.layout;
|
|
36
|
+
const rings = this.rings;
|
|
37
|
+
for (const ring of rings) {
|
|
38
|
+
this.drawBackground(ring.track);
|
|
39
|
+
this.drawValue(ring.track, { value: ring.scale.clamp(ring.value), scale: ring.scale, color: ring.color, cap: 'round' });
|
|
40
|
+
}
|
|
41
|
+
if (legend) {
|
|
42
|
+
const x = cx + r + thickness / 2 + 16;
|
|
43
|
+
const top = cy - ((rings.length - 1) * 18) / 2;
|
|
44
|
+
rings.forEach((ring, i) => {
|
|
45
|
+
const y = top + i * 18;
|
|
46
|
+
this.renderer.circle(x + 4, y, 4, ring.color);
|
|
47
|
+
this.renderer.text(`${ring.label ?? `#${i + 1}`} ${ring.scale.label(ring.scale.clamp(ring.value))}`, x + 14, y, {
|
|
48
|
+
textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#2f3542',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
this._renderTitle(title, cx, cy + r + thickness / 2 + 18, { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ArcGauge } from './ArcGauge.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RingGauge — a progress ring: a whole circle filled clockwise from the top
|
|
5
|
+
* to the value, its number in the middle.
|
|
6
|
+
*
|
|
7
|
+
* Options as ArcGauge's, drawn as a ring: cx, cy, r (100, 100, 60),
|
|
8
|
+
* thickness (14), startAngle (-90, the top) and sweep (360).
|
|
9
|
+
*/
|
|
10
|
+
export class RingGauge extends ArcGauge {
|
|
11
|
+
static chartType = 'ringGauge';
|
|
12
|
+
static LAYOUT = { cx: 100, cy: 100, r: 60, thickness: 14, startAngle: -90, sweep: 360 };
|
|
13
|
+
|
|
14
|
+
/** The number in the very middle. */
|
|
15
|
+
drawLabels(track) {
|
|
16
|
+
this._renderValueLabel(track.cx, track.cy, { fontSize: `${Math.max(12, Math.round(track.r * 0.36))}px`, fill: '#2f3542', fontWeight: 'bold' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A ring's two ends are one point: no end labels. */
|
|
20
|
+
drawEnds() {}
|
|
21
|
+
|
|
22
|
+
titleY(track) { return track.cy + track.r + track.thickness / 2 + 18; }
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GaugeChart } from './GaugeChart.js';
|
|
2
|
+
import { VerticalTrack } from '../gauges/VerticalTrack.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ThermometerGauge — a tube filled from a bulb at its foot up to the value.
|
|
6
|
+
*
|
|
7
|
+
* Options: x, y, width, height (the tube: 40, 20, 18, 200), bulb (its radius:
|
|
8
|
+
* 16), and every GaugeChart option (min, max, units, bands, target, ticks,
|
|
9
|
+
* segments, ...). The value's label sits under the bulb.
|
|
10
|
+
*/
|
|
11
|
+
export class ThermometerGauge extends GaugeChart {
|
|
12
|
+
static chartType = 'thermometerGauge';
|
|
13
|
+
static LAYOUT = { x: 40, y: 20, width: 18, height: 200, bulb: 16 };
|
|
14
|
+
static defaultColor = '#ff4757';
|
|
15
|
+
|
|
16
|
+
track() {
|
|
17
|
+
const { x, y, width, height } = this.layout;
|
|
18
|
+
return new VerticalTrack({ x, y, length: height, thickness: width, radius: width / 2 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The bulb's centre: under the tube, overlapping its foot so the two join. */
|
|
22
|
+
bulbCenter(track) {
|
|
23
|
+
const { bulb } = this.layout;
|
|
24
|
+
const foot = track.point(0);
|
|
25
|
+
return { x: foot.x, y: foot.y + bulb * 0.75 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_render() {
|
|
29
|
+
const { title } = this.options;
|
|
30
|
+
const { bulb } = this.layout;
|
|
31
|
+
const track = this.track();
|
|
32
|
+
const centre = this.bulbCenter(track);
|
|
33
|
+
this._renderTitle(title, centre.x, track.y - 10, { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
|
|
34
|
+
this.drawBackground(track);
|
|
35
|
+
this.drawValue(track);
|
|
36
|
+
// The bulb is always full: the value's colour, round a lighter ring.
|
|
37
|
+
this.renderer.circle(centre.x, centre.y, bulb, this.constructor.trackColor);
|
|
38
|
+
this.renderer.circle(centre.x, centre.y, bulb - 3, this.valueColor());
|
|
39
|
+
this.drawTarget(track);
|
|
40
|
+
this.drawTicks(track);
|
|
41
|
+
this._renderValueLabel(centre.x, centre.y + bulb + 14, { fontWeight: 'bold', fill: '#2f3542' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Gauge } from '../Gauge.js';
|
|
2
|
+
import { GaugeTrack } from './GaugeTrack.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ArcTrack — a gauge track along a circle's arc: the dial's 270°, a half
|
|
6
|
+
* circle, a full ring. Angles are degrees clockwise from 3 o'clock (the
|
|
7
|
+
* screen's own direction), so 135° with a 270° sweep is the dial's
|
|
8
|
+
* bottom-left to bottom-right, and 180° with 180° is a half circle over the top.
|
|
9
|
+
*/
|
|
10
|
+
export class ArcTrack extends GaugeTrack {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {number} options.cx
|
|
14
|
+
* @param {number} options.cy
|
|
15
|
+
* @param {number} options.r - The radius of the track's centre line
|
|
16
|
+
* @param {number} options.thickness
|
|
17
|
+
* @param {number} [options.startAngle=135]
|
|
18
|
+
* @param {number} [options.sweep=270]
|
|
19
|
+
*/
|
|
20
|
+
constructor({ cx, cy, r, thickness, startAngle = 135, sweep = 270 }) {
|
|
21
|
+
super(thickness);
|
|
22
|
+
Object.assign(this, { cx, cy, r, startAngle, sweep });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The arc's length along its centre line, in pixels. */
|
|
26
|
+
get length() { return this.r * Math.abs(this.sweep) * Math.PI / 180; }
|
|
27
|
+
|
|
28
|
+
/** The angle in degrees at a fraction along. */
|
|
29
|
+
angleAt(fraction) { return this.startAngle + fraction * this.sweep; }
|
|
30
|
+
|
|
31
|
+
point(fraction, offset = 0) {
|
|
32
|
+
const a = this.angleAt(fraction) * Math.PI / 180;
|
|
33
|
+
const r = this.r + offset;
|
|
34
|
+
return { x: this.cx + r * Math.cos(a), y: this.cy + r * Math.sin(a) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
draw(renderer, from, to, color, { thickness = this.thickness, cap } = {}) {
|
|
38
|
+
const rad = Math.PI / 180;
|
|
39
|
+
const a0 = this.angleAt(Math.min(from, to)) * rad;
|
|
40
|
+
const a1 = this.angleAt(Math.max(from, to)) * rad;
|
|
41
|
+
renderer.path(Gauge.arcPath(this.cx, this.cy, this.r, a0, a1), 'none', { color, width: thickness, ...(cap ? { cap } : {}) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get center() { return { x: this.cx, y: this.cy }; }
|
|
45
|
+
|
|
46
|
+
/** A label outside the arc leans away from the centre: 'start' on the right, 'end' on the left. */
|
|
47
|
+
anchorAt(fraction) {
|
|
48
|
+
const dx = Math.cos(this.angleAt(fraction) * Math.PI / 180);
|
|
49
|
+
return dx > 0.3 ? 'start' : dx < -0.3 ? 'end' : 'middle';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { GaugeValue } from './GaugeValue.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GaugeAnimation — eases a gauge from the value it shows to a new one, so a
|
|
5
|
+
* needle or fill moves instead of jumping.
|
|
6
|
+
*
|
|
7
|
+
* Each frame draws the gauge from `GaugeValue.between(from, to, t)`: the
|
|
8
|
+
* value, the target and each of several values move together; the history
|
|
9
|
+
* and the previous value are the new ones throughout. The clock is
|
|
10
|
+
* injectable (`schedule`, `now`) so it runs without a browser in tests.
|
|
11
|
+
*/
|
|
12
|
+
export class GaugeAnimation {
|
|
13
|
+
/** Ease out (cubic): quick at first, settling on the value. */
|
|
14
|
+
static ease(t) { return 1 - (1 - t) ** 3; }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {GaugeValue} options.from
|
|
19
|
+
* @param {GaugeValue} options.to
|
|
20
|
+
* @param {number} [options.duration=400] - Milliseconds
|
|
21
|
+
* @param {(data: Object) => void} options.draw - Draws one frame from its gauge data
|
|
22
|
+
* @param {(callback: Function) => *} [options.schedule] - requestAnimationFrame, else a 16ms timer
|
|
23
|
+
* @param {() => number} [options.now]
|
|
24
|
+
*/
|
|
25
|
+
constructor({ from, to, duration = 400, draw, schedule = GaugeAnimation.defaultSchedule(), now = () => Date.now() }) {
|
|
26
|
+
Object.assign(this, { from, to, duration, draw, schedule, now });
|
|
27
|
+
this.stopped = false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static defaultSchedule() {
|
|
31
|
+
return typeof globalThis.requestAnimationFrame === 'function'
|
|
32
|
+
? (callback) => globalThis.requestAnimationFrame(callback)
|
|
33
|
+
: (callback) => setTimeout(callback, 16);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Draw the first frame now and the rest on the clock, ending exactly on `to`. */
|
|
37
|
+
start() {
|
|
38
|
+
const started = this.now();
|
|
39
|
+
const frame = () => {
|
|
40
|
+
if (this.stopped) return;
|
|
41
|
+
const t = this.duration > 0 ? Math.min(1, (this.now() - started) / this.duration) : 1;
|
|
42
|
+
this.draw(GaugeValue.between(this.from, this.to, GaugeAnimation.ease(t)));
|
|
43
|
+
if (t < 1) this.schedule(frame);
|
|
44
|
+
};
|
|
45
|
+
frame();
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** No more frames (a newer value has started its own). */
|
|
50
|
+
stop() { this.stopped = true; }
|
|
51
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ColorPalette } from '../ColorPalette.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GaugeBands — ranges along a gauge's scale ("poor", "ok", "good"), each with
|
|
5
|
+
* its colour and label.
|
|
6
|
+
*
|
|
7
|
+
* Given as `[{ to, color?, label? }]` in order: each band runs from where the
|
|
8
|
+
* one before it ended (the scale's min for the first) to its `to` (`max` is
|
|
9
|
+
* read the same, as the bullet graph has always taken it). Every gauge draws
|
|
10
|
+
* its bands from here, and a value takes the colour of the band it falls in.
|
|
11
|
+
*/
|
|
12
|
+
export class GaugeBands {
|
|
13
|
+
/** Band colours used when a band has none of its own: bad, warning, good. */
|
|
14
|
+
static COLORS = ['#ff6b6b', '#ffd93d', '#6bcb77'];
|
|
15
|
+
|
|
16
|
+
/** How far a band's colour is mixed toward white when drawn behind a value. */
|
|
17
|
+
static TINT = 0.65;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Array<{to?:number, max?:number, color?:string, label?:string}>} bands
|
|
21
|
+
* @param {import('./GaugeScale.js').GaugeScale} scale
|
|
22
|
+
* @param {Object} [options]
|
|
23
|
+
* @param {string[]} [options.colors] - Colours for bands without their own
|
|
24
|
+
*/
|
|
25
|
+
constructor(bands = [], scale, { colors = GaugeBands.COLORS } = {}) {
|
|
26
|
+
this.scale = scale;
|
|
27
|
+
let from = scale.min;
|
|
28
|
+
this.list = (bands ?? []).map((band, index) => {
|
|
29
|
+
const to = scale.clamp(band.to ?? band.max ?? scale.max);
|
|
30
|
+
const entry = { from, to, color: band.color ?? colors[index % colors.length], label: band.label ?? null, index };
|
|
31
|
+
from = to;
|
|
32
|
+
return entry;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get length() { return this.list.length; }
|
|
37
|
+
|
|
38
|
+
/** The band a value falls in (the last one at a shared edge), or null. */
|
|
39
|
+
at(value) {
|
|
40
|
+
const v = this.scale.clamp(value);
|
|
41
|
+
return this.list.find(b => v >= b.from && v < b.to) ?? this.list.findLast(b => v >= b.from && v <= b.to) ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A band's colour as drawn behind the value: lighter, so the value shows on it. */
|
|
45
|
+
static tint(color) {
|
|
46
|
+
return /^#[0-9a-f]{6}$/i.test(color) ? ColorPalette.interpolate(color, '#ffffff', GaugeBands.TINT) : color;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GaugeScale — the range a gauge measures and how its numbers read.
|
|
3
|
+
*
|
|
4
|
+
* One scale per gauge: it turns a value into a 0-1 fraction of the way along
|
|
5
|
+
* the track, gives the tick values, and formats the value and tick labels.
|
|
6
|
+
* Every gauge draws from it, so `min`, `max` and `format` mean the same on
|
|
7
|
+
* all of them.
|
|
8
|
+
*/
|
|
9
|
+
export class GaugeScale {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} [options]
|
|
12
|
+
* @param {number} [options.min=0]
|
|
13
|
+
* @param {number} [options.max=100]
|
|
14
|
+
* @param {Function|Object} [options.format] - `(value) => string`, or
|
|
15
|
+
* `{ decimals, prefix, suffix, grouping, compact }`. Without one, a
|
|
16
|
+
* 0-100 scale reads as a percentage ("75.0%"), any other as its number.
|
|
17
|
+
* @param {string} [options.units] - The suffix when no format is given ("ms", "°C")
|
|
18
|
+
*/
|
|
19
|
+
constructor({ min = 0, max = 100, format = null, units = null } = {}) {
|
|
20
|
+
this.min = Number(min);
|
|
21
|
+
this.max = Number(max);
|
|
22
|
+
this.format = format ?? { decimals: 1, suffix: units != null ? ` ${units}` : (this.isPercent ? '%' : '') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True for the plain 0-100 scale a percentage gauge has. */
|
|
26
|
+
get isPercent() { return this.min === 0 && this.max === 100; }
|
|
27
|
+
|
|
28
|
+
get span() { return (this.max - this.min) || 1; }
|
|
29
|
+
|
|
30
|
+
/** The value held to the scale's range. */
|
|
31
|
+
clamp(value) {
|
|
32
|
+
const v = Number(value);
|
|
33
|
+
if (!Number.isFinite(v)) return this.min;
|
|
34
|
+
return Math.min(Math.max(v, Math.min(this.min, this.max)), Math.max(this.min, this.max));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How far along the scale a value is, 0-1 (held to the range). */
|
|
38
|
+
fraction(value) { return (this.clamp(value) - this.min) / this.span; }
|
|
39
|
+
|
|
40
|
+
/** The value at a fraction of the way along. */
|
|
41
|
+
valueAt(fraction) { return this.min + fraction * this.span; }
|
|
42
|
+
|
|
43
|
+
/** `count` equal steps: count + 1 values from min to max. */
|
|
44
|
+
ticks(count = 5) {
|
|
45
|
+
const n = Math.max(1, Math.round(count));
|
|
46
|
+
return Array.from({ length: n + 1 }, (_, i) => this.valueAt(i / n));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A value as the gauge shows it. */
|
|
50
|
+
label(value) { return GaugeScale.formatNumber(value, this.format); }
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A tick's label: the value's prefix and suffix, with only the decimals a
|
|
54
|
+
* step of `span / count` needs ("0", "25", ... not "0.0%", "25.0%").
|
|
55
|
+
*/
|
|
56
|
+
tickLabel(value, count = 5) {
|
|
57
|
+
if (typeof this.format === 'function') return this.format(value);
|
|
58
|
+
const step = this.span / Math.max(1, Math.round(count));
|
|
59
|
+
// The fewest decimals that show the step exactly (0.25 needs 2), at most 6.
|
|
60
|
+
let decimals = 0;
|
|
61
|
+
while (decimals < 6 && Math.abs(step * 10 ** decimals - Math.round(step * 10 ** decimals)) > 1e-9) decimals++;
|
|
62
|
+
return GaugeScale.formatNumber(value, { ...this.format, decimals });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Format a number: a function format is called; an object one gives
|
|
67
|
+
* decimals (null: as it is), prefix, suffix, grouping (1,234) and compact (1.2K).
|
|
68
|
+
*/
|
|
69
|
+
static formatNumber(value, format = {}) {
|
|
70
|
+
if (typeof format === 'function') return format(value);
|
|
71
|
+
const { decimals = null, prefix = '', suffix = '', grouping = false, compact = false } = format ?? {};
|
|
72
|
+
const v = Number(value);
|
|
73
|
+
if (!Number.isFinite(v)) return `${prefix}${value}${suffix}`;
|
|
74
|
+
let text;
|
|
75
|
+
if (compact || grouping) {
|
|
76
|
+
const digits = decimals ?? (compact ? 1 : undefined);
|
|
77
|
+
text = new Intl.NumberFormat('en', {
|
|
78
|
+
notation: compact ? 'compact' : 'standard',
|
|
79
|
+
useGrouping: true,
|
|
80
|
+
...(digits != null ? { minimumFractionDigits: compact ? 0 : digits, maximumFractionDigits: digits } : {}),
|
|
81
|
+
}).format(v);
|
|
82
|
+
} else {
|
|
83
|
+
text = decimals == null ? String(v) : v.toFixed(decimals);
|
|
84
|
+
}
|
|
85
|
+
return `${prefix}${text}${suffix}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GaugeTrack — the path a gauge's value moves along, as fractions 0-1.
|
|
3
|
+
*
|
|
4
|
+
* A subclass is one geometry (a straight bar, an arc). The gauge asks it for
|
|
5
|
+
* points and draws segments on it, so bands, the value, ticks and a target
|
|
6
|
+
* marker are drawn the same way on every gauge whatever its shape.
|
|
7
|
+
*
|
|
8
|
+
* A track has a `length` (pixels along it) as well as its `thickness`.
|
|
9
|
+
* Offsets are across the track from its centre line: positive is outward
|
|
10
|
+
* (below a horizontal bar, right of a vertical one, away from an arc's centre).
|
|
11
|
+
*/
|
|
12
|
+
export class GaugeTrack {
|
|
13
|
+
/** @param {number} thickness - The track's width across */
|
|
14
|
+
constructor(thickness) {
|
|
15
|
+
if (new.target === GaugeTrack) throw new TypeError('GaugeTrack is abstract — subclass it');
|
|
16
|
+
this.thickness = thickness;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The point at a fraction along, `offset` across. @returns {{x:number, y:number}} */
|
|
20
|
+
point(fraction, offset = 0) { throw new Error(`${this.constructor.name} must implement point()`); }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Draw the track from one fraction to another, in a colour.
|
|
24
|
+
* @param {Object} renderer
|
|
25
|
+
* @param {number} from - 0-1
|
|
26
|
+
* @param {number} to - 0-1
|
|
27
|
+
* @param {string} color
|
|
28
|
+
* @param {Object} [options] - `thickness` (this track's by default), `cap` ('round' ends)
|
|
29
|
+
*/
|
|
30
|
+
draw(renderer, from, to, color, options = {}) { throw new Error(`${this.constructor.name} must implement draw()`); }
|
|
31
|
+
|
|
32
|
+
/** Where a label sits in the middle of the gauge. @returns {{x:number, y:number}} */
|
|
33
|
+
get center() { throw new Error(`${this.constructor.name} must implement center`); }
|
|
34
|
+
|
|
35
|
+
/** A straight line across the track at a fraction, between two offsets (a tick, a target). */
|
|
36
|
+
line(renderer, fraction, fromOffset, toOffset, stroke) {
|
|
37
|
+
const a = this.point(fraction, fromOffset);
|
|
38
|
+
const b = this.point(fraction, toOffset);
|
|
39
|
+
renderer.path(`M${a.x},${a.y} L${b.x},${b.y}`, 'none', stroke);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How a label at a point outside the track is anchored ('start', 'middle' or 'end'). */
|
|
43
|
+
anchorAt(fraction) { return 'middle'; }
|
|
44
|
+
}
|