@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
package/dist/renderengine.umd.js
CHANGED
|
@@ -1449,18 +1449,18 @@
|
|
|
1449
1449
|
key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
|
|
1450
1450
|
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
1451
1451
|
axes: [
|
|
1452
|
-
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] },
|
|
1452
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
1453
1453
|
],
|
|
1454
|
-
dataShape: 'number (
|
|
1454
|
+
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)',
|
|
1455
1455
|
streamShape: 'none', // no array/window at all — update by calling dialGauge(newValue) again directly
|
|
1456
1456
|
},
|
|
1457
1457
|
{
|
|
1458
1458
|
key: 'gauge-bar', label: 'Gauge (Bar)', method: 'barGauge', category: 'gauge',
|
|
1459
1459
|
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
1460
1460
|
axes: [
|
|
1461
|
-
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] },
|
|
1461
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
1462
1462
|
],
|
|
1463
|
-
dataShape: 'number (0-100)',
|
|
1463
|
+
dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
|
|
1464
1464
|
streamShape: 'none', // no array/window at all — update by calling barGauge(newValue) again directly
|
|
1465
1465
|
},
|
|
1466
1466
|
{
|
|
@@ -1473,6 +1473,51 @@
|
|
|
1473
1473
|
dataShape: 'number or {value, target?, ranges?:Array<{max, label?, color?}>}',
|
|
1474
1474
|
streamShape: 'none',
|
|
1475
1475
|
},
|
|
1476
|
+
{
|
|
1477
|
+
key: 'gauge-arc', label: 'Gauge (Arc)', method: 'arcGauge', category: 'gauge',
|
|
1478
|
+
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
1479
|
+
axes: [
|
|
1480
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
1481
|
+
],
|
|
1482
|
+
dataShape: 'number, or {value, target?} (0-100 unless min/max are given) — a half circle by default, any startAngle/sweep',
|
|
1483
|
+
streamShape: 'none',
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
key: 'gauge-ring', label: 'Gauge (Ring)', method: 'ringGauge', category: 'gauge',
|
|
1487
|
+
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
1488
|
+
axes: [
|
|
1489
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
1490
|
+
],
|
|
1491
|
+
dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
|
|
1492
|
+
streamShape: 'none',
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
key: 'gauge-rings', label: 'Gauge (Rings)', method: 'multiRingGauge', category: 'gauge',
|
|
1496
|
+
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
|
|
1497
|
+
axes: [
|
|
1498
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
1499
|
+
],
|
|
1500
|
+
dataShape: 'Array<number|{value, label?, color?, min?, max?}> — one ring per value, outer first, each on its own scale',
|
|
1501
|
+
streamShape: 'none',
|
|
1502
|
+
},
|
|
1503
|
+
{
|
|
1504
|
+
key: 'gauge-thermometer', label: 'Gauge (Thermometer)', method: 'thermometerGauge', category: 'gauge',
|
|
1505
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
1506
|
+
axes: [
|
|
1507
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
1508
|
+
],
|
|
1509
|
+
dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
|
|
1510
|
+
streamShape: 'none',
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
key: 'kpi-tile', label: 'KPI Tile', method: 'kpiTile', category: 'gauge',
|
|
1514
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
1515
|
+
axes: [
|
|
1516
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
|
|
1517
|
+
],
|
|
1518
|
+
dataShape: 'number, or {value?, previous?, history?:number[]} — the figure, its change since the previous value, a sparkline of the history',
|
|
1519
|
+
streamShape: 'none',
|
|
1520
|
+
},
|
|
1476
1521
|
{
|
|
1477
1522
|
key: 'bar3D', label: 'Bar (3D)', method: 'barChart3D', category: '3d',
|
|
1478
1523
|
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.ISOMETRIC,
|
|
@@ -11987,6 +12032,12 @@
|
|
|
11987
12032
|
* Angles are in radians.
|
|
11988
12033
|
*/
|
|
11989
12034
|
static arcPath(cx, cy, r, startAngle, endAngle) {
|
|
12035
|
+
// A whole circle starts and ends on one point, which an SVG arc draws as nothing: two halves.
|
|
12036
|
+
if (endAngle - startAngle >= 2 * Math.PI - 1e-9) {
|
|
12037
|
+
const mid = startAngle + Math.PI;
|
|
12038
|
+
const at = (a) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
|
|
12039
|
+
return `M ${at(startAngle)} A ${r} ${r} 0 0 1 ${at(mid)} A ${r} ${r} 0 0 1 ${at(startAngle)}`;
|
|
12040
|
+
}
|
|
11990
12041
|
const largeArcFlag = endAngle - startAngle <= Math.PI ? '0' : '1';
|
|
11991
12042
|
const start = {
|
|
11992
12043
|
x: cx + r * Math.cos(startAngle),
|
|
@@ -12001,21 +12052,343 @@
|
|
|
12001
12052
|
}
|
|
12002
12053
|
|
|
12003
12054
|
/**
|
|
12004
|
-
*
|
|
12055
|
+
* GaugeScale — the range a gauge measures and how its numbers read.
|
|
12005
12056
|
*
|
|
12006
|
-
*
|
|
12007
|
-
*
|
|
12057
|
+
* One scale per gauge: it turns a value into a 0-1 fraction of the way along
|
|
12058
|
+
* the track, gives the tick values, and formats the value and tick labels.
|
|
12059
|
+
* Every gauge draws from it, so `min`, `max` and `format` mean the same on
|
|
12060
|
+
* all of them.
|
|
12061
|
+
*/
|
|
12062
|
+
class GaugeScale {
|
|
12063
|
+
/**
|
|
12064
|
+
* @param {Object} [options]
|
|
12065
|
+
* @param {number} [options.min=0]
|
|
12066
|
+
* @param {number} [options.max=100]
|
|
12067
|
+
* @param {Function|Object} [options.format] - `(value) => string`, or
|
|
12068
|
+
* `{ decimals, prefix, suffix, grouping, compact }`. Without one, a
|
|
12069
|
+
* 0-100 scale reads as a percentage ("75.0%"), any other as its number.
|
|
12070
|
+
* @param {string} [options.units] - The suffix when no format is given ("ms", "°C")
|
|
12071
|
+
*/
|
|
12072
|
+
constructor({ min = 0, max = 100, format = null, units = null } = {}) {
|
|
12073
|
+
this.min = Number(min);
|
|
12074
|
+
this.max = Number(max);
|
|
12075
|
+
this.format = format ?? { decimals: 1, suffix: units != null ? ` ${units}` : (this.isPercent ? '%' : '') };
|
|
12076
|
+
}
|
|
12077
|
+
|
|
12078
|
+
/** True for the plain 0-100 scale a percentage gauge has. */
|
|
12079
|
+
get isPercent() { return this.min === 0 && this.max === 100; }
|
|
12080
|
+
|
|
12081
|
+
get span() { return (this.max - this.min) || 1; }
|
|
12082
|
+
|
|
12083
|
+
/** The value held to the scale's range. */
|
|
12084
|
+
clamp(value) {
|
|
12085
|
+
const v = Number(value);
|
|
12086
|
+
if (!Number.isFinite(v)) return this.min;
|
|
12087
|
+
return Math.min(Math.max(v, Math.min(this.min, this.max)), Math.max(this.min, this.max));
|
|
12088
|
+
}
|
|
12089
|
+
|
|
12090
|
+
/** How far along the scale a value is, 0-1 (held to the range). */
|
|
12091
|
+
fraction(value) { return (this.clamp(value) - this.min) / this.span; }
|
|
12092
|
+
|
|
12093
|
+
/** The value at a fraction of the way along. */
|
|
12094
|
+
valueAt(fraction) { return this.min + fraction * this.span; }
|
|
12095
|
+
|
|
12096
|
+
/** `count` equal steps: count + 1 values from min to max. */
|
|
12097
|
+
ticks(count = 5) {
|
|
12098
|
+
const n = Math.max(1, Math.round(count));
|
|
12099
|
+
return Array.from({ length: n + 1 }, (_, i) => this.valueAt(i / n));
|
|
12100
|
+
}
|
|
12101
|
+
|
|
12102
|
+
/** A value as the gauge shows it. */
|
|
12103
|
+
label(value) { return GaugeScale.formatNumber(value, this.format); }
|
|
12104
|
+
|
|
12105
|
+
/**
|
|
12106
|
+
* A tick's label: the value's prefix and suffix, with only the decimals a
|
|
12107
|
+
* step of `span / count` needs ("0", "25", ... not "0.0%", "25.0%").
|
|
12108
|
+
*/
|
|
12109
|
+
tickLabel(value, count = 5) {
|
|
12110
|
+
if (typeof this.format === 'function') return this.format(value);
|
|
12111
|
+
const step = this.span / Math.max(1, Math.round(count));
|
|
12112
|
+
// The fewest decimals that show the step exactly (0.25 needs 2), at most 6.
|
|
12113
|
+
let decimals = 0;
|
|
12114
|
+
while (decimals < 6 && Math.abs(step * 10 ** decimals - Math.round(step * 10 ** decimals)) > 1e-9) decimals++;
|
|
12115
|
+
return GaugeScale.formatNumber(value, { ...this.format, decimals });
|
|
12116
|
+
}
|
|
12117
|
+
|
|
12118
|
+
/**
|
|
12119
|
+
* Format a number: a function format is called; an object one gives
|
|
12120
|
+
* decimals (null: as it is), prefix, suffix, grouping (1,234) and compact (1.2K).
|
|
12121
|
+
*/
|
|
12122
|
+
static formatNumber(value, format = {}) {
|
|
12123
|
+
if (typeof format === 'function') return format(value);
|
|
12124
|
+
const { decimals = null, prefix = '', suffix = '', grouping = false, compact = false } = format ?? {};
|
|
12125
|
+
const v = Number(value);
|
|
12126
|
+
if (!Number.isFinite(v)) return `${prefix}${value}${suffix}`;
|
|
12127
|
+
let text;
|
|
12128
|
+
if (compact || grouping) {
|
|
12129
|
+
const digits = decimals ?? (compact ? 1 : undefined);
|
|
12130
|
+
text = new Intl.NumberFormat('en', {
|
|
12131
|
+
notation: compact ? 'compact' : 'standard',
|
|
12132
|
+
useGrouping: true,
|
|
12133
|
+
...(digits != null ? { minimumFractionDigits: compact ? 0 : digits, maximumFractionDigits: digits } : {}),
|
|
12134
|
+
}).format(v);
|
|
12135
|
+
} else {
|
|
12136
|
+
text = decimals == null ? String(v) : v.toFixed(decimals);
|
|
12137
|
+
}
|
|
12138
|
+
return `${prefix}${text}${suffix}`;
|
|
12139
|
+
}
|
|
12140
|
+
}
|
|
12141
|
+
|
|
12142
|
+
/**
|
|
12143
|
+
* GaugeBands — ranges along a gauge's scale ("poor", "ok", "good"), each with
|
|
12144
|
+
* its colour and label.
|
|
12145
|
+
*
|
|
12146
|
+
* Given as `[{ to, color?, label? }]` in order: each band runs from where the
|
|
12147
|
+
* one before it ended (the scale's min for the first) to its `to` (`max` is
|
|
12148
|
+
* read the same, as the bullet graph has always taken it). Every gauge draws
|
|
12149
|
+
* its bands from here, and a value takes the colour of the band it falls in.
|
|
12150
|
+
*/
|
|
12151
|
+
class GaugeBands {
|
|
12152
|
+
/** Band colours used when a band has none of its own: bad, warning, good. */
|
|
12153
|
+
static COLORS = ['#ff6b6b', '#ffd93d', '#6bcb77'];
|
|
12154
|
+
|
|
12155
|
+
/** How far a band's colour is mixed toward white when drawn behind a value. */
|
|
12156
|
+
static TINT = 0.65;
|
|
12157
|
+
|
|
12158
|
+
/**
|
|
12159
|
+
* @param {Array<{to?:number, max?:number, color?:string, label?:string}>} bands
|
|
12160
|
+
* @param {import('./GaugeScale.js').GaugeScale} scale
|
|
12161
|
+
* @param {Object} [options]
|
|
12162
|
+
* @param {string[]} [options.colors] - Colours for bands without their own
|
|
12163
|
+
*/
|
|
12164
|
+
constructor(bands = [], scale, { colors = GaugeBands.COLORS } = {}) {
|
|
12165
|
+
this.scale = scale;
|
|
12166
|
+
let from = scale.min;
|
|
12167
|
+
this.list = (bands ?? []).map((band, index) => {
|
|
12168
|
+
const to = scale.clamp(band.to ?? band.max ?? scale.max);
|
|
12169
|
+
const entry = { from, to, color: band.color ?? colors[index % colors.length], label: band.label ?? null, index };
|
|
12170
|
+
from = to;
|
|
12171
|
+
return entry;
|
|
12172
|
+
});
|
|
12173
|
+
}
|
|
12174
|
+
|
|
12175
|
+
get length() { return this.list.length; }
|
|
12176
|
+
|
|
12177
|
+
/** The band a value falls in (the last one at a shared edge), or null. */
|
|
12178
|
+
at(value) {
|
|
12179
|
+
const v = this.scale.clamp(value);
|
|
12180
|
+
return this.list.find(b => v >= b.from && v < b.to) ?? this.list.findLast(b => v >= b.from && v <= b.to) ?? null;
|
|
12181
|
+
}
|
|
12182
|
+
|
|
12183
|
+
/** A band's colour as drawn behind the value: lighter, so the value shows on it. */
|
|
12184
|
+
static tint(color) {
|
|
12185
|
+
return /^#[0-9a-f]{6}$/i.test(color) ? ColorPalette.interpolate(color, '#ffffff', GaugeBands.TINT) : color;
|
|
12186
|
+
}
|
|
12187
|
+
}
|
|
12188
|
+
|
|
12189
|
+
/**
|
|
12190
|
+
* GaugeValue — what a gauge's data says: the value, and what it is compared
|
|
12191
|
+
* with.
|
|
12192
|
+
*
|
|
12193
|
+
* Data is a plain number, or `{ value, target?, previous?, history?, values? }`;
|
|
12194
|
+
* options of the same names win over the data's. `values` is for gauges that
|
|
12195
|
+
* show several (one ring each): numbers, or `{ value, label?, color?, min?, max? }`;
|
|
12196
|
+
* an array as the data is read as `values`.
|
|
12197
|
+
*/
|
|
12198
|
+
class GaugeValue {
|
|
12199
|
+
constructor({ value = 0, target = null, previous = null, history = [], values = [] } = {}) {
|
|
12200
|
+
this.value = Number(value) || 0;
|
|
12201
|
+
this.target = target == null ? null : Number(target);
|
|
12202
|
+
this.history = (history ?? []).map(Number).filter(Number.isFinite);
|
|
12203
|
+
// The value before this one: given, or the one before the last in the history.
|
|
12204
|
+
this.previous = previous != null ? Number(previous) : (this.history.length > 1 ? this.history.at(-2) : null);
|
|
12205
|
+
this.values = (values ?? []).map(v => (typeof v === 'object' && v !== null ? { ...v, value: Number(v.value) || 0 } : { value: Number(v) || 0 }));
|
|
12206
|
+
}
|
|
12207
|
+
|
|
12208
|
+
/** The change from the previous value, or null without one. */
|
|
12209
|
+
get change() { return this.previous == null ? null : this.value - this.previous; }
|
|
12210
|
+
|
|
12211
|
+
/** The change as a fraction of the previous value (0.1 is up 10%), or null. */
|
|
12212
|
+
get changeRatio() { return this.previous ? this.change / Math.abs(this.previous) : null; }
|
|
12213
|
+
|
|
12214
|
+
/** As gauge data again (`{ value, target, previous, history, values }`). */
|
|
12215
|
+
toData() {
|
|
12216
|
+
const { value, target, previous, history, values } = this;
|
|
12217
|
+
return { value, target, previous, history: [...history], values: values.map(v => ({ ...v })) };
|
|
12218
|
+
}
|
|
12219
|
+
|
|
12220
|
+
/**
|
|
12221
|
+
* The data part of the way (t, 0-1) from one value to another: the value,
|
|
12222
|
+
* the target and each of several values in between (one that is new
|
|
12223
|
+
* starts from the first value); the rest as `to` has them.
|
|
12224
|
+
*/
|
|
12225
|
+
static between(from, to, t) {
|
|
12226
|
+
const mix = (a, b) => (a == null || b == null ? b : a + (b - a) * t);
|
|
12227
|
+
const data = to.toData();
|
|
12228
|
+
data.value = mix(from.value, to.value);
|
|
12229
|
+
data.target = mix(from.target, to.target);
|
|
12230
|
+
data.values = to.values.map((v, i) => ({ ...v, value: mix(from.values[i]?.value ?? from.value, v.value) }));
|
|
12231
|
+
return data;
|
|
12232
|
+
}
|
|
12233
|
+
|
|
12234
|
+
static from(data, options = {}) {
|
|
12235
|
+
const given = Array.isArray(data) ? { values: data } : (typeof data === 'object' && data !== null ? data : { value: data });
|
|
12236
|
+
const pick = (key) => options[key] ?? given[key];
|
|
12237
|
+
const values = pick('values') ?? [];
|
|
12238
|
+
const history = pick('history') ?? [];
|
|
12239
|
+
// Without a value: the first of several, else the last of the history.
|
|
12240
|
+
const first = values.length ? (typeof values[0] === 'object' ? values[0].value : values[0]) : history.at(-1);
|
|
12241
|
+
return new GaugeValue({
|
|
12242
|
+
value: given.value ?? first ?? 0,
|
|
12243
|
+
target: pick('target'),
|
|
12244
|
+
previous: pick('previous'),
|
|
12245
|
+
history,
|
|
12246
|
+
values,
|
|
12247
|
+
});
|
|
12248
|
+
}
|
|
12249
|
+
}
|
|
12250
|
+
|
|
12251
|
+
/**
|
|
12252
|
+
* GaugeChart — abstract base for gauges: one value shown along a track.
|
|
12253
|
+
*
|
|
12254
|
+
* It reads what every gauge has in common from its data and options (a
|
|
12255
|
+
* `GaugeScale`, `GaugeBands`, a `GaugeValue`) and draws the parts every gauge
|
|
12256
|
+
* has on the subclass's `track()`: the background or the bands, the value, a
|
|
12257
|
+
* target marker, ticks and the labels. A subclass picks the track and its
|
|
12258
|
+
* layout, and adds its own parts (the dial's needle).
|
|
12259
|
+
*
|
|
12260
|
+
* Options every gauge takes:
|
|
12261
|
+
* min, max (0, 100), format, units — the scale (see GaugeScale)
|
|
12262
|
+
* bands: [{to, color?, label?}] — ranges drawn behind the value; the value takes its band's colour
|
|
12263
|
+
* bandColors: string[] — colours for bands without their own
|
|
12264
|
+
* color — the value's colour: one, a gradient array, or (percent) => colour
|
|
12265
|
+
* target — a marker at a value (or data.target)
|
|
12266
|
+
* targetColor ('#333')
|
|
12267
|
+
* ticks: number — that many equal steps, marked and labelled
|
|
12268
|
+
* segments: number, segmentGap (2) — the track as that many blocks (LED style), lit up to the value;
|
|
12269
|
+
* with bands, each lit block takes its own band's colour
|
|
12270
|
+
* title
|
|
12008
12271
|
*/
|
|
12009
12272
|
class GaugeChart extends Chart {
|
|
12010
|
-
/**
|
|
12011
|
-
|
|
12012
|
-
|
|
12013
|
-
|
|
12273
|
+
/** The value's colour when neither `color` nor a band gives one. */
|
|
12274
|
+
static defaultColor = '#5352ed';
|
|
12275
|
+
|
|
12276
|
+
/** The track behind the value when there are no bands. */
|
|
12277
|
+
static trackColor = '#f1f2f6';
|
|
12278
|
+
|
|
12279
|
+
/** Where and how big this type draws when the options don't say (cx, cy, r, ...). */
|
|
12280
|
+
static LAYOUT = {};
|
|
12281
|
+
|
|
12282
|
+
/** The layout: the options' values for this type's LAYOUT keys, over its own. */
|
|
12283
|
+
get layout() {
|
|
12284
|
+
const layout = { ...this.constructor.LAYOUT };
|
|
12285
|
+
for (const key of Object.keys(layout)) if (this.options[key] != null) layout[key] = this.options[key];
|
|
12286
|
+
return layout;
|
|
12014
12287
|
}
|
|
12015
12288
|
|
|
12016
|
-
|
|
12017
|
-
|
|
12018
|
-
return
|
|
12289
|
+
get scale() {
|
|
12290
|
+
const { min, max, format, units } = this.options;
|
|
12291
|
+
return (this._scale ??= new GaugeScale({ min, max, format: format ?? this.defaultFormat(), units }));
|
|
12292
|
+
}
|
|
12293
|
+
|
|
12294
|
+
get bands() {
|
|
12295
|
+
// `ranges` and `rangeColors`: the bullet graph's older names for them.
|
|
12296
|
+
const { bands, ranges, bandColors = this.options.rangeColors } = this.options;
|
|
12297
|
+
return (this._bands ??= new GaugeBands(bands ?? ranges ?? this.defaultBands(), this.scale, bandColors ? { colors: bandColors } : {}));
|
|
12298
|
+
}
|
|
12299
|
+
|
|
12300
|
+
get gaugeValue() { return (this._value ??= GaugeValue.from(this.data, this.options)); }
|
|
12301
|
+
|
|
12302
|
+
/** The value, held to the scale. */
|
|
12303
|
+
get value() { return this.scale.clamp(this.gaugeValue.value); }
|
|
12304
|
+
|
|
12305
|
+
/** A format this gauge type reads its numbers with when none is given (null: the scale's). */
|
|
12306
|
+
defaultFormat() { return null; }
|
|
12307
|
+
|
|
12308
|
+
/** Bands this gauge type draws when none are given. */
|
|
12309
|
+
defaultBands() { return []; }
|
|
12310
|
+
|
|
12311
|
+
/** The track the value moves along. Subclasses implement. @returns {import('../gauges/GaugeTrack.js').GaugeTrack} */
|
|
12312
|
+
track() { throw new Error(`${this.constructor.name} must implement track()`); }
|
|
12313
|
+
|
|
12314
|
+
/** The value's colour: the `color` option, else its band's, else the type's own. */
|
|
12315
|
+
valueColor(value = this.value) {
|
|
12316
|
+
const { color } = this.options;
|
|
12317
|
+
if (color != null) return Gauge.resolveColor(color, this.scale.fraction(value) * 100);
|
|
12318
|
+
return this.bands.at(value)?.color ?? this.constructor.defaultColor;
|
|
12319
|
+
}
|
|
12320
|
+
|
|
12321
|
+
/**
|
|
12322
|
+
* With `segments`, the track as that many blocks a `segmentGap` (pixels)
|
|
12323
|
+
* apart: [{from, to, mid}] as fractions; null without (fewer than 2).
|
|
12324
|
+
*/
|
|
12325
|
+
segmentsOf(track) {
|
|
12326
|
+
const n = Math.round(this.options.segments ?? 0);
|
|
12327
|
+
if (n < 2) return null;
|
|
12328
|
+
const gap = Math.min((this.options.segmentGap ?? 2) / (track.length || 1), 0.5 / n);
|
|
12329
|
+
return Array.from({ length: n }, (_, i) => ({ from: i / n + gap / 2, to: (i + 1) / n - gap / 2, mid: (i + 0.5) / n }));
|
|
12330
|
+
}
|
|
12331
|
+
|
|
12332
|
+
/** The bands, lighter, along the whole track; or the plain track without any. */
|
|
12333
|
+
drawBackground(track, options = {}) {
|
|
12334
|
+
const blocks = this.segmentsOf(track);
|
|
12335
|
+
if (blocks) {
|
|
12336
|
+
for (const block of blocks) {
|
|
12337
|
+
const band = this.bands.at(this.scale.valueAt(block.mid));
|
|
12338
|
+
track.draw(this.renderer, block.from, block.to, band ? GaugeBands.tint(band.color) : this.constructor.trackColor, { ...options, cap: undefined });
|
|
12339
|
+
}
|
|
12340
|
+
return;
|
|
12341
|
+
}
|
|
12342
|
+
if (!this.bands.length) {
|
|
12343
|
+
track.draw(this.renderer, 0, 1, this.constructor.trackColor, options);
|
|
12344
|
+
return;
|
|
12345
|
+
}
|
|
12346
|
+
for (const band of this.bands.list) {
|
|
12347
|
+
track.draw(this.renderer, this.scale.fraction(band.from), this.scale.fraction(band.to), GaugeBands.tint(band.color), { ...options, cap: undefined });
|
|
12348
|
+
}
|
|
12349
|
+
}
|
|
12350
|
+
|
|
12351
|
+
/**
|
|
12352
|
+
* A value: the track from the scale's start to it. This gauge's value and
|
|
12353
|
+
* scale unless given (a gauge of several values draws each on its own).
|
|
12354
|
+
*/
|
|
12355
|
+
drawValue(track, { value = this.value, scale = this.scale, color, ...options } = {}) {
|
|
12356
|
+
const blocks = this.segmentsOf(track);
|
|
12357
|
+
if (!blocks) {
|
|
12358
|
+
track.draw(this.renderer, 0, scale.fraction(value), color ?? this.valueColor(value), options);
|
|
12359
|
+
return;
|
|
12360
|
+
}
|
|
12361
|
+
// The blocks up to the value are lit: each in its band's colour (a level meter), unless a colour is given.
|
|
12362
|
+
const lit = Math.round(scale.fraction(value) * blocks.length);
|
|
12363
|
+
const own = color ?? (this.options.color != null || !this.bands.length ? this.valueColor(value) : null);
|
|
12364
|
+
for (const block of blocks.slice(0, lit)) {
|
|
12365
|
+
const blockColor = own ?? this.bands.at(scale.valueAt(block.mid))?.color ?? this.constructor.defaultColor;
|
|
12366
|
+
track.draw(this.renderer, block.from, block.to, blockColor, { ...options, cap: undefined });
|
|
12367
|
+
}
|
|
12368
|
+
}
|
|
12369
|
+
|
|
12370
|
+
/** A line across the track at the target, a little past both edges. */
|
|
12371
|
+
drawTarget(track, { overhang = 3, width = 2 } = {}) {
|
|
12372
|
+
const { target } = this.gaugeValue;
|
|
12373
|
+
if (target == null) return;
|
|
12374
|
+
const half = track.thickness / 2 + overhang;
|
|
12375
|
+
track.line(this.renderer, this.scale.fraction(target), -half, half, { color: this.options.targetColor ?? '#333', width });
|
|
12376
|
+
}
|
|
12377
|
+
|
|
12378
|
+
/** `ticks` marks outside the track, each labelled with its value. */
|
|
12379
|
+
drawTicks(track) {
|
|
12380
|
+
const { ticks } = this.options;
|
|
12381
|
+
if (!ticks) return;
|
|
12382
|
+
const count = Math.max(1, Math.round(ticks));
|
|
12383
|
+
const edge = track.thickness / 2;
|
|
12384
|
+
for (const value of this.scale.ticks(count)) {
|
|
12385
|
+
const f = this.scale.fraction(value);
|
|
12386
|
+
track.line(this.renderer, f, edge + 2, edge + 6, { color: '#999', width: 1 });
|
|
12387
|
+
const at = track.point(f, edge + 14);
|
|
12388
|
+
this.renderer.text(this.scale.tickLabel(value, count), at.x, at.y, {
|
|
12389
|
+
textAnchor: track.anchorAt(f), verticalAlign: 'middle', fontSize: '10px', fill: '#747d8c',
|
|
12390
|
+
});
|
|
12391
|
+
}
|
|
12019
12392
|
}
|
|
12020
12393
|
|
|
12021
12394
|
/** Draw a gauge title. */
|
|
@@ -12029,9 +12402,9 @@
|
|
|
12029
12402
|
});
|
|
12030
12403
|
}
|
|
12031
12404
|
|
|
12032
|
-
/** Draw
|
|
12033
|
-
|
|
12034
|
-
this.renderer.text(
|
|
12405
|
+
/** Draw the value's label, centred at a point. */
|
|
12406
|
+
_renderValueLabel(x, y, options = {}) {
|
|
12407
|
+
this.renderer.text(this.scale.label(this.value), x, y, {
|
|
12035
12408
|
textAnchor: options.textAnchor || 'middle',
|
|
12036
12409
|
verticalAlign: options.verticalAlign || 'middle',
|
|
12037
12410
|
fontSize: options.fontSize || '12px',
|
|
@@ -12046,22 +12419,143 @@
|
|
|
12046
12419
|
}
|
|
12047
12420
|
|
|
12048
12421
|
/**
|
|
12049
|
-
*
|
|
12422
|
+
* GaugeTrack — the path a gauge's value moves along, as fractions 0-1.
|
|
12423
|
+
*
|
|
12424
|
+
* A subclass is one geometry (a straight bar, an arc). The gauge asks it for
|
|
12425
|
+
* points and draws segments on it, so bands, the value, ticks and a target
|
|
12426
|
+
* marker are drawn the same way on every gauge whatever its shape.
|
|
12427
|
+
*
|
|
12428
|
+
* A track has a `length` (pixels along it) as well as its `thickness`.
|
|
12429
|
+
* Offsets are across the track from its centre line: positive is outward
|
|
12430
|
+
* (below a horizontal bar, right of a vertical one, away from an arc's centre).
|
|
12431
|
+
*/
|
|
12432
|
+
class GaugeTrack {
|
|
12433
|
+
/** @param {number} thickness - The track's width across */
|
|
12434
|
+
constructor(thickness) {
|
|
12435
|
+
if (new.target === GaugeTrack) throw new TypeError('GaugeTrack is abstract — subclass it');
|
|
12436
|
+
this.thickness = thickness;
|
|
12437
|
+
}
|
|
12438
|
+
|
|
12439
|
+
/** The point at a fraction along, `offset` across. @returns {{x:number, y:number}} */
|
|
12440
|
+
point(fraction, offset = 0) { throw new Error(`${this.constructor.name} must implement point()`); }
|
|
12441
|
+
|
|
12442
|
+
/**
|
|
12443
|
+
* Draw the track from one fraction to another, in a colour.
|
|
12444
|
+
* @param {Object} renderer
|
|
12445
|
+
* @param {number} from - 0-1
|
|
12446
|
+
* @param {number} to - 0-1
|
|
12447
|
+
* @param {string} color
|
|
12448
|
+
* @param {Object} [options] - `thickness` (this track's by default), `cap` ('round' ends)
|
|
12449
|
+
*/
|
|
12450
|
+
draw(renderer, from, to, color, options = {}) { throw new Error(`${this.constructor.name} must implement draw()`); }
|
|
12451
|
+
|
|
12452
|
+
/** Where a label sits in the middle of the gauge. @returns {{x:number, y:number}} */
|
|
12453
|
+
get center() { throw new Error(`${this.constructor.name} must implement center`); }
|
|
12454
|
+
|
|
12455
|
+
/** A straight line across the track at a fraction, between two offsets (a tick, a target). */
|
|
12456
|
+
line(renderer, fraction, fromOffset, toOffset, stroke) {
|
|
12457
|
+
const a = this.point(fraction, fromOffset);
|
|
12458
|
+
const b = this.point(fraction, toOffset);
|
|
12459
|
+
renderer.path(`M${a.x},${a.y} L${b.x},${b.y}`, 'none', stroke);
|
|
12460
|
+
}
|
|
12461
|
+
|
|
12462
|
+
/** How a label at a point outside the track is anchored ('start', 'middle' or 'end'). */
|
|
12463
|
+
anchorAt(fraction) { return 'middle'; }
|
|
12464
|
+
}
|
|
12465
|
+
|
|
12466
|
+
/**
|
|
12467
|
+
* LinearTrack — a straight gauge track: a box `length` long and `thickness`
|
|
12468
|
+
* across, its segments drawn as rectangles. A subclass gives the direction.
|
|
12469
|
+
*/
|
|
12470
|
+
class LinearTrack extends GaugeTrack {
|
|
12471
|
+
/**
|
|
12472
|
+
* @param {Object} options
|
|
12473
|
+
* @param {number} options.x - The box's left
|
|
12474
|
+
* @param {number} options.y - The box's top
|
|
12475
|
+
* @param {number} options.length
|
|
12476
|
+
* @param {number} options.thickness
|
|
12477
|
+
* @param {number} [options.radius=0] - Corner radius of the drawn segments
|
|
12478
|
+
*/
|
|
12479
|
+
constructor({ x, y, length, thickness, radius = 0 }) {
|
|
12480
|
+
super(thickness);
|
|
12481
|
+
if (new.target === LinearTrack) throw new TypeError('LinearTrack is abstract — use HorizontalTrack or VerticalTrack');
|
|
12482
|
+
Object.assign(this, { x, y, length, radius });
|
|
12483
|
+
}
|
|
12484
|
+
|
|
12485
|
+
/** The rectangle covering from one fraction to another, `thickness` across and centred. */
|
|
12486
|
+
rectangle(from, to, thickness) { throw new Error(`${this.constructor.name} must implement rectangle()`); }
|
|
12487
|
+
|
|
12488
|
+
draw(renderer, from, to, color, { thickness = this.thickness } = {}) {
|
|
12489
|
+
const r = this.rectangle(Math.min(from, to), Math.max(from, to), thickness);
|
|
12490
|
+
renderer.rect(r.x, r.y, r.width, r.height, color, null, this.radius ? { rx: this.radius, ry: this.radius } : {});
|
|
12491
|
+
}
|
|
12492
|
+
}
|
|
12493
|
+
|
|
12494
|
+
/** A track left to right. */
|
|
12495
|
+
class HorizontalTrack extends LinearTrack {
|
|
12496
|
+
point(fraction, offset = 0) {
|
|
12497
|
+
return { x: this.x + fraction * this.length, y: this.y + this.thickness / 2 + offset };
|
|
12498
|
+
}
|
|
12499
|
+
|
|
12500
|
+
rectangle(from, to, thickness) {
|
|
12501
|
+
return { x: this.x + from * this.length, y: this.y + (this.thickness - thickness) / 2, width: (to - from) * this.length, height: thickness };
|
|
12502
|
+
}
|
|
12503
|
+
|
|
12504
|
+
get center() { return this.point(0.5); }
|
|
12505
|
+
}
|
|
12506
|
+
|
|
12507
|
+
/** A track bottom to top. */
|
|
12508
|
+
class VerticalTrack extends LinearTrack {
|
|
12509
|
+
point(fraction, offset = 0) {
|
|
12510
|
+
return { x: this.x + this.thickness / 2 + offset, y: this.y + this.length - fraction * this.length };
|
|
12511
|
+
}
|
|
12512
|
+
|
|
12513
|
+
rectangle(from, to, thickness) {
|
|
12514
|
+
return { x: this.x + (this.thickness - thickness) / 2, y: this.y + this.length - to * this.length, width: thickness, height: (to - from) * this.length };
|
|
12515
|
+
}
|
|
12516
|
+
|
|
12517
|
+
get center() { return this.point(0.5); }
|
|
12518
|
+
|
|
12519
|
+
/** Labels beside a vertical track start at their point. */
|
|
12520
|
+
anchorAt() { return 'start'; }
|
|
12521
|
+
}
|
|
12522
|
+
|
|
12523
|
+
/**
|
|
12524
|
+
* BarGauge — a straight bar filled to the value, left to right or (with
|
|
12525
|
+
* `orientation: 'vertical'`) bottom to top.
|
|
12526
|
+
*
|
|
12527
|
+
* Options: x, y, width, height (the bar's box; 200 × 30 across, 30 × 200 up),
|
|
12528
|
+
* orientation, and every GaugeChart option (min, max, bands, target, ticks, ...).
|
|
12050
12529
|
*/
|
|
12051
12530
|
class BarGauge extends GaugeChart {
|
|
12052
12531
|
static chartType = 'barGauge';
|
|
12053
12532
|
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
|
|
12057
|
-
const
|
|
12058
|
-
const
|
|
12059
|
-
|
|
12533
|
+
get vertical() { return this.options.orientation === 'vertical'; }
|
|
12534
|
+
|
|
12535
|
+
track() {
|
|
12536
|
+
const { x = 10, y = 10 } = this.options;
|
|
12537
|
+
const { width = this.vertical ? 30 : 200, height = this.vertical ? 200 : 30 } = this.options;
|
|
12538
|
+
return this.vertical
|
|
12539
|
+
? new VerticalTrack({ x, y, length: height, thickness: width, radius: 4 })
|
|
12540
|
+
: new HorizontalTrack({ x, y, length: width, thickness: height, radius: 4 });
|
|
12541
|
+
}
|
|
12060
12542
|
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
this.
|
|
12064
|
-
this.
|
|
12543
|
+
_render() {
|
|
12544
|
+
const { x = 10, y = 10, title } = this.options;
|
|
12545
|
+
const track = this.track();
|
|
12546
|
+
this._renderTitle(title, x, y - (this.vertical ? 8 : 5));
|
|
12547
|
+
this.drawBackground(track);
|
|
12548
|
+
this.drawValue(track);
|
|
12549
|
+
this.drawTarget(track);
|
|
12550
|
+
this.drawTicks(track);
|
|
12551
|
+
if (this.vertical) {
|
|
12552
|
+
// A vertical bar is too narrow for its label: it goes underneath.
|
|
12553
|
+
const below = track.point(0, 0);
|
|
12554
|
+
this._renderValueLabel(below.x, below.y + 14, { fontWeight: 'bold', fill: '#2f3542' });
|
|
12555
|
+
} else {
|
|
12556
|
+
const { x: cx, y: cy } = track.center;
|
|
12557
|
+
this._renderValueLabel(cx, cy, { fontWeight: 'bold' });
|
|
12558
|
+
}
|
|
12065
12559
|
}
|
|
12066
12560
|
}
|
|
12067
12561
|
|
|
@@ -12072,82 +12566,77 @@
|
|
|
12072
12566
|
* Data may be a plain number (the actual value) or an object
|
|
12073
12567
|
* `{ value: number, target?: number, ranges?: Array<{max:number, label?, color?}> }`.
|
|
12074
12568
|
*
|
|
12075
|
-
* Options:
|
|
12076
|
-
*
|
|
12077
|
-
* ranges?: Range[] — qualitative ranges (overrides data.ranges)
|
|
12078
|
-
* min?: number = 0 — scale minimum
|
|
12079
|
-
* max?: number = 100 — scale maximum
|
|
12569
|
+
* Options (with every GaugeChart option: min, max, bands, target, ticks, ...):
|
|
12570
|
+
* ranges?: Range[] — the bands, by their older name (overrides data.ranges)
|
|
12080
12571
|
* barColor?: string — color of the actual-value bar
|
|
12081
12572
|
* targetColor?: string = #333 — color of the target marker
|
|
12082
12573
|
* rangeColors?: string[] — fallback palette for ranges without their own color
|
|
12083
|
-
* title?: string
|
|
12084
12574
|
* showLabels?: boolean = true — value/target/range labels
|
|
12085
12575
|
*/
|
|
12086
|
-
class BulletGauge extends
|
|
12576
|
+
class BulletGauge extends GaugeChart {
|
|
12087
12577
|
static chartType = 'bullet';
|
|
12578
|
+
static defaultColor = '#5470c6';
|
|
12088
12579
|
|
|
12089
|
-
|
|
12090
|
-
|
|
12091
|
-
const options = this.options;
|
|
12092
|
-
const box = ChartBox.fromRenderer(this.renderer);
|
|
12093
|
-
const {
|
|
12094
|
-
x = box.x, y = box.y, width = box.width, height = box.height * 0.4,
|
|
12095
|
-
min = 0, max = 100,
|
|
12096
|
-
target: optTarget, targetColor = '#333',
|
|
12097
|
-
barColor = '#5470c6',
|
|
12098
|
-
ranges: optRanges, rangeColors = ['#ff6b6b', '#ffd93d', '#6bcb77'],
|
|
12099
|
-
title, showLabels = true,
|
|
12100
|
-
} = options;
|
|
12580
|
+
/** Its numbers as they are ("65", not "65.0%"). */
|
|
12581
|
+
defaultFormat() { return (v) => String(v); }
|
|
12101
12582
|
|
|
12102
|
-
|
|
12103
|
-
|
|
12104
|
-
const
|
|
12583
|
+
/** The data's ranges, else poor / satisfactory / good at 60% and 80% of the scale. */
|
|
12584
|
+
defaultBands() {
|
|
12585
|
+
const max = this.options.max ?? 100;
|
|
12586
|
+
return this.data?.bands ?? this.data?.ranges ?? [
|
|
12105
12587
|
{ max: max * 0.6, label: 'Poor' },
|
|
12106
12588
|
{ max: max * 0.8, label: 'Satisfactory' },
|
|
12107
|
-
{ max
|
|
12589
|
+
{ max, label: 'Good' },
|
|
12108
12590
|
];
|
|
12591
|
+
}
|
|
12592
|
+
|
|
12593
|
+
/** The bar keeps one colour (`barColor`): the bands behind it are the comparison. */
|
|
12594
|
+
valueColor(value) {
|
|
12595
|
+
const { barColor, color } = this.options;
|
|
12596
|
+
return barColor ?? (color != null ? super.valueColor(value) : this.constructor.defaultColor);
|
|
12597
|
+
}
|
|
12109
12598
|
|
|
12110
|
-
|
|
12111
|
-
const
|
|
12599
|
+
get box() {
|
|
12600
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
12601
|
+
const { x = box.x, y = box.y, width = box.width, height = box.height * 0.4 } = this.options;
|
|
12602
|
+
return { x, y, width, height };
|
|
12603
|
+
}
|
|
12604
|
+
|
|
12605
|
+
track() {
|
|
12606
|
+
const { x, y, width, height } = this.box;
|
|
12607
|
+
return new HorizontalTrack({ x, y, length: width, thickness: height });
|
|
12608
|
+
}
|
|
12609
|
+
|
|
12610
|
+
_render() {
|
|
12611
|
+
const { title, showLabels = true, ticks } = this.options;
|
|
12612
|
+
const { x, y, width, height } = this.box;
|
|
12613
|
+
const track = this.track();
|
|
12112
12614
|
|
|
12113
12615
|
new ChartLabels({ title }).draw(this.renderer, { x, y, width, height });
|
|
12114
12616
|
|
|
12115
|
-
|
|
12116
|
-
|
|
12117
|
-
|
|
12118
|
-
const
|
|
12119
|
-
|
|
12120
|
-
|
|
12121
|
-
|
|
12122
|
-
this.renderer.rect(rx, y, rw, height, color, null, { opacity: 0.35 });
|
|
12123
|
-
if (showLabels && range.label) {
|
|
12124
|
-
this.renderer.text(range.label, rx + rw / 2, y + height + 14, {
|
|
12125
|
-
textAnchor: 'middle', fontSize: '10px', fill: '#666',
|
|
12126
|
-
});
|
|
12617
|
+
this.drawBackground(track);
|
|
12618
|
+
if (showLabels) {
|
|
12619
|
+
const below = y + height + (ticks ? 28 : 14);
|
|
12620
|
+
for (const band of this.bands.list) {
|
|
12621
|
+
if (!band.label) continue;
|
|
12622
|
+
const mid = track.point((this.scale.fraction(band.from) + this.scale.fraction(band.to)) / 2);
|
|
12623
|
+
this.renderer.text(band.label, mid.x, below, { textAnchor: 'middle', fontSize: '10px', fill: '#666' });
|
|
12127
12624
|
}
|
|
12128
|
-
|
|
12129
|
-
});
|
|
12625
|
+
}
|
|
12130
12626
|
|
|
12131
|
-
//
|
|
12627
|
+
// The actual value: a bar half as thick, centred on the bands.
|
|
12132
12628
|
const barHeight = height * 0.5;
|
|
12133
|
-
|
|
12134
|
-
|
|
12135
|
-
|
|
12136
|
-
this.renderer.rect(actualX, barY, actualW, barHeight, barColor, null, {});
|
|
12137
|
-
|
|
12138
|
-
// Draw target marker (vertical line)
|
|
12139
|
-
if (target != null) {
|
|
12140
|
-
const tx = toX(target);
|
|
12141
|
-
this.renderer.path(`M${tx},${y - 2} L${tx},${y + height + 2}`, 'none', { color: targetColor, width: 2 });
|
|
12142
|
-
}
|
|
12629
|
+
this.drawValue(track, { thickness: barHeight });
|
|
12630
|
+
this.drawTarget(track, { overhang: 2 });
|
|
12631
|
+
this.drawTicks(track);
|
|
12143
12632
|
|
|
12144
12633
|
if (showLabels) {
|
|
12145
|
-
|
|
12146
|
-
|
|
12147
|
-
}
|
|
12634
|
+
const { x: cx, y: cy } = track.center;
|
|
12635
|
+
this._renderValueLabel(cx, cy, { fontSize: '11px', fontWeight: 'bold' });
|
|
12636
|
+
const { target } = this.gaugeValue;
|
|
12148
12637
|
if (target != null) {
|
|
12149
|
-
this.renderer.text(
|
|
12150
|
-
textAnchor: 'start', fontSize: '10px', fill: targetColor,
|
|
12638
|
+
this.renderer.text(this.scale.label(target), track.point(this.scale.fraction(target)).x + 5, y - 8, {
|
|
12639
|
+
textAnchor: 'start', fontSize: '10px', fill: this.options.targetColor ?? '#333',
|
|
12151
12640
|
});
|
|
12152
12641
|
}
|
|
12153
12642
|
}
|
|
@@ -12155,38 +12644,445 @@
|
|
|
12155
12644
|
}
|
|
12156
12645
|
|
|
12157
12646
|
/**
|
|
12158
|
-
*
|
|
12647
|
+
* ArcTrack — a gauge track along a circle's arc: the dial's 270°, a half
|
|
12648
|
+
* circle, a full ring. Angles are degrees clockwise from 3 o'clock (the
|
|
12649
|
+
* screen's own direction), so 135° with a 270° sweep is the dial's
|
|
12650
|
+
* bottom-left to bottom-right, and 180° with 180° is a half circle over the top.
|
|
12651
|
+
*/
|
|
12652
|
+
class ArcTrack extends GaugeTrack {
|
|
12653
|
+
/**
|
|
12654
|
+
* @param {Object} options
|
|
12655
|
+
* @param {number} options.cx
|
|
12656
|
+
* @param {number} options.cy
|
|
12657
|
+
* @param {number} options.r - The radius of the track's centre line
|
|
12658
|
+
* @param {number} options.thickness
|
|
12659
|
+
* @param {number} [options.startAngle=135]
|
|
12660
|
+
* @param {number} [options.sweep=270]
|
|
12661
|
+
*/
|
|
12662
|
+
constructor({ cx, cy, r, thickness, startAngle = 135, sweep = 270 }) {
|
|
12663
|
+
super(thickness);
|
|
12664
|
+
Object.assign(this, { cx, cy, r, startAngle, sweep });
|
|
12665
|
+
}
|
|
12666
|
+
|
|
12667
|
+
/** The arc's length along its centre line, in pixels. */
|
|
12668
|
+
get length() { return this.r * Math.abs(this.sweep) * Math.PI / 180; }
|
|
12669
|
+
|
|
12670
|
+
/** The angle in degrees at a fraction along. */
|
|
12671
|
+
angleAt(fraction) { return this.startAngle + fraction * this.sweep; }
|
|
12672
|
+
|
|
12673
|
+
point(fraction, offset = 0) {
|
|
12674
|
+
const a = this.angleAt(fraction) * Math.PI / 180;
|
|
12675
|
+
const r = this.r + offset;
|
|
12676
|
+
return { x: this.cx + r * Math.cos(a), y: this.cy + r * Math.sin(a) };
|
|
12677
|
+
}
|
|
12678
|
+
|
|
12679
|
+
draw(renderer, from, to, color, { thickness = this.thickness, cap } = {}) {
|
|
12680
|
+
const rad = Math.PI / 180;
|
|
12681
|
+
const a0 = this.angleAt(Math.min(from, to)) * rad;
|
|
12682
|
+
const a1 = this.angleAt(Math.max(from, to)) * rad;
|
|
12683
|
+
renderer.path(Gauge.arcPath(this.cx, this.cy, this.r, a0, a1), 'none', { color, width: thickness, ...(cap ? { cap } : {}) });
|
|
12684
|
+
}
|
|
12685
|
+
|
|
12686
|
+
get center() { return { x: this.cx, y: this.cy }; }
|
|
12687
|
+
|
|
12688
|
+
/** A label outside the arc leans away from the centre: 'start' on the right, 'end' on the left. */
|
|
12689
|
+
anchorAt(fraction) {
|
|
12690
|
+
const dx = Math.cos(this.angleAt(fraction) * Math.PI / 180);
|
|
12691
|
+
return dx > 0.3 ? 'start' : dx < -0.3 ? 'end' : 'middle';
|
|
12692
|
+
}
|
|
12693
|
+
}
|
|
12694
|
+
|
|
12695
|
+
/**
|
|
12696
|
+
* DialGauge — an arc filled to the value, with a needle pointing at it.
|
|
12697
|
+
*
|
|
12698
|
+
* Data: a value, or several (`[{ value, label?, color? }]`, e.g. actual and
|
|
12699
|
+
* budget): a needle each, the first also filling the arc and labelled in
|
|
12700
|
+
* the middle, and a legend of them all under the title.
|
|
12701
|
+
* Options: cx, cy, r (100, 80, 60), thickness (12), startAngle and sweep in
|
|
12702
|
+
* degrees (135 and 270: bottom-left round to bottom-right), needleColors,
|
|
12703
|
+
* and every GaugeChart option (min, max, bands, target, ticks, segments, ...).
|
|
12159
12704
|
*/
|
|
12160
12705
|
class DialGauge extends GaugeChart {
|
|
12161
12706
|
static chartType = 'dialGauge';
|
|
12707
|
+
static LAYOUT = { cx: 100, cy: 80, r: 60, thickness: 12, startAngle: 135, sweep: 270 };
|
|
12708
|
+
static NEEDLE_COLORS = ['#2f3542', '#ff4757', '#1e90ff', '#ffa502'];
|
|
12162
12709
|
|
|
12163
|
-
|
|
12164
|
-
const options = this.options;
|
|
12165
|
-
const { cx = 100, cy = 80, r = 60, color = '#5352ed', title } = options;
|
|
12166
|
-
const p = this._normalizePercent();
|
|
12167
|
-
const resolvedColor = this._resolveColor(color, p);
|
|
12168
|
-
const totalAngle = 270;
|
|
12169
|
-
const startAngle = 135;
|
|
12170
|
-
const endAngle = startAngle + totalAngle;
|
|
12171
|
-
const needleAngle = startAngle + (p / 100) * totalAngle;
|
|
12172
|
-
const rad = Math.PI / 180;
|
|
12710
|
+
track() { return new ArcTrack(this.layout); }
|
|
12173
12711
|
|
|
12174
|
-
|
|
12175
|
-
|
|
12176
|
-
|
|
12177
|
-
this.
|
|
12712
|
+
/** One per value, the first the main one: {value, label, color}. */
|
|
12713
|
+
get needles() {
|
|
12714
|
+
const { needleColors = DialGauge.NEEDLE_COLORS } = this.options;
|
|
12715
|
+
const values = this.gaugeValue.values.length ? this.gaugeValue.values : [{ value: this.gaugeValue.value }];
|
|
12716
|
+
return values.map((v, i) => ({ ...v, value: this.scale.clamp(v.value), color: v.color ?? needleColors[i % needleColors.length] }));
|
|
12717
|
+
}
|
|
12178
12718
|
|
|
12179
|
-
|
|
12180
|
-
const
|
|
12181
|
-
const
|
|
12182
|
-
const
|
|
12183
|
-
|
|
12184
|
-
this.
|
|
12719
|
+
_render() {
|
|
12720
|
+
const { title } = this.options;
|
|
12721
|
+
const track = this.track();
|
|
12722
|
+
const { cx, cy, r } = track;
|
|
12723
|
+
|
|
12724
|
+
this.drawBackground(track, { cap: 'round' });
|
|
12725
|
+
this.drawValue(track, { cap: 'round' });
|
|
12726
|
+
this.drawTarget(track);
|
|
12727
|
+
this.drawTicks(track);
|
|
12728
|
+
const needles = this.needles;
|
|
12729
|
+
// The main needle last, so it is on top.
|
|
12730
|
+
for (const needle of [...needles].reverse()) this.drawNeedle(track, needle);
|
|
12731
|
+
this.renderer.circle(cx, cy, 5, needles[0].color);
|
|
12185
12732
|
|
|
12186
|
-
// Text
|
|
12187
12733
|
this._renderTitle(title, cx, cy + r + 25, { textAnchor: 'middle', fill: '#747d8c', textTransform: 'uppercase' });
|
|
12188
|
-
this.
|
|
12734
|
+
this._renderValueLabel(cx, cy + r - 15, { fontSize: '18px', fill: '#2f3542', fontWeight: 'bold' });
|
|
12735
|
+
if (needles.length > 1) this.drawLegend(needles, cx, cy + r + (title ? 42 : 25));
|
|
12736
|
+
}
|
|
12737
|
+
|
|
12738
|
+
/** A needle from the centre to just inside the arc. */
|
|
12739
|
+
drawNeedle(track, { value, color }) {
|
|
12740
|
+
const tip = track.point(this.scale.fraction(value), -8);
|
|
12741
|
+
this.renderer.path(`M${track.cx},${track.cy} L${tip.x},${tip.y}`, 'none', { color, width: 3, cap: 'round' });
|
|
12742
|
+
}
|
|
12743
|
+
|
|
12744
|
+
/** Each needle's label and value on one line, centred. */
|
|
12745
|
+
drawLegend(needles, cx, y) {
|
|
12746
|
+
const text = needles.map((n, i) => `${n.label ?? `#${i + 1}`} ${this.scale.label(n.value)}`);
|
|
12747
|
+
const font = { fontSize: '11px' };
|
|
12748
|
+
const width = (t) => (this.renderer.measureText?.(t, font) ?? t.length * 6) + 22;
|
|
12749
|
+
let x = cx - text.reduce((sum, t) => sum + width(t), 0) / 2;
|
|
12750
|
+
needles.forEach((needle, i) => {
|
|
12751
|
+
this.renderer.circle(x + 4, y, 4, needle.color);
|
|
12752
|
+
this.renderer.text(text[i], x + 12, y, { ...font, textAnchor: 'start', verticalAlign: 'middle', fill: '#2f3542' });
|
|
12753
|
+
x += width(text[i]);
|
|
12754
|
+
});
|
|
12755
|
+
}
|
|
12756
|
+
}
|
|
12757
|
+
|
|
12758
|
+
/**
|
|
12759
|
+
* ArcGauge — an arc filled to the value, its number in the middle: a half
|
|
12760
|
+
* circle by default, or any start angle and sweep.
|
|
12761
|
+
*
|
|
12762
|
+
* Options: cx, cy, r (100, 100, 70), thickness (16), startAngle and sweep in
|
|
12763
|
+
* degrees (180 and 180: a half circle over the top), cap ('round' ends), and
|
|
12764
|
+
* every GaugeChart option (min, max, bands, target, ticks, ...). Without
|
|
12765
|
+
* ticks the scale's two ends are labelled.
|
|
12766
|
+
*/
|
|
12767
|
+
class ArcGauge extends GaugeChart {
|
|
12768
|
+
static chartType = 'arcGauge';
|
|
12769
|
+
|
|
12770
|
+
static LAYOUT = { cx: 100, cy: 100, r: 70, thickness: 16, startAngle: 180, sweep: 180 };
|
|
12771
|
+
|
|
12772
|
+
track() { return new ArcTrack(this.layout); }
|
|
12773
|
+
|
|
12774
|
+
_render() {
|
|
12775
|
+
const { title, cap } = this.options;
|
|
12776
|
+
const track = this.track();
|
|
12777
|
+
const capOption = cap ? { cap } : {};
|
|
12778
|
+
this.drawBackground(track, capOption);
|
|
12779
|
+
this.drawValue(track, capOption);
|
|
12780
|
+
this.drawTarget(track);
|
|
12781
|
+
if (this.options.ticks) this.drawTicks(track);
|
|
12782
|
+
else this.drawEnds(track);
|
|
12783
|
+
this.drawLabels(track);
|
|
12784
|
+
this._renderTitle(title, track.cx, this.titleY(track), { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
|
|
12785
|
+
}
|
|
12786
|
+
|
|
12787
|
+
/** The value's number in the middle, a little above the centre for an open arc. */
|
|
12788
|
+
drawLabels(track) {
|
|
12789
|
+
const { cx, cy, r } = track;
|
|
12790
|
+
this._renderValueLabel(cx, cy - r * 0.15, { fontSize: `${Math.max(12, Math.round(r * 0.32))}px`, fill: '#2f3542', fontWeight: 'bold' });
|
|
12791
|
+
}
|
|
12792
|
+
|
|
12793
|
+
/** Below the arc's lowest point. */
|
|
12794
|
+
titleY(track) {
|
|
12795
|
+
const lowest = Math.max(track.point(0).y, track.point(1).y, track.cy);
|
|
12796
|
+
return lowest + track.thickness / 2 + 18;
|
|
12797
|
+
}
|
|
12798
|
+
|
|
12799
|
+
/** The scale's min and max under the arc's two ends. */
|
|
12800
|
+
drawEnds(track) {
|
|
12801
|
+
const below = track.thickness / 2 + 2;
|
|
12802
|
+
for (const f of [0, 1]) {
|
|
12803
|
+
const at = track.point(f);
|
|
12804
|
+
this.renderer.text(this.scale.tickLabel(this.scale.valueAt(f), 1), at.x, at.y + below + 8, {
|
|
12805
|
+
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px', fill: '#747d8c',
|
|
12806
|
+
});
|
|
12807
|
+
}
|
|
12808
|
+
}
|
|
12809
|
+
}
|
|
12810
|
+
|
|
12811
|
+
/**
|
|
12812
|
+
* RingGauge — a progress ring: a whole circle filled clockwise from the top
|
|
12813
|
+
* to the value, its number in the middle.
|
|
12814
|
+
*
|
|
12815
|
+
* Options as ArcGauge's, drawn as a ring: cx, cy, r (100, 100, 60),
|
|
12816
|
+
* thickness (14), startAngle (-90, the top) and sweep (360).
|
|
12817
|
+
*/
|
|
12818
|
+
class RingGauge extends ArcGauge {
|
|
12819
|
+
static chartType = 'ringGauge';
|
|
12820
|
+
static LAYOUT = { cx: 100, cy: 100, r: 60, thickness: 14, startAngle: -90, sweep: 360 };
|
|
12821
|
+
|
|
12822
|
+
/** The number in the very middle. */
|
|
12823
|
+
drawLabels(track) {
|
|
12824
|
+
this._renderValueLabel(track.cx, track.cy, { fontSize: `${Math.max(12, Math.round(track.r * 0.36))}px`, fill: '#2f3542', fontWeight: 'bold' });
|
|
12825
|
+
}
|
|
12826
|
+
|
|
12827
|
+
/** A ring's two ends are one point: no end labels. */
|
|
12828
|
+
drawEnds() {}
|
|
12829
|
+
|
|
12830
|
+
titleY(track) { return track.cy + track.r + track.thickness / 2 + 18; }
|
|
12831
|
+
}
|
|
12832
|
+
|
|
12833
|
+
/**
|
|
12834
|
+
* MultiRingGauge — several values as rings one inside the other ("activity
|
|
12835
|
+
* rings"), each filled to its value on its own scale, with a legend.
|
|
12836
|
+
*
|
|
12837
|
+
* Data: `[{ value, label?, color?, min?, max? }]` (or plain numbers), outer
|
|
12838
|
+
* ring first; each ring's min and max default to the options'.
|
|
12839
|
+
* Options: cx, cy, r (the outer ring: 100, 100, 70), thickness (12), gap (4),
|
|
12840
|
+
* startAngle (-90) and sweep (360), colors (a colour per ring without its
|
|
12841
|
+
* own), legend (true: each ring's label and value to the right), and the
|
|
12842
|
+
* GaugeChart options min, max, format, units.
|
|
12843
|
+
*/
|
|
12844
|
+
class MultiRingGauge extends GaugeChart {
|
|
12845
|
+
static chartType = 'multiRingGauge';
|
|
12846
|
+
static LAYOUT = { cx: 100, cy: 100, r: 70, thickness: 12, gap: 4, startAngle: -90, sweep: 360 };
|
|
12847
|
+
static COLORS = ['#5352ed', '#2ed573', '#ffa502', '#ff4757', '#1e90ff'];
|
|
12848
|
+
|
|
12849
|
+
/** One per value: its track, scale and colour. */
|
|
12850
|
+
get rings() {
|
|
12851
|
+
const { cx, cy, r, thickness, gap, startAngle, sweep } = this.layout;
|
|
12852
|
+
const { min, max, format, units, colors = MultiRingGauge.COLORS } = this.options;
|
|
12853
|
+
return this.gaugeValue.values.map((v, i) => ({
|
|
12854
|
+
...v,
|
|
12855
|
+
track: new ArcTrack({ cx, cy, r: r - i * (thickness + gap), thickness, startAngle, sweep }),
|
|
12856
|
+
scale: new GaugeScale({ min: v.min ?? min, max: v.max ?? max, format: format ?? null, units }),
|
|
12857
|
+
color: v.color ?? colors[i % colors.length],
|
|
12858
|
+
})).filter(ring => ring.track.r > ring.track.thickness / 2);
|
|
12859
|
+
}
|
|
12860
|
+
|
|
12861
|
+
_render() {
|
|
12862
|
+
const { title, legend = true } = this.options;
|
|
12863
|
+
const { cx, cy, r, thickness } = this.layout;
|
|
12864
|
+
const rings = this.rings;
|
|
12865
|
+
for (const ring of rings) {
|
|
12866
|
+
this.drawBackground(ring.track);
|
|
12867
|
+
this.drawValue(ring.track, { value: ring.scale.clamp(ring.value), scale: ring.scale, color: ring.color, cap: 'round' });
|
|
12868
|
+
}
|
|
12869
|
+
if (legend) {
|
|
12870
|
+
const x = cx + r + thickness / 2 + 16;
|
|
12871
|
+
const top = cy - ((rings.length - 1) * 18) / 2;
|
|
12872
|
+
rings.forEach((ring, i) => {
|
|
12873
|
+
const y = top + i * 18;
|
|
12874
|
+
this.renderer.circle(x + 4, y, 4, ring.color);
|
|
12875
|
+
this.renderer.text(`${ring.label ?? `#${i + 1}`} ${ring.scale.label(ring.scale.clamp(ring.value))}`, x + 14, y, {
|
|
12876
|
+
textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#2f3542',
|
|
12877
|
+
});
|
|
12878
|
+
});
|
|
12879
|
+
}
|
|
12880
|
+
this._renderTitle(title, cx, cy + r + thickness / 2 + 18, { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
|
|
12881
|
+
}
|
|
12882
|
+
}
|
|
12883
|
+
|
|
12884
|
+
/**
|
|
12885
|
+
* Sparkline — a small line of a series' recent values, fitted to a box:
|
|
12886
|
+
* the first value at the left, the last at the right, the lowest at the
|
|
12887
|
+
* bottom and the highest at the top.
|
|
12888
|
+
*/
|
|
12889
|
+
class Sparkline {
|
|
12890
|
+
/**
|
|
12891
|
+
* @param {number[]} values - At least two to draw a line
|
|
12892
|
+
* @param {{x:number, y:number, width:number, height:number}} box
|
|
12893
|
+
*/
|
|
12894
|
+
constructor(values, box) {
|
|
12895
|
+
this.values = values.map(Number).filter(Number.isFinite);
|
|
12896
|
+
this.box = box;
|
|
12897
|
+
}
|
|
12898
|
+
|
|
12899
|
+
/** Each value's point in the box (none for fewer than two values). */
|
|
12900
|
+
get points() {
|
|
12901
|
+
const { values, box } = this;
|
|
12902
|
+
if (values.length < 2) return [];
|
|
12903
|
+
const lo = Math.min(...values);
|
|
12904
|
+
const hi = Math.max(...values);
|
|
12905
|
+
const span = (hi - lo) || 1;
|
|
12906
|
+
// A flat series sits across the middle.
|
|
12907
|
+
const flat = hi === lo;
|
|
12908
|
+
return values.map((v, i) => ({
|
|
12909
|
+
x: box.x + (i / (values.length - 1)) * box.width,
|
|
12910
|
+
y: flat ? box.y + box.height / 2 : box.y + box.height - ((v - lo) / span) * box.height,
|
|
12911
|
+
}));
|
|
12912
|
+
}
|
|
12913
|
+
|
|
12914
|
+
/** The line as a path `d` (empty for fewer than two values). */
|
|
12915
|
+
get d() { return this.points.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' '); }
|
|
12916
|
+
|
|
12917
|
+
/** Draw the line and a dot on its last value. */
|
|
12918
|
+
draw(renderer, { color = '#5352ed', width = 1.5 } = {}) {
|
|
12919
|
+
const points = this.points;
|
|
12920
|
+
if (!points.length) return;
|
|
12921
|
+
renderer.path(this.d, 'none', { color, width });
|
|
12922
|
+
const last = points.at(-1);
|
|
12923
|
+
renderer.circle(last.x, last.y, 2.5, color);
|
|
12924
|
+
}
|
|
12925
|
+
}
|
|
12926
|
+
|
|
12927
|
+
/**
|
|
12928
|
+
* KpiTile — a key figure on a card: its title, its value large, the change
|
|
12929
|
+
* since the previous value (▲ / ▼, coloured good or bad), and a sparkline of
|
|
12930
|
+
* its history.
|
|
12931
|
+
*
|
|
12932
|
+
* Data: a number, or `{ value, previous?, history?, target? }` (`history` ends
|
|
12933
|
+
* with the current value; without `previous` the one before it is used).
|
|
12934
|
+
* Options: x, y, width, height (10, 10, 220, 120), format and units (a
|
|
12935
|
+
* number with thousands grouped by default), change ('percent', or 'value'
|
|
12936
|
+
* for the difference), upIsGood (true: up is green; false for costs, errors,
|
|
12937
|
+
* ...), bands (the value takes its band's colour), sparkColor, background,
|
|
12938
|
+
* and title.
|
|
12939
|
+
*/
|
|
12940
|
+
class KpiTile extends GaugeChart {
|
|
12941
|
+
static chartType = 'kpiTile';
|
|
12942
|
+
static LAYOUT = { x: 10, y: 10, width: 220, height: 120 };
|
|
12943
|
+
static GOOD = '#2e9e5b';
|
|
12944
|
+
static BAD = '#d64545';
|
|
12945
|
+
|
|
12946
|
+
/** A figure reads as it is, its thousands grouped ("12,345"). */
|
|
12947
|
+
defaultFormat() { return { grouping: true }; }
|
|
12948
|
+
|
|
12949
|
+
/** The value as it is: a tile has no range to hold it to. */
|
|
12950
|
+
get value() { return this.gaugeValue.value; }
|
|
12951
|
+
|
|
12952
|
+
/** The value's colour: its band's when there are bands, else the text colour. */
|
|
12953
|
+
valueColor(value = this.value) {
|
|
12954
|
+
if (this.options.color != null || this.bands.length) return super.valueColor(value);
|
|
12955
|
+
return '#2f3542';
|
|
12956
|
+
}
|
|
12957
|
+
|
|
12958
|
+
/** The change line: "▲ 10.0%" or "▼ 3", and whether it is good; null without a previous value. */
|
|
12959
|
+
get changeText() {
|
|
12960
|
+
const { change, changeRatio } = this.gaugeValue;
|
|
12961
|
+
if (change == null) return null;
|
|
12962
|
+
const { change: as = 'percent', upIsGood = true } = this.options;
|
|
12963
|
+
const arrow = change > 0 ? '▲' : change < 0 ? '▼' : '■';
|
|
12964
|
+
const amount = as === 'value' || changeRatio == null
|
|
12965
|
+
? this.scale.label(Math.abs(change))
|
|
12966
|
+
: `${(Math.abs(changeRatio) * 100).toFixed(1)}%`;
|
|
12967
|
+
const good = change === 0 ? null : (change > 0) === upIsGood;
|
|
12968
|
+
return { text: `${arrow} ${amount}`, good };
|
|
12969
|
+
}
|
|
12970
|
+
|
|
12971
|
+
_render() {
|
|
12972
|
+
const { x, y, width, height } = this.layout;
|
|
12973
|
+
const { title, background = '#ffffff', sparkColor = '#5352ed' } = this.options;
|
|
12974
|
+
const pad = 12;
|
|
12975
|
+
this.renderer.rect(x, y, width, height, background, { color: '#e1e4e8', width: 1 }, { rx: 8, ry: 8 });
|
|
12976
|
+
if (title) {
|
|
12977
|
+
this.renderer.text(title, x + pad, y + pad + 6, { textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#747d8c' });
|
|
12978
|
+
}
|
|
12979
|
+
const valueSize = Math.max(14, Math.round(height * 0.26));
|
|
12980
|
+
const valueY = y + pad + 18 + valueSize / 2;
|
|
12981
|
+
this.renderer.text(this.scale.label(this.value), x + pad, valueY, {
|
|
12982
|
+
textAnchor: 'start', verticalAlign: 'middle', fontSize: `${valueSize}px`, fontWeight: 'bold', fill: this.valueColor(),
|
|
12983
|
+
});
|
|
12984
|
+
const change = this.changeText;
|
|
12985
|
+
if (change) {
|
|
12986
|
+
const color = change.good == null ? '#747d8c' : change.good ? KpiTile.GOOD : KpiTile.BAD;
|
|
12987
|
+
this.renderer.text(change.text, x + width - pad, valueY, { textAnchor: 'end', verticalAlign: 'middle', fontSize: '12px', fontWeight: 'bold', fill: color });
|
|
12988
|
+
}
|
|
12989
|
+
const sparkTop = valueY + valueSize / 2 + 8;
|
|
12990
|
+
const sparkHeight = y + height - pad - sparkTop;
|
|
12991
|
+
if (sparkHeight > 4) {
|
|
12992
|
+
new Sparkline(this.gaugeValue.history, { x: x + pad, y: sparkTop, width: width - 2 * pad, height: sparkHeight }).draw(this.renderer, { color: sparkColor });
|
|
12993
|
+
}
|
|
12994
|
+
}
|
|
12995
|
+
}
|
|
12996
|
+
|
|
12997
|
+
/**
|
|
12998
|
+
* ThermometerGauge — a tube filled from a bulb at its foot up to the value.
|
|
12999
|
+
*
|
|
13000
|
+
* Options: x, y, width, height (the tube: 40, 20, 18, 200), bulb (its radius:
|
|
13001
|
+
* 16), and every GaugeChart option (min, max, units, bands, target, ticks,
|
|
13002
|
+
* segments, ...). The value's label sits under the bulb.
|
|
13003
|
+
*/
|
|
13004
|
+
class ThermometerGauge extends GaugeChart {
|
|
13005
|
+
static chartType = 'thermometerGauge';
|
|
13006
|
+
static LAYOUT = { x: 40, y: 20, width: 18, height: 200, bulb: 16 };
|
|
13007
|
+
static defaultColor = '#ff4757';
|
|
13008
|
+
|
|
13009
|
+
track() {
|
|
13010
|
+
const { x, y, width, height } = this.layout;
|
|
13011
|
+
return new VerticalTrack({ x, y, length: height, thickness: width, radius: width / 2 });
|
|
13012
|
+
}
|
|
13013
|
+
|
|
13014
|
+
/** The bulb's centre: under the tube, overlapping its foot so the two join. */
|
|
13015
|
+
bulbCenter(track) {
|
|
13016
|
+
const { bulb } = this.layout;
|
|
13017
|
+
const foot = track.point(0);
|
|
13018
|
+
return { x: foot.x, y: foot.y + bulb * 0.75 };
|
|
13019
|
+
}
|
|
13020
|
+
|
|
13021
|
+
_render() {
|
|
13022
|
+
const { title } = this.options;
|
|
13023
|
+
const { bulb } = this.layout;
|
|
13024
|
+
const track = this.track();
|
|
13025
|
+
const centre = this.bulbCenter(track);
|
|
13026
|
+
this._renderTitle(title, centre.x, track.y - 10, { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
|
|
13027
|
+
this.drawBackground(track);
|
|
13028
|
+
this.drawValue(track);
|
|
13029
|
+
// The bulb is always full: the value's colour, round a lighter ring.
|
|
13030
|
+
this.renderer.circle(centre.x, centre.y, bulb, this.constructor.trackColor);
|
|
13031
|
+
this.renderer.circle(centre.x, centre.y, bulb - 3, this.valueColor());
|
|
13032
|
+
this.drawTarget(track);
|
|
13033
|
+
this.drawTicks(track);
|
|
13034
|
+
this._renderValueLabel(centre.x, centre.y + bulb + 14, { fontWeight: 'bold', fill: '#2f3542' });
|
|
13035
|
+
}
|
|
13036
|
+
}
|
|
13037
|
+
|
|
13038
|
+
/**
|
|
13039
|
+
* GaugeAnimation — eases a gauge from the value it shows to a new one, so a
|
|
13040
|
+
* needle or fill moves instead of jumping.
|
|
13041
|
+
*
|
|
13042
|
+
* Each frame draws the gauge from `GaugeValue.between(from, to, t)`: the
|
|
13043
|
+
* value, the target and each of several values move together; the history
|
|
13044
|
+
* and the previous value are the new ones throughout. The clock is
|
|
13045
|
+
* injectable (`schedule`, `now`) so it runs without a browser in tests.
|
|
13046
|
+
*/
|
|
13047
|
+
class GaugeAnimation {
|
|
13048
|
+
/** Ease out (cubic): quick at first, settling on the value. */
|
|
13049
|
+
static ease(t) { return 1 - (1 - t) ** 3; }
|
|
13050
|
+
|
|
13051
|
+
/**
|
|
13052
|
+
* @param {Object} options
|
|
13053
|
+
* @param {GaugeValue} options.from
|
|
13054
|
+
* @param {GaugeValue} options.to
|
|
13055
|
+
* @param {number} [options.duration=400] - Milliseconds
|
|
13056
|
+
* @param {(data: Object) => void} options.draw - Draws one frame from its gauge data
|
|
13057
|
+
* @param {(callback: Function) => *} [options.schedule] - requestAnimationFrame, else a 16ms timer
|
|
13058
|
+
* @param {() => number} [options.now]
|
|
13059
|
+
*/
|
|
13060
|
+
constructor({ from, to, duration = 400, draw, schedule = GaugeAnimation.defaultSchedule(), now = () => Date.now() }) {
|
|
13061
|
+
Object.assign(this, { from, to, duration, draw, schedule, now });
|
|
13062
|
+
this.stopped = false;
|
|
13063
|
+
}
|
|
13064
|
+
|
|
13065
|
+
static defaultSchedule() {
|
|
13066
|
+
return typeof globalThis.requestAnimationFrame === 'function'
|
|
13067
|
+
? (callback) => globalThis.requestAnimationFrame(callback)
|
|
13068
|
+
: (callback) => setTimeout(callback, 16);
|
|
13069
|
+
}
|
|
13070
|
+
|
|
13071
|
+
/** Draw the first frame now and the rest on the clock, ending exactly on `to`. */
|
|
13072
|
+
start() {
|
|
13073
|
+
const started = this.now();
|
|
13074
|
+
const frame = () => {
|
|
13075
|
+
if (this.stopped) return;
|
|
13076
|
+
const t = this.duration > 0 ? Math.min(1, (this.now() - started) / this.duration) : 1;
|
|
13077
|
+
this.draw(GaugeValue.between(this.from, this.to, GaugeAnimation.ease(t)));
|
|
13078
|
+
if (t < 1) this.schedule(frame);
|
|
13079
|
+
};
|
|
13080
|
+
frame();
|
|
13081
|
+
return this;
|
|
12189
13082
|
}
|
|
13083
|
+
|
|
13084
|
+
/** No more frames (a newer value has started its own). */
|
|
13085
|
+
stop() { this.stopped = true; }
|
|
12190
13086
|
}
|
|
12191
13087
|
|
|
12192
13088
|
/**
|
|
@@ -22316,20 +23212,14 @@
|
|
|
22316
23212
|
};
|
|
22317
23213
|
const style = this.textStyle;
|
|
22318
23214
|
const font = TextStyle.svgAttributes(style, { defaultPixels: 12 });
|
|
22319
|
-
const zLine = `Z:${this.zIndex || 0}`;
|
|
22320
23215
|
if (TextStyle.placesText(style)) {
|
|
22321
23216
|
// Placed in the shape's box by its alignment and position (centred, middle where unset).
|
|
22322
23217
|
const at = TextStyle.block(style, this, 1, { pixels: font['font-size'], defaults: { textAlign: 'center', textPosition: 'middle' } });
|
|
22323
23218
|
const placed = { 'text-anchor': at.textAnchor, 'dominant-baseline': 'auto' };
|
|
22324
23219
|
if (this.name) text({ x: at.x, y: at.baseline, ...font, ...placed, fill: this.textColor }, this.name);
|
|
22325
|
-
// Under the name, or over it when the name is at the bottom (so it stays in the shape).
|
|
22326
|
-
const zY = style.textPosition === 'bottom' ? at.baseline - font['font-size'] - 2 : at.baseline + 12;
|
|
22327
|
-
text({ x: at.x, y: zY, 'font-size': 10, fill: '#666', ...placed }, zLine);
|
|
22328
23220
|
return;
|
|
22329
23221
|
}
|
|
22330
23222
|
if (this.name) text({ x, y, ...font, fill: this.textColor }, this.name);
|
|
22331
|
-
// Below the name, however large it is set.
|
|
22332
|
-
text({ x, y: y + Math.max(16, font['font-size'] / 2 + 10), 'font-size': 10, fill: '#666' }, zLine);
|
|
22333
23223
|
}
|
|
22334
23224
|
|
|
22335
23225
|
serialize() {
|
|
@@ -40085,14 +40975,11 @@
|
|
|
40085
40975
|
|
|
40086
40976
|
/**
|
|
40087
40977
|
* Renders a bar gauge.
|
|
40088
|
-
* @param {number} value The value to display (0-100).
|
|
40978
|
+
* @param {number|{value:number, target?:number}} value The value to display (0-100 unless options.min/max are given).
|
|
40089
40979
|
* @param {object} [options] Charting options.
|
|
40090
40980
|
*/
|
|
40091
40981
|
barGauge(value, options = {}) {
|
|
40092
|
-
|
|
40093
|
-
this._chart3D = false; // leaving 3D mode — clears the SVG-zoom guard added for bubbleChart3D
|
|
40094
|
-
this._chartState = { type: 'barGauge', data: value, options };
|
|
40095
|
-
this._renderChart(new BarGauge(this.renderer, value, options));
|
|
40982
|
+
this._renderGauge(BarGauge, 'barGauge', value, options);
|
|
40096
40983
|
}
|
|
40097
40984
|
|
|
40098
40985
|
/**
|
|
@@ -40102,22 +40989,90 @@
|
|
|
40102
40989
|
* @param {object} [options] Charting options.
|
|
40103
40990
|
*/
|
|
40104
40991
|
bulletGauge(data, options = {}) {
|
|
40105
|
-
|
|
40106
|
-
this._chart3D = false;
|
|
40107
|
-
this._chartState = { type: 'bullet', data, options };
|
|
40108
|
-
this._renderChart(new BulletGauge(this.renderer, data, options));
|
|
40992
|
+
this._renderGauge(BulletGauge, 'bullet', data, options);
|
|
40109
40993
|
}
|
|
40110
40994
|
|
|
40111
40995
|
/**
|
|
40112
40996
|
* Renders a dial gauge.
|
|
40113
|
-
* @param {number} value The value to display (0-100).
|
|
40997
|
+
* @param {number|{value:number, target?:number}} value The value to display (0-100 unless options.min/max are given).
|
|
40114
40998
|
* @param {object} [options] Charting options.
|
|
40115
40999
|
*/
|
|
40116
41000
|
dialGauge(value, options = {}) {
|
|
41001
|
+
this._renderGauge(DialGauge, 'dialGauge', value, options);
|
|
41002
|
+
}
|
|
41003
|
+
|
|
41004
|
+
/**
|
|
41005
|
+
* Renders an arc gauge: a half circle (or any start angle and sweep) filled to the value.
|
|
41006
|
+
* @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
|
|
41007
|
+
* @param {object} [options] The gauge options (see REFERENCE.md, Gauges), cx, cy, r, thickness, startAngle, sweep, cap.
|
|
41008
|
+
*/
|
|
41009
|
+
arcGauge(value, options = {}) {
|
|
41010
|
+
this._renderGauge(ArcGauge, 'arcGauge', value, options);
|
|
41011
|
+
}
|
|
41012
|
+
|
|
41013
|
+
/**
|
|
41014
|
+
* Renders a progress ring, its value in the middle.
|
|
41015
|
+
* @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
|
|
41016
|
+
* @param {object} [options] The gauge options, cx, cy, r, thickness.
|
|
41017
|
+
*/
|
|
41018
|
+
ringGauge(value, options = {}) {
|
|
41019
|
+
this._renderGauge(RingGauge, 'ringGauge', value, options);
|
|
41020
|
+
}
|
|
41021
|
+
|
|
41022
|
+
/**
|
|
41023
|
+
* Renders several values as rings one inside the other, with a legend.
|
|
41024
|
+
* @param {Array<number|{value:number, label?:string, color?:string, min?:number, max?:number}>} values Outer ring first.
|
|
41025
|
+
* @param {object} [options] cx, cy, r, thickness, gap, colors, legend, min, max, format, units, title.
|
|
41026
|
+
*/
|
|
41027
|
+
multiRingGauge(values, options = {}) {
|
|
41028
|
+
this._renderGauge(MultiRingGauge, 'multiRingGauge', values, options);
|
|
41029
|
+
}
|
|
41030
|
+
|
|
41031
|
+
/**
|
|
41032
|
+
* Renders a KPI tile: a figure, its change since the previous value, and a sparkline of its history.
|
|
41033
|
+
* @param {number|{value?:number, previous?:number, history?:number[]}} data
|
|
41034
|
+
* @param {object} [options] x, y, width, height, format, units, change ('percent'|'value'), upIsGood, bands, sparkColor, background, title.
|
|
41035
|
+
*/
|
|
41036
|
+
kpiTile(data, options = {}) {
|
|
41037
|
+
this._renderGauge(KpiTile, 'kpiTile', data, options);
|
|
41038
|
+
}
|
|
41039
|
+
|
|
41040
|
+
/**
|
|
41041
|
+
* Renders a thermometer: a tube filled from a bulb up to the value.
|
|
41042
|
+
* @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
|
|
41043
|
+
* @param {object} [options] The gauge options, x, y, width, height, bulb.
|
|
41044
|
+
*/
|
|
41045
|
+
thermometerGauge(value, options = {}) {
|
|
41046
|
+
this._renderGauge(ThermometerGauge, 'thermometerGauge', value, options);
|
|
41047
|
+
}
|
|
41048
|
+
|
|
41049
|
+
/**
|
|
41050
|
+
* Every gauge method draws through here. With `options.animate` (true:
|
|
41051
|
+
* 400ms, or milliseconds) a gauge of the same type already showing eases
|
|
41052
|
+
* from the value it shows to the new one; a newer call stops an older
|
|
41053
|
+
* animation and starts from where it got to.
|
|
41054
|
+
* @private
|
|
41055
|
+
*/
|
|
41056
|
+
_renderGauge(GaugeType, type, data, options = {}) {
|
|
40117
41057
|
if (!this.renderer) return;
|
|
40118
41058
|
this._chart3D = false; // leaving 3D mode — clears the SVG-zoom guard added for bubbleChart3D
|
|
40119
|
-
this._chartState = { type
|
|
40120
|
-
this.
|
|
41059
|
+
this._chartState = { type, data, options };
|
|
41060
|
+
this._gaugeAnimation?.stop();
|
|
41061
|
+
const shown = this._gaugeShown?.type === type ? this._gaugeShown.data : null;
|
|
41062
|
+
const draw = (frame) => {
|
|
41063
|
+
this._gaugeShown = { type, data: frame };
|
|
41064
|
+
this._renderChart(new GaugeType(this.renderer, frame, options));
|
|
41065
|
+
};
|
|
41066
|
+
if (!options.animate || shown == null) {
|
|
41067
|
+
draw(data);
|
|
41068
|
+
return;
|
|
41069
|
+
}
|
|
41070
|
+
this._gaugeAnimation = new GaugeAnimation({
|
|
41071
|
+
from: GaugeValue.from(shown, options),
|
|
41072
|
+
to: GaugeValue.from(data, options),
|
|
41073
|
+
duration: options.animate === true ? 400 : Number(options.animate),
|
|
41074
|
+
draw,
|
|
41075
|
+
}).start();
|
|
40121
41076
|
}
|
|
40122
41077
|
|
|
40123
41078
|
_arcPath(cx, cy, r, startAngle, endAngle) {
|