@peter.prib/renderengine 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/REFERENCE.md +42 -3
- package/dist/renderengine-workspace.umd.js +1079 -118
- package/dist/renderengine.umd.js +1079 -118
- package/package.json +1 -1
- 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/index.js +84 -12
package/package.json
CHANGED
package/src/chartCatalog.js
CHANGED
|
@@ -461,18 +461,18 @@ export const CHART_CATALOG = [
|
|
|
461
461
|
key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
|
|
462
462
|
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
463
463
|
axes: [
|
|
464
|
-
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] },
|
|
464
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
465
465
|
],
|
|
466
|
-
dataShape: 'number (
|
|
466
|
+
dataShape: 'number, {value, target?}, or [{value, label?, color?}] (a needle each) — no category axis; the only role IS the value (0-100 unless min/max are given)',
|
|
467
467
|
streamShape: 'none', // no array/window at all — update by calling dialGauge(newValue) again directly
|
|
468
468
|
},
|
|
469
469
|
{
|
|
470
470
|
key: 'gauge-bar', label: 'Gauge (Bar)', method: 'barGauge', category: 'gauge',
|
|
471
471
|
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
472
472
|
axes: [
|
|
473
|
-
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] },
|
|
473
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
474
474
|
],
|
|
475
|
-
dataShape: 'number (0-100)',
|
|
475
|
+
dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
|
|
476
476
|
streamShape: 'none', // no array/window at all — update by calling barGauge(newValue) again directly
|
|
477
477
|
},
|
|
478
478
|
{
|
|
@@ -485,6 +485,51 @@ export const CHART_CATALOG = [
|
|
|
485
485
|
dataShape: 'number or {value, target?, ranges?:Array<{max, label?, color?}>}',
|
|
486
486
|
streamShape: 'none',
|
|
487
487
|
},
|
|
488
|
+
{
|
|
489
|
+
key: 'gauge-arc', label: 'Gauge (Arc)', method: 'arcGauge', category: 'gauge',
|
|
490
|
+
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
491
|
+
axes: [
|
|
492
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
493
|
+
],
|
|
494
|
+
dataShape: 'number, or {value, target?} (0-100 unless min/max are given) — a half circle by default, any startAngle/sweep',
|
|
495
|
+
streamShape: 'none',
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
key: 'gauge-ring', label: 'Gauge (Ring)', method: 'ringGauge', category: 'gauge',
|
|
499
|
+
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
500
|
+
axes: [
|
|
501
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
502
|
+
],
|
|
503
|
+
dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
|
|
504
|
+
streamShape: 'none',
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
key: 'gauge-rings', label: 'Gauge (Rings)', method: 'multiRingGauge', category: 'gauge',
|
|
508
|
+
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
509
|
+
axes: [
|
|
510
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
511
|
+
],
|
|
512
|
+
dataShape: 'Array<number|{value, label?, color?, min?, max?}> — one ring per value, outer first, each on its own scale',
|
|
513
|
+
streamShape: 'none',
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
key: 'gauge-thermometer', label: 'Gauge (Thermometer)', method: 'thermometerGauge', category: 'gauge',
|
|
517
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
518
|
+
axes: [
|
|
519
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
520
|
+
],
|
|
521
|
+
dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
|
|
522
|
+
streamShape: 'none',
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
key: 'kpi-tile', label: 'KPI Tile', method: 'kpiTile', category: 'gauge',
|
|
526
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
527
|
+
axes: [
|
|
528
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
529
|
+
],
|
|
530
|
+
dataShape: 'number, or {value?, previous?, history?:number[]} — the figure, its change since the previous value, a sparkline of the history',
|
|
531
|
+
streamShape: 'none',
|
|
532
|
+
},
|
|
488
533
|
{
|
|
489
534
|
key: 'bar3D', label: 'Bar (3D)', method: 'barChart3D', category: '3d',
|
|
490
535
|
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.ISOMETRIC,
|
package/src/charting/Gauge.js
CHANGED
|
@@ -38,6 +38,12 @@ export class Gauge {
|
|
|
38
38
|
* Angles are in radians.
|
|
39
39
|
*/
|
|
40
40
|
static arcPath(cx, cy, r, startAngle, endAngle) {
|
|
41
|
+
// A whole circle starts and ends on one point, which an SVG arc draws as nothing: two halves.
|
|
42
|
+
if (endAngle - startAngle >= 2 * Math.PI - 1e-9) {
|
|
43
|
+
const mid = startAngle + Math.PI;
|
|
44
|
+
const at = (a) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
|
|
45
|
+
return `M ${at(startAngle)} A ${r} ${r} 0 0 1 ${at(mid)} A ${r} ${r} 0 0 1 ${at(startAngle)}`;
|
|
46
|
+
}
|
|
41
47
|
const largeArcFlag = endAngle - startAngle <= Math.PI ? '0' : '1';
|
|
42
48
|
const start = {
|
|
43
49
|
x: cx + r * Math.cos(startAngle),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { GaugeChart } from './GaugeChart.js';
|
|
2
|
+
import { ArcTrack } from '../gauges/ArcTrack.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ArcGauge — an arc filled to the value, its number in the middle: a half
|
|
6
|
+
* circle by default, or any start angle and sweep.
|
|
7
|
+
*
|
|
8
|
+
* Options: cx, cy, r (100, 100, 70), thickness (16), startAngle and sweep in
|
|
9
|
+
* degrees (180 and 180: a half circle over the top), cap ('round' ends), and
|
|
10
|
+
* every GaugeChart option (min, max, bands, target, ticks, ...). Without
|
|
11
|
+
* ticks the scale's two ends are labelled.
|
|
12
|
+
*/
|
|
13
|
+
export class ArcGauge extends GaugeChart {
|
|
14
|
+
static chartType = 'arcGauge';
|
|
15
|
+
|
|
16
|
+
static LAYOUT = { cx: 100, cy: 100, r: 70, thickness: 16, startAngle: 180, sweep: 180 };
|
|
17
|
+
|
|
18
|
+
track() { return new ArcTrack(this.layout); }
|
|
19
|
+
|
|
20
|
+
_render() {
|
|
21
|
+
const { title, cap } = this.options;
|
|
22
|
+
const track = this.track();
|
|
23
|
+
const capOption = cap ? { cap } : {};
|
|
24
|
+
this.drawBackground(track, capOption);
|
|
25
|
+
this.drawValue(track, capOption);
|
|
26
|
+
this.drawTarget(track);
|
|
27
|
+
if (this.options.ticks) this.drawTicks(track);
|
|
28
|
+
else this.drawEnds(track);
|
|
29
|
+
this.drawLabels(track);
|
|
30
|
+
this._renderTitle(title, track.cx, this.titleY(track), { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The value's number in the middle, a little above the centre for an open arc. */
|
|
34
|
+
drawLabels(track) {
|
|
35
|
+
const { cx, cy, r } = track;
|
|
36
|
+
this._renderValueLabel(cx, cy - r * 0.15, { fontSize: `${Math.max(12, Math.round(r * 0.32))}px`, fill: '#2f3542', fontWeight: 'bold' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Below the arc's lowest point. */
|
|
40
|
+
titleY(track) {
|
|
41
|
+
const lowest = Math.max(track.point(0).y, track.point(1).y, track.cy);
|
|
42
|
+
return lowest + track.thickness / 2 + 18;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The scale's min and max under the arc's two ends. */
|
|
46
|
+
drawEnds(track) {
|
|
47
|
+
const below = track.thickness / 2 + 2;
|
|
48
|
+
for (const f of [0, 1]) {
|
|
49
|
+
const at = track.point(f);
|
|
50
|
+
this.renderer.text(this.scale.tickLabel(this.scale.valueAt(f), 1), at.x, at.y + below + 8, {
|
|
51
|
+
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px', fill: '#747d8c',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,21 +1,42 @@
|
|
|
1
1
|
import { GaugeChart } from './GaugeChart.js';
|
|
2
|
+
import { HorizontalTrack } from '../gauges/HorizontalTrack.js';
|
|
3
|
+
import { VerticalTrack } from '../gauges/VerticalTrack.js';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
* BarGauge — a
|
|
6
|
+
* BarGauge — a straight bar filled to the value, left to right or (with
|
|
7
|
+
* `orientation: 'vertical'`) bottom to top.
|
|
8
|
+
*
|
|
9
|
+
* Options: x, y, width, height (the bar's box; 200 × 30 across, 30 × 200 up),
|
|
10
|
+
* orientation, and every GaugeChart option (min, max, bands, target, ticks, ...).
|
|
5
11
|
*/
|
|
6
12
|
export class BarGauge extends GaugeChart {
|
|
7
13
|
static chartType = 'barGauge';
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
+
get vertical() { return this.options.orientation === 'vertical'; }
|
|
16
|
+
|
|
17
|
+
track() {
|
|
18
|
+
const { x = 10, y = 10 } = this.options;
|
|
19
|
+
const { width = this.vertical ? 30 : 200, height = this.vertical ? 200 : 30 } = this.options;
|
|
20
|
+
return this.vertical
|
|
21
|
+
? new VerticalTrack({ x, y, length: height, thickness: width, radius: 4 })
|
|
22
|
+
: new HorizontalTrack({ x, y, length: width, thickness: height, radius: 4 });
|
|
23
|
+
}
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
this.
|
|
19
|
-
this.
|
|
25
|
+
_render() {
|
|
26
|
+
const { x = 10, y = 10, title } = this.options;
|
|
27
|
+
const track = this.track();
|
|
28
|
+
this._renderTitle(title, x, y - (this.vertical ? 8 : 5));
|
|
29
|
+
this.drawBackground(track);
|
|
30
|
+
this.drawValue(track);
|
|
31
|
+
this.drawTarget(track);
|
|
32
|
+
this.drawTicks(track);
|
|
33
|
+
if (this.vertical) {
|
|
34
|
+
// A vertical bar is too narrow for its label: it goes underneath.
|
|
35
|
+
const below = track.point(0, 0);
|
|
36
|
+
this._renderValueLabel(below.x, below.y + 14, { fontWeight: 'bold', fill: '#2f3542' });
|
|
37
|
+
} else {
|
|
38
|
+
const { x: cx, y: cy } = track.center;
|
|
39
|
+
this._renderValueLabel(cx, cy, { fontWeight: 'bold' });
|
|
40
|
+
}
|
|
20
41
|
}
|
|
21
42
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { GaugeChart } from './GaugeChart.js';
|
|
2
|
+
import { HorizontalTrack } from '../gauges/HorizontalTrack.js';
|
|
2
3
|
import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -8,82 +9,77 @@ import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
|
8
9
|
* Data may be a plain number (the actual value) or an object
|
|
9
10
|
* `{ value: number, target?: number, ranges?: Array<{max:number, label?, color?}> }`.
|
|
10
11
|
*
|
|
11
|
-
* Options:
|
|
12
|
-
*
|
|
13
|
-
* ranges?: Range[] — qualitative ranges (overrides data.ranges)
|
|
14
|
-
* min?: number = 0 — scale minimum
|
|
15
|
-
* max?: number = 100 — scale maximum
|
|
12
|
+
* Options (with every GaugeChart option: min, max, bands, target, ticks, ...):
|
|
13
|
+
* ranges?: Range[] — the bands, by their older name (overrides data.ranges)
|
|
16
14
|
* barColor?: string — color of the actual-value bar
|
|
17
15
|
* targetColor?: string = #333 — color of the target marker
|
|
18
16
|
* rangeColors?: string[] — fallback palette for ranges without their own color
|
|
19
|
-
* title?: string
|
|
20
17
|
* showLabels?: boolean = true — value/target/range labels
|
|
21
18
|
*/
|
|
22
|
-
export class BulletGauge extends
|
|
19
|
+
export class BulletGauge extends GaugeChart {
|
|
23
20
|
static chartType = 'bullet';
|
|
21
|
+
static defaultColor = '#5470c6';
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const options = this.options;
|
|
28
|
-
const box = ChartBox.fromRenderer(this.renderer);
|
|
29
|
-
const {
|
|
30
|
-
x = box.x, y = box.y, width = box.width, height = box.height * 0.4,
|
|
31
|
-
min = 0, max = 100,
|
|
32
|
-
target: optTarget, targetColor = '#333',
|
|
33
|
-
barColor = '#5470c6',
|
|
34
|
-
ranges: optRanges, rangeColors = ['#ff6b6b', '#ffd93d', '#6bcb77'],
|
|
35
|
-
title, showLabels = true,
|
|
36
|
-
} = options;
|
|
23
|
+
/** Its numbers as they are ("65", not "65.0%"). */
|
|
24
|
+
defaultFormat() { return (v) => String(v); }
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
26
|
+
/** The data's ranges, else poor / satisfactory / good at 60% and 80% of the scale. */
|
|
27
|
+
defaultBands() {
|
|
28
|
+
const max = this.options.max ?? 100;
|
|
29
|
+
return this.data?.bands ?? this.data?.ranges ?? [
|
|
41
30
|
{ max: max * 0.6, label: 'Poor' },
|
|
42
31
|
{ max: max * 0.8, label: 'Satisfactory' },
|
|
43
|
-
{ max
|
|
32
|
+
{ max, label: 'Good' },
|
|
44
33
|
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The bar keeps one colour (`barColor`): the bands behind it are the comparison. */
|
|
37
|
+
valueColor(value) {
|
|
38
|
+
const { barColor, color } = this.options;
|
|
39
|
+
return barColor ?? (color != null ? super.valueColor(value) : this.constructor.defaultColor);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get box() {
|
|
43
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
44
|
+
const { x = box.x, y = box.y, width = box.width, height = box.height * 0.4 } = this.options;
|
|
45
|
+
return { x, y, width, height };
|
|
46
|
+
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
+
track() {
|
|
49
|
+
const { x, y, width, height } = this.box;
|
|
50
|
+
return new HorizontalTrack({ x, y, length: width, thickness: height });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_render() {
|
|
54
|
+
const { title, showLabels = true, ticks } = this.options;
|
|
55
|
+
const { x, y, width, height } = this.box;
|
|
56
|
+
const track = this.track();
|
|
48
57
|
|
|
49
58
|
new ChartLabels({ title }).draw(this.renderer, { x, y, width, height });
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.renderer.rect(rx, y, rw, height, color, null, { opacity: 0.35 });
|
|
59
|
-
if (showLabels && range.label) {
|
|
60
|
-
this.renderer.text(range.label, rx + rw / 2, y + height + 14, {
|
|
61
|
-
textAnchor: 'middle', fontSize: '10px', fill: '#666',
|
|
62
|
-
});
|
|
60
|
+
this.drawBackground(track);
|
|
61
|
+
if (showLabels) {
|
|
62
|
+
const below = y + height + (ticks ? 28 : 14);
|
|
63
|
+
for (const band of this.bands.list) {
|
|
64
|
+
if (!band.label) continue;
|
|
65
|
+
const mid = track.point((this.scale.fraction(band.from) + this.scale.fraction(band.to)) / 2);
|
|
66
|
+
this.renderer.text(band.label, mid.x, below, { textAnchor: 'middle', fontSize: '10px', fill: '#666' });
|
|
63
67
|
}
|
|
64
|
-
|
|
65
|
-
});
|
|
68
|
+
}
|
|
66
69
|
|
|
67
|
-
//
|
|
70
|
+
// The actual value: a bar half as thick, centred on the bands.
|
|
68
71
|
const barHeight = height * 0.5;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
this.renderer.rect(actualX, barY, actualW, barHeight, barColor, null, {});
|
|
73
|
-
|
|
74
|
-
// Draw target marker (vertical line)
|
|
75
|
-
if (target != null) {
|
|
76
|
-
const tx = toX(target);
|
|
77
|
-
this.renderer.path(`M${tx},${y - 2} L${tx},${y + height + 2}`, 'none', { color: targetColor, width: 2 });
|
|
78
|
-
}
|
|
72
|
+
this.drawValue(track, { thickness: barHeight });
|
|
73
|
+
this.drawTarget(track, { overhang: 2 });
|
|
74
|
+
this.drawTicks(track);
|
|
79
75
|
|
|
80
76
|
if (showLabels) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
77
|
+
const { x: cx, y: cy } = track.center;
|
|
78
|
+
this._renderValueLabel(cx, cy, { fontSize: '11px', fontWeight: 'bold' });
|
|
79
|
+
const { target } = this.gaugeValue;
|
|
84
80
|
if (target != null) {
|
|
85
|
-
this.renderer.text(
|
|
86
|
-
textAnchor: 'start', fontSize: '10px', fill: targetColor,
|
|
81
|
+
this.renderer.text(this.scale.label(target), track.point(this.scale.fraction(target)).x + 5, y - 8, {
|
|
82
|
+
textAnchor: 'start', fontSize: '10px', fill: this.options.targetColor ?? '#333',
|
|
87
83
|
});
|
|
88
84
|
}
|
|
89
85
|
}
|
|
@@ -1,37 +1,65 @@
|
|
|
1
|
-
import { Gauge } from '../Gauge.js';
|
|
2
1
|
import { GaugeChart } from './GaugeChart.js';
|
|
2
|
+
import { ArcTrack } from '../gauges/ArcTrack.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* DialGauge —
|
|
5
|
+
* DialGauge — an arc filled to the value, with a needle pointing at it.
|
|
6
|
+
*
|
|
7
|
+
* Data: a value, or several (`[{ value, label?, color? }]`, e.g. actual and
|
|
8
|
+
* budget): a needle each, the first also filling the arc and labelled in
|
|
9
|
+
* the middle, and a legend of them all under the title.
|
|
10
|
+
* Options: cx, cy, r (100, 80, 60), thickness (12), startAngle and sweep in
|
|
11
|
+
* degrees (135 and 270: bottom-left round to bottom-right), needleColors,
|
|
12
|
+
* and every GaugeChart option (min, max, bands, target, ticks, segments, ...).
|
|
6
13
|
*/
|
|
7
14
|
export class DialGauge extends GaugeChart {
|
|
8
15
|
static chartType = 'dialGauge';
|
|
16
|
+
static LAYOUT = { cx: 100, cy: 80, r: 60, thickness: 12, startAngle: 135, sweep: 270 };
|
|
17
|
+
static NEEDLE_COLORS = ['#2f3542', '#ff4757', '#1e90ff', '#ffa502'];
|
|
18
|
+
|
|
19
|
+
track() { return new ArcTrack(this.layout); }
|
|
20
|
+
|
|
21
|
+
/** One per value, the first the main one: {value, label, color}. */
|
|
22
|
+
get needles() {
|
|
23
|
+
const { needleColors = DialGauge.NEEDLE_COLORS } = this.options;
|
|
24
|
+
const values = this.gaugeValue.values.length ? this.gaugeValue.values : [{ value: this.gaugeValue.value }];
|
|
25
|
+
return values.map((v, i) => ({ ...v, value: this.scale.clamp(v.value), color: v.color ?? needleColors[i % needleColors.length] }));
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
_render() {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.renderer.
|
|
23
|
-
|
|
24
|
-
this.renderer.path(Gauge.arcPath(cx, cy, r, startAngle * rad, needleAngle * rad), 'none', { color: resolvedColor, width: 12, cap: 'round' });
|
|
25
|
-
|
|
26
|
-
// Needle
|
|
27
|
-
const needleRad = needleAngle * rad;
|
|
28
|
-
const nx = cx + (r - 8) * Math.cos(needleRad);
|
|
29
|
-
const ny = cy + (r - 8) * Math.sin(needleRad);
|
|
30
|
-
this.renderer.path(`M${cx},${cy} L${nx},${ny}`, 'none', { color: '#2f3542', width: 3, cap: 'round' });
|
|
31
|
-
this.renderer.circle(cx, cy, 5, '#2f3542');
|
|
32
|
-
|
|
33
|
-
// Text
|
|
29
|
+
const { title } = this.options;
|
|
30
|
+
const track = this.track();
|
|
31
|
+
const { cx, cy, r } = track;
|
|
32
|
+
|
|
33
|
+
this.drawBackground(track, { cap: 'round' });
|
|
34
|
+
this.drawValue(track, { cap: 'round' });
|
|
35
|
+
this.drawTarget(track);
|
|
36
|
+
this.drawTicks(track);
|
|
37
|
+
const needles = this.needles;
|
|
38
|
+
// The main needle last, so it is on top.
|
|
39
|
+
for (const needle of [...needles].reverse()) this.drawNeedle(track, needle);
|
|
40
|
+
this.renderer.circle(cx, cy, 5, needles[0].color);
|
|
41
|
+
|
|
34
42
|
this._renderTitle(title, cx, cy + r + 25, { textAnchor: 'middle', fill: '#747d8c', textTransform: 'uppercase' });
|
|
35
|
-
this.
|
|
43
|
+
this._renderValueLabel(cx, cy + r - 15, { fontSize: '18px', fill: '#2f3542', fontWeight: 'bold' });
|
|
44
|
+
if (needles.length > 1) this.drawLegend(needles, cx, cy + r + (title ? 42 : 25));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A needle from the centre to just inside the arc. */
|
|
48
|
+
drawNeedle(track, { value, color }) {
|
|
49
|
+
const tip = track.point(this.scale.fraction(value), -8);
|
|
50
|
+
this.renderer.path(`M${track.cx},${track.cy} L${tip.x},${tip.y}`, 'none', { color, width: 3, cap: 'round' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Each needle's label and value on one line, centred. */
|
|
54
|
+
drawLegend(needles, cx, y) {
|
|
55
|
+
const text = needles.map((n, i) => `${n.label ?? `#${i + 1}`} ${this.scale.label(n.value)}`);
|
|
56
|
+
const font = { fontSize: '11px' };
|
|
57
|
+
const width = (t) => (this.renderer.measureText?.(t, font) ?? t.length * 6) + 22;
|
|
58
|
+
let x = cx - text.reduce((sum, t) => sum + width(t), 0) / 2;
|
|
59
|
+
needles.forEach((needle, i) => {
|
|
60
|
+
this.renderer.circle(x + 4, y, 4, needle.color);
|
|
61
|
+
this.renderer.text(text[i], x + 12, y, { ...font, textAnchor: 'start', verticalAlign: 'middle', fill: '#2f3542' });
|
|
62
|
+
x += width(text[i]);
|
|
63
|
+
});
|
|
36
64
|
}
|
|
37
65
|
}
|
|
@@ -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',
|