@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.
@@ -4297,18 +4297,18 @@
4297
4297
  key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
4298
4298
  coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
4299
4299
  axes: [
4300
- { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] },
4300
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
4301
4301
  ],
4302
- dataShape: 'number (0-100) — no category axis; the only role IS the value',
4302
+ 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)',
4303
4303
  streamShape: 'none', // no array/window at all — update by calling dialGauge(newValue) again directly
4304
4304
  },
4305
4305
  {
4306
4306
  key: 'gauge-bar', label: 'Gauge (Bar)', method: 'barGauge', category: 'gauge',
4307
4307
  coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
4308
4308
  axes: [
4309
- { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] },
4309
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
4310
4310
  ],
4311
- dataShape: 'number (0-100)',
4311
+ dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
4312
4312
  streamShape: 'none', // no array/window at all — update by calling barGauge(newValue) again directly
4313
4313
  },
4314
4314
  {
@@ -4321,6 +4321,51 @@
4321
4321
  dataShape: 'number or {value, target?, ranges?:Array<{max, label?, color?}>}',
4322
4322
  streamShape: 'none',
4323
4323
  },
4324
+ {
4325
+ key: 'gauge-arc', label: 'Gauge (Arc)', method: 'arcGauge', category: 'gauge',
4326
+ coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
4327
+ axes: [
4328
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
4329
+ ],
4330
+ dataShape: 'number, or {value, target?} (0-100 unless min/max are given) — a half circle by default, any startAngle/sweep',
4331
+ streamShape: 'none',
4332
+ },
4333
+ {
4334
+ key: 'gauge-ring', label: 'Gauge (Ring)', method: 'ringGauge', category: 'gauge',
4335
+ coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
4336
+ axes: [
4337
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
4338
+ ],
4339
+ dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
4340
+ streamShape: 'none',
4341
+ },
4342
+ {
4343
+ key: 'gauge-rings', label: 'Gauge (Rings)', method: 'multiRingGauge', category: 'gauge',
4344
+ coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
4345
+ axes: [
4346
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
4347
+ ],
4348
+ dataShape: 'Array<number|{value, label?, color?, min?, max?}> — one ring per value, outer first, each on its own scale',
4349
+ streamShape: 'none',
4350
+ },
4351
+ {
4352
+ key: 'gauge-thermometer', label: 'Gauge (Thermometer)', method: 'thermometerGauge', category: 'gauge',
4353
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
4354
+ axes: [
4355
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
4356
+ ],
4357
+ dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
4358
+ streamShape: 'none',
4359
+ },
4360
+ {
4361
+ key: 'kpi-tile', label: 'KPI Tile', method: 'kpiTile', category: 'gauge',
4362
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
4363
+ axes: [
4364
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
4365
+ ],
4366
+ dataShape: 'number, or {value?, previous?, history?:number[]} — the figure, its change since the previous value, a sparkline of the history',
4367
+ streamShape: 'none',
4368
+ },
4324
4369
  {
4325
4370
  key: 'bar3D', label: 'Bar (3D)', method: 'barChart3D', category: '3d',
4326
4371
  coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.ISOMETRIC,
@@ -14751,6 +14796,12 @@
14751
14796
  * Angles are in radians.
14752
14797
  */
14753
14798
  static arcPath(cx, cy, r, startAngle, endAngle) {
14799
+ // A whole circle starts and ends on one point, which an SVG arc draws as nothing: two halves.
14800
+ if (endAngle - startAngle >= 2 * Math.PI - 1e-9) {
14801
+ const mid = startAngle + Math.PI;
14802
+ const at = (a) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
14803
+ return `M ${at(startAngle)} A ${r} ${r} 0 0 1 ${at(mid)} A ${r} ${r} 0 0 1 ${at(startAngle)}`;
14804
+ }
14754
14805
  const largeArcFlag = endAngle - startAngle <= Math.PI ? '0' : '1';
14755
14806
  const start = {
14756
14807
  x: cx + r * Math.cos(startAngle),
@@ -14765,21 +14816,343 @@
14765
14816
  }
14766
14817
 
14767
14818
  /**
14768
- * GaugeChart — abstract base for 0-100 percentage gauges.
14819
+ * GaugeScale — the range a gauge measures and how its numbers read.
14769
14820
  *
14770
- * Subclasses implement `_renderGauge()` and use the shared normalization,
14771
- * color resolution, title, and percentage-label helpers.
14821
+ * One scale per gauge: it turns a value into a 0-1 fraction of the way along
14822
+ * the track, gives the tick values, and formats the value and tick labels.
14823
+ * Every gauge draws from it, so `min`, `max` and `format` mean the same on
14824
+ * all of them.
14825
+ */
14826
+ class GaugeScale {
14827
+ /**
14828
+ * @param {Object} [options]
14829
+ * @param {number} [options.min=0]
14830
+ * @param {number} [options.max=100]
14831
+ * @param {Function|Object} [options.format] - `(value) => string`, or
14832
+ * `{ decimals, prefix, suffix, grouping, compact }`. Without one, a
14833
+ * 0-100 scale reads as a percentage ("75.0%"), any other as its number.
14834
+ * @param {string} [options.units] - The suffix when no format is given ("ms", "°C")
14835
+ */
14836
+ constructor({ min = 0, max = 100, format = null, units = null } = {}) {
14837
+ this.min = Number(min);
14838
+ this.max = Number(max);
14839
+ this.format = format ?? { decimals: 1, suffix: units != null ? ` ${units}` : (this.isPercent ? '%' : '') };
14840
+ }
14841
+
14842
+ /** True for the plain 0-100 scale a percentage gauge has. */
14843
+ get isPercent() { return this.min === 0 && this.max === 100; }
14844
+
14845
+ get span() { return (this.max - this.min) || 1; }
14846
+
14847
+ /** The value held to the scale's range. */
14848
+ clamp(value) {
14849
+ const v = Number(value);
14850
+ if (!Number.isFinite(v)) return this.min;
14851
+ return Math.min(Math.max(v, Math.min(this.min, this.max)), Math.max(this.min, this.max));
14852
+ }
14853
+
14854
+ /** How far along the scale a value is, 0-1 (held to the range). */
14855
+ fraction(value) { return (this.clamp(value) - this.min) / this.span; }
14856
+
14857
+ /** The value at a fraction of the way along. */
14858
+ valueAt(fraction) { return this.min + fraction * this.span; }
14859
+
14860
+ /** `count` equal steps: count + 1 values from min to max. */
14861
+ ticks(count = 5) {
14862
+ const n = Math.max(1, Math.round(count));
14863
+ return Array.from({ length: n + 1 }, (_, i) => this.valueAt(i / n));
14864
+ }
14865
+
14866
+ /** A value as the gauge shows it. */
14867
+ label(value) { return GaugeScale.formatNumber(value, this.format); }
14868
+
14869
+ /**
14870
+ * A tick's label: the value's prefix and suffix, with only the decimals a
14871
+ * step of `span / count` needs ("0", "25", ... not "0.0%", "25.0%").
14872
+ */
14873
+ tickLabel(value, count = 5) {
14874
+ if (typeof this.format === 'function') return this.format(value);
14875
+ const step = this.span / Math.max(1, Math.round(count));
14876
+ // The fewest decimals that show the step exactly (0.25 needs 2), at most 6.
14877
+ let decimals = 0;
14878
+ while (decimals < 6 && Math.abs(step * 10 ** decimals - Math.round(step * 10 ** decimals)) > 1e-9) decimals++;
14879
+ return GaugeScale.formatNumber(value, { ...this.format, decimals });
14880
+ }
14881
+
14882
+ /**
14883
+ * Format a number: a function format is called; an object one gives
14884
+ * decimals (null: as it is), prefix, suffix, grouping (1,234) and compact (1.2K).
14885
+ */
14886
+ static formatNumber(value, format = {}) {
14887
+ if (typeof format === 'function') return format(value);
14888
+ const { decimals = null, prefix = '', suffix = '', grouping = false, compact = false } = format ?? {};
14889
+ const v = Number(value);
14890
+ if (!Number.isFinite(v)) return `${prefix}${value}${suffix}`;
14891
+ let text;
14892
+ if (compact || grouping) {
14893
+ const digits = decimals ?? (compact ? 1 : undefined);
14894
+ text = new Intl.NumberFormat('en', {
14895
+ notation: compact ? 'compact' : 'standard',
14896
+ useGrouping: true,
14897
+ ...(digits != null ? { minimumFractionDigits: compact ? 0 : digits, maximumFractionDigits: digits } : {}),
14898
+ }).format(v);
14899
+ } else {
14900
+ text = decimals == null ? String(v) : v.toFixed(decimals);
14901
+ }
14902
+ return `${prefix}${text}${suffix}`;
14903
+ }
14904
+ }
14905
+
14906
+ /**
14907
+ * GaugeBands — ranges along a gauge's scale ("poor", "ok", "good"), each with
14908
+ * its colour and label.
14909
+ *
14910
+ * Given as `[{ to, color?, label? }]` in order: each band runs from where the
14911
+ * one before it ended (the scale's min for the first) to its `to` (`max` is
14912
+ * read the same, as the bullet graph has always taken it). Every gauge draws
14913
+ * its bands from here, and a value takes the colour of the band it falls in.
14914
+ */
14915
+ class GaugeBands {
14916
+ /** Band colours used when a band has none of its own: bad, warning, good. */
14917
+ static COLORS = ['#ff6b6b', '#ffd93d', '#6bcb77'];
14918
+
14919
+ /** How far a band's colour is mixed toward white when drawn behind a value. */
14920
+ static TINT = 0.65;
14921
+
14922
+ /**
14923
+ * @param {Array<{to?:number, max?:number, color?:string, label?:string}>} bands
14924
+ * @param {import('./GaugeScale.js').GaugeScale} scale
14925
+ * @param {Object} [options]
14926
+ * @param {string[]} [options.colors] - Colours for bands without their own
14927
+ */
14928
+ constructor(bands = [], scale, { colors = GaugeBands.COLORS } = {}) {
14929
+ this.scale = scale;
14930
+ let from = scale.min;
14931
+ this.list = (bands ?? []).map((band, index) => {
14932
+ const to = scale.clamp(band.to ?? band.max ?? scale.max);
14933
+ const entry = { from, to, color: band.color ?? colors[index % colors.length], label: band.label ?? null, index };
14934
+ from = to;
14935
+ return entry;
14936
+ });
14937
+ }
14938
+
14939
+ get length() { return this.list.length; }
14940
+
14941
+ /** The band a value falls in (the last one at a shared edge), or null. */
14942
+ at(value) {
14943
+ const v = this.scale.clamp(value);
14944
+ return this.list.find(b => v >= b.from && v < b.to) ?? this.list.findLast(b => v >= b.from && v <= b.to) ?? null;
14945
+ }
14946
+
14947
+ /** A band's colour as drawn behind the value: lighter, so the value shows on it. */
14948
+ static tint(color) {
14949
+ return /^#[0-9a-f]{6}$/i.test(color) ? ColorPalette.interpolate(color, '#ffffff', GaugeBands.TINT) : color;
14950
+ }
14951
+ }
14952
+
14953
+ /**
14954
+ * GaugeValue — what a gauge's data says: the value, and what it is compared
14955
+ * with.
14956
+ *
14957
+ * Data is a plain number, or `{ value, target?, previous?, history?, values? }`;
14958
+ * options of the same names win over the data's. `values` is for gauges that
14959
+ * show several (one ring each): numbers, or `{ value, label?, color?, min?, max? }`;
14960
+ * an array as the data is read as `values`.
14961
+ */
14962
+ class GaugeValue {
14963
+ constructor({ value = 0, target = null, previous = null, history = [], values = [] } = {}) {
14964
+ this.value = Number(value) || 0;
14965
+ this.target = target == null ? null : Number(target);
14966
+ this.history = (history ?? []).map(Number).filter(Number.isFinite);
14967
+ // The value before this one: given, or the one before the last in the history.
14968
+ this.previous = previous != null ? Number(previous) : (this.history.length > 1 ? this.history.at(-2) : null);
14969
+ this.values = (values ?? []).map(v => (typeof v === 'object' && v !== null ? { ...v, value: Number(v.value) || 0 } : { value: Number(v) || 0 }));
14970
+ }
14971
+
14972
+ /** The change from the previous value, or null without one. */
14973
+ get change() { return this.previous == null ? null : this.value - this.previous; }
14974
+
14975
+ /** The change as a fraction of the previous value (0.1 is up 10%), or null. */
14976
+ get changeRatio() { return this.previous ? this.change / Math.abs(this.previous) : null; }
14977
+
14978
+ /** As gauge data again (`{ value, target, previous, history, values }`). */
14979
+ toData() {
14980
+ const { value, target, previous, history, values } = this;
14981
+ return { value, target, previous, history: [...history], values: values.map(v => ({ ...v })) };
14982
+ }
14983
+
14984
+ /**
14985
+ * The data part of the way (t, 0-1) from one value to another: the value,
14986
+ * the target and each of several values in between (one that is new
14987
+ * starts from the first value); the rest as `to` has them.
14988
+ */
14989
+ static between(from, to, t) {
14990
+ const mix = (a, b) => (a == null || b == null ? b : a + (b - a) * t);
14991
+ const data = to.toData();
14992
+ data.value = mix(from.value, to.value);
14993
+ data.target = mix(from.target, to.target);
14994
+ data.values = to.values.map((v, i) => ({ ...v, value: mix(from.values[i]?.value ?? from.value, v.value) }));
14995
+ return data;
14996
+ }
14997
+
14998
+ static from(data, options = {}) {
14999
+ const given = Array.isArray(data) ? { values: data } : (typeof data === 'object' && data !== null ? data : { value: data });
15000
+ const pick = (key) => options[key] ?? given[key];
15001
+ const values = pick('values') ?? [];
15002
+ const history = pick('history') ?? [];
15003
+ // Without a value: the first of several, else the last of the history.
15004
+ const first = values.length ? (typeof values[0] === 'object' ? values[0].value : values[0]) : history.at(-1);
15005
+ return new GaugeValue({
15006
+ value: given.value ?? first ?? 0,
15007
+ target: pick('target'),
15008
+ previous: pick('previous'),
15009
+ history,
15010
+ values,
15011
+ });
15012
+ }
15013
+ }
15014
+
15015
+ /**
15016
+ * GaugeChart — abstract base for gauges: one value shown along a track.
15017
+ *
15018
+ * It reads what every gauge has in common from its data and options (a
15019
+ * `GaugeScale`, `GaugeBands`, a `GaugeValue`) and draws the parts every gauge
15020
+ * has on the subclass's `track()`: the background or the bands, the value, a
15021
+ * target marker, ticks and the labels. A subclass picks the track and its
15022
+ * layout, and adds its own parts (the dial's needle).
15023
+ *
15024
+ * Options every gauge takes:
15025
+ * min, max (0, 100), format, units — the scale (see GaugeScale)
15026
+ * bands: [{to, color?, label?}] — ranges drawn behind the value; the value takes its band's colour
15027
+ * bandColors: string[] — colours for bands without their own
15028
+ * color — the value's colour: one, a gradient array, or (percent) => colour
15029
+ * target — a marker at a value (or data.target)
15030
+ * targetColor ('#333')
15031
+ * ticks: number — that many equal steps, marked and labelled
15032
+ * segments: number, segmentGap (2) — the track as that many blocks (LED style), lit up to the value;
15033
+ * with bands, each lit block takes its own band's colour
15034
+ * title
14772
15035
  */
14773
15036
  class GaugeChart extends Chart {
14774
- /** Normalize data to a 0-100 percentage. */
14775
- _normalizePercent() {
14776
- const value = Number(this.data);
14777
- return Math.min(Math.max(value, 0), 100);
15037
+ /** The value's colour when neither `color` nor a band gives one. */
15038
+ static defaultColor = '#5352ed';
15039
+
15040
+ /** The track behind the value when there are no bands. */
15041
+ static trackColor = '#f1f2f6';
15042
+
15043
+ /** Where and how big this type draws when the options don't say (cx, cy, r, ...). */
15044
+ static LAYOUT = {};
15045
+
15046
+ /** The layout: the options' values for this type's LAYOUT keys, over its own. */
15047
+ get layout() {
15048
+ const layout = { ...this.constructor.LAYOUT };
15049
+ for (const key of Object.keys(layout)) if (this.options[key] != null) layout[key] = this.options[key];
15050
+ return layout;
14778
15051
  }
14779
15052
 
14780
- /** Resolve the gauge color for the current percentage. */
14781
- _resolveColor(color, percent) {
14782
- return Gauge.resolveColor(color, percent);
15053
+ get scale() {
15054
+ const { min, max, format, units } = this.options;
15055
+ return (this._scale ??= new GaugeScale({ min, max, format: format ?? this.defaultFormat(), units }));
15056
+ }
15057
+
15058
+ get bands() {
15059
+ // `ranges` and `rangeColors`: the bullet graph's older names for them.
15060
+ const { bands, ranges, bandColors = this.options.rangeColors } = this.options;
15061
+ return (this._bands ??= new GaugeBands(bands ?? ranges ?? this.defaultBands(), this.scale, bandColors ? { colors: bandColors } : {}));
15062
+ }
15063
+
15064
+ get gaugeValue() { return (this._value ??= GaugeValue.from(this.data, this.options)); }
15065
+
15066
+ /** The value, held to the scale. */
15067
+ get value() { return this.scale.clamp(this.gaugeValue.value); }
15068
+
15069
+ /** A format this gauge type reads its numbers with when none is given (null: the scale's). */
15070
+ defaultFormat() { return null; }
15071
+
15072
+ /** Bands this gauge type draws when none are given. */
15073
+ defaultBands() { return []; }
15074
+
15075
+ /** The track the value moves along. Subclasses implement. @returns {import('../gauges/GaugeTrack.js').GaugeTrack} */
15076
+ track() { throw new Error(`${this.constructor.name} must implement track()`); }
15077
+
15078
+ /** The value's colour: the `color` option, else its band's, else the type's own. */
15079
+ valueColor(value = this.value) {
15080
+ const { color } = this.options;
15081
+ if (color != null) return Gauge.resolveColor(color, this.scale.fraction(value) * 100);
15082
+ return this.bands.at(value)?.color ?? this.constructor.defaultColor;
15083
+ }
15084
+
15085
+ /**
15086
+ * With `segments`, the track as that many blocks a `segmentGap` (pixels)
15087
+ * apart: [{from, to, mid}] as fractions; null without (fewer than 2).
15088
+ */
15089
+ segmentsOf(track) {
15090
+ const n = Math.round(this.options.segments ?? 0);
15091
+ if (n < 2) return null;
15092
+ const gap = Math.min((this.options.segmentGap ?? 2) / (track.length || 1), 0.5 / n);
15093
+ return Array.from({ length: n }, (_, i) => ({ from: i / n + gap / 2, to: (i + 1) / n - gap / 2, mid: (i + 0.5) / n }));
15094
+ }
15095
+
15096
+ /** The bands, lighter, along the whole track; or the plain track without any. */
15097
+ drawBackground(track, options = {}) {
15098
+ const blocks = this.segmentsOf(track);
15099
+ if (blocks) {
15100
+ for (const block of blocks) {
15101
+ const band = this.bands.at(this.scale.valueAt(block.mid));
15102
+ track.draw(this.renderer, block.from, block.to, band ? GaugeBands.tint(band.color) : this.constructor.trackColor, { ...options, cap: undefined });
15103
+ }
15104
+ return;
15105
+ }
15106
+ if (!this.bands.length) {
15107
+ track.draw(this.renderer, 0, 1, this.constructor.trackColor, options);
15108
+ return;
15109
+ }
15110
+ for (const band of this.bands.list) {
15111
+ track.draw(this.renderer, this.scale.fraction(band.from), this.scale.fraction(band.to), GaugeBands.tint(band.color), { ...options, cap: undefined });
15112
+ }
15113
+ }
15114
+
15115
+ /**
15116
+ * A value: the track from the scale's start to it. This gauge's value and
15117
+ * scale unless given (a gauge of several values draws each on its own).
15118
+ */
15119
+ drawValue(track, { value = this.value, scale = this.scale, color, ...options } = {}) {
15120
+ const blocks = this.segmentsOf(track);
15121
+ if (!blocks) {
15122
+ track.draw(this.renderer, 0, scale.fraction(value), color ?? this.valueColor(value), options);
15123
+ return;
15124
+ }
15125
+ // The blocks up to the value are lit: each in its band's colour (a level meter), unless a colour is given.
15126
+ const lit = Math.round(scale.fraction(value) * blocks.length);
15127
+ const own = color ?? (this.options.color != null || !this.bands.length ? this.valueColor(value) : null);
15128
+ for (const block of blocks.slice(0, lit)) {
15129
+ const blockColor = own ?? this.bands.at(scale.valueAt(block.mid))?.color ?? this.constructor.defaultColor;
15130
+ track.draw(this.renderer, block.from, block.to, blockColor, { ...options, cap: undefined });
15131
+ }
15132
+ }
15133
+
15134
+ /** A line across the track at the target, a little past both edges. */
15135
+ drawTarget(track, { overhang = 3, width = 2 } = {}) {
15136
+ const { target } = this.gaugeValue;
15137
+ if (target == null) return;
15138
+ const half = track.thickness / 2 + overhang;
15139
+ track.line(this.renderer, this.scale.fraction(target), -half, half, { color: this.options.targetColor ?? '#333', width });
15140
+ }
15141
+
15142
+ /** `ticks` marks outside the track, each labelled with its value. */
15143
+ drawTicks(track) {
15144
+ const { ticks } = this.options;
15145
+ if (!ticks) return;
15146
+ const count = Math.max(1, Math.round(ticks));
15147
+ const edge = track.thickness / 2;
15148
+ for (const value of this.scale.ticks(count)) {
15149
+ const f = this.scale.fraction(value);
15150
+ track.line(this.renderer, f, edge + 2, edge + 6, { color: '#999', width: 1 });
15151
+ const at = track.point(f, edge + 14);
15152
+ this.renderer.text(this.scale.tickLabel(value, count), at.x, at.y, {
15153
+ textAnchor: track.anchorAt(f), verticalAlign: 'middle', fontSize: '10px', fill: '#747d8c',
15154
+ });
15155
+ }
14783
15156
  }
14784
15157
 
14785
15158
  /** Draw a gauge title. */
@@ -14793,9 +15166,9 @@
14793
15166
  });
14794
15167
  }
14795
15168
 
14796
- /** Draw a centered percentage label. */
14797
- _renderPercentageLabel(percent, x, y, options = {}) {
14798
- this.renderer.text(`${percent.toFixed(1)}%`, x, y, {
15169
+ /** Draw the value's label, centred at a point. */
15170
+ _renderValueLabel(x, y, options = {}) {
15171
+ this.renderer.text(this.scale.label(this.value), x, y, {
14799
15172
  textAnchor: options.textAnchor || 'middle',
14800
15173
  verticalAlign: options.verticalAlign || 'middle',
14801
15174
  fontSize: options.fontSize || '12px',
@@ -14810,22 +15183,143 @@
14810
15183
  }
14811
15184
 
14812
15185
  /**
14813
- * BarGauge — a simple horizontal percentage bar.
15186
+ * GaugeTrack — the path a gauge's value moves along, as fractions 0-1.
15187
+ *
15188
+ * A subclass is one geometry (a straight bar, an arc). The gauge asks it for
15189
+ * points and draws segments on it, so bands, the value, ticks and a target
15190
+ * marker are drawn the same way on every gauge whatever its shape.
15191
+ *
15192
+ * A track has a `length` (pixels along it) as well as its `thickness`.
15193
+ * Offsets are across the track from its centre line: positive is outward
15194
+ * (below a horizontal bar, right of a vertical one, away from an arc's centre).
15195
+ */
15196
+ class GaugeTrack {
15197
+ /** @param {number} thickness - The track's width across */
15198
+ constructor(thickness) {
15199
+ if (new.target === GaugeTrack) throw new TypeError('GaugeTrack is abstract — subclass it');
15200
+ this.thickness = thickness;
15201
+ }
15202
+
15203
+ /** The point at a fraction along, `offset` across. @returns {{x:number, y:number}} */
15204
+ point(fraction, offset = 0) { throw new Error(`${this.constructor.name} must implement point()`); }
15205
+
15206
+ /**
15207
+ * Draw the track from one fraction to another, in a colour.
15208
+ * @param {Object} renderer
15209
+ * @param {number} from - 0-1
15210
+ * @param {number} to - 0-1
15211
+ * @param {string} color
15212
+ * @param {Object} [options] - `thickness` (this track's by default), `cap` ('round' ends)
15213
+ */
15214
+ draw(renderer, from, to, color, options = {}) { throw new Error(`${this.constructor.name} must implement draw()`); }
15215
+
15216
+ /** Where a label sits in the middle of the gauge. @returns {{x:number, y:number}} */
15217
+ get center() { throw new Error(`${this.constructor.name} must implement center`); }
15218
+
15219
+ /** A straight line across the track at a fraction, between two offsets (a tick, a target). */
15220
+ line(renderer, fraction, fromOffset, toOffset, stroke) {
15221
+ const a = this.point(fraction, fromOffset);
15222
+ const b = this.point(fraction, toOffset);
15223
+ renderer.path(`M${a.x},${a.y} L${b.x},${b.y}`, 'none', stroke);
15224
+ }
15225
+
15226
+ /** How a label at a point outside the track is anchored ('start', 'middle' or 'end'). */
15227
+ anchorAt(fraction) { return 'middle'; }
15228
+ }
15229
+
15230
+ /**
15231
+ * LinearTrack — a straight gauge track: a box `length` long and `thickness`
15232
+ * across, its segments drawn as rectangles. A subclass gives the direction.
15233
+ */
15234
+ class LinearTrack extends GaugeTrack {
15235
+ /**
15236
+ * @param {Object} options
15237
+ * @param {number} options.x - The box's left
15238
+ * @param {number} options.y - The box's top
15239
+ * @param {number} options.length
15240
+ * @param {number} options.thickness
15241
+ * @param {number} [options.radius=0] - Corner radius of the drawn segments
15242
+ */
15243
+ constructor({ x, y, length, thickness, radius = 0 }) {
15244
+ super(thickness);
15245
+ if (new.target === LinearTrack) throw new TypeError('LinearTrack is abstract — use HorizontalTrack or VerticalTrack');
15246
+ Object.assign(this, { x, y, length, radius });
15247
+ }
15248
+
15249
+ /** The rectangle covering from one fraction to another, `thickness` across and centred. */
15250
+ rectangle(from, to, thickness) { throw new Error(`${this.constructor.name} must implement rectangle()`); }
15251
+
15252
+ draw(renderer, from, to, color, { thickness = this.thickness } = {}) {
15253
+ const r = this.rectangle(Math.min(from, to), Math.max(from, to), thickness);
15254
+ renderer.rect(r.x, r.y, r.width, r.height, color, null, this.radius ? { rx: this.radius, ry: this.radius } : {});
15255
+ }
15256
+ }
15257
+
15258
+ /** A track left to right. */
15259
+ class HorizontalTrack extends LinearTrack {
15260
+ point(fraction, offset = 0) {
15261
+ return { x: this.x + fraction * this.length, y: this.y + this.thickness / 2 + offset };
15262
+ }
15263
+
15264
+ rectangle(from, to, thickness) {
15265
+ return { x: this.x + from * this.length, y: this.y + (this.thickness - thickness) / 2, width: (to - from) * this.length, height: thickness };
15266
+ }
15267
+
15268
+ get center() { return this.point(0.5); }
15269
+ }
15270
+
15271
+ /** A track bottom to top. */
15272
+ class VerticalTrack extends LinearTrack {
15273
+ point(fraction, offset = 0) {
15274
+ return { x: this.x + this.thickness / 2 + offset, y: this.y + this.length - fraction * this.length };
15275
+ }
15276
+
15277
+ rectangle(from, to, thickness) {
15278
+ return { x: this.x + (this.thickness - thickness) / 2, y: this.y + this.length - to * this.length, width: thickness, height: (to - from) * this.length };
15279
+ }
15280
+
15281
+ get center() { return this.point(0.5); }
15282
+
15283
+ /** Labels beside a vertical track start at their point. */
15284
+ anchorAt() { return 'start'; }
15285
+ }
15286
+
15287
+ /**
15288
+ * BarGauge — a straight bar filled to the value, left to right or (with
15289
+ * `orientation: 'vertical'`) bottom to top.
15290
+ *
15291
+ * Options: x, y, width, height (the bar's box; 200 × 30 across, 30 × 200 up),
15292
+ * orientation, and every GaugeChart option (min, max, bands, target, ticks, ...).
14814
15293
  */
14815
15294
  class BarGauge extends GaugeChart {
14816
15295
  static chartType = 'barGauge';
14817
15296
 
14818
- _render() {
14819
- const options = this.options;
14820
- const { x = 10, y = 10, width = 200, height = 30, color = '#5352ed', title } = options;
14821
- const p = this._normalizePercent();
14822
- const barWidth = (p / 100) * width;
14823
- const resolvedColor = this._resolveColor(color, p);
15297
+ get vertical() { return this.options.orientation === 'vertical'; }
15298
+
15299
+ track() {
15300
+ const { x = 10, y = 10 } = this.options;
15301
+ const { width = this.vertical ? 30 : 200, height = this.vertical ? 200 : 30 } = this.options;
15302
+ return this.vertical
15303
+ ? new VerticalTrack({ x, y, length: height, thickness: width, radius: 4 })
15304
+ : new HorizontalTrack({ x, y, length: width, thickness: height, radius: 4 });
15305
+ }
14824
15306
 
14825
- this._renderTitle(title, x, y - 5);
14826
- this.renderer.rect(x, y, width, height, '#f1f2f6', null, { rx: 4, ry: 4 });
14827
- this.renderer.rect(x, y, barWidth, height, resolvedColor, null, { rx: 4, ry: 4 });
14828
- this._renderPercentageLabel(p, x + width / 2, y + height / 2, { fontWeight: 'bold' });
15307
+ _render() {
15308
+ const { x = 10, y = 10, title } = this.options;
15309
+ const track = this.track();
15310
+ this._renderTitle(title, x, y - (this.vertical ? 8 : 5));
15311
+ this.drawBackground(track);
15312
+ this.drawValue(track);
15313
+ this.drawTarget(track);
15314
+ this.drawTicks(track);
15315
+ if (this.vertical) {
15316
+ // A vertical bar is too narrow for its label: it goes underneath.
15317
+ const below = track.point(0, 0);
15318
+ this._renderValueLabel(below.x, below.y + 14, { fontWeight: 'bold', fill: '#2f3542' });
15319
+ } else {
15320
+ const { x: cx, y: cy } = track.center;
15321
+ this._renderValueLabel(cx, cy, { fontWeight: 'bold' });
15322
+ }
14829
15323
  }
14830
15324
  }
14831
15325
 
@@ -14836,82 +15330,77 @@
14836
15330
  * Data may be a plain number (the actual value) or an object
14837
15331
  * `{ value: number, target?: number, ranges?: Array<{max:number, label?, color?}> }`.
14838
15332
  *
14839
- * Options:
14840
- * target?: number — target value (overrides data.target)
14841
- * ranges?: Range[] — qualitative ranges (overrides data.ranges)
14842
- * min?: number = 0 — scale minimum
14843
- * max?: number = 100 — scale maximum
15333
+ * Options (with every GaugeChart option: min, max, bands, target, ticks, ...):
15334
+ * ranges?: Range[] — the bands, by their older name (overrides data.ranges)
14844
15335
  * barColor?: string — color of the actual-value bar
14845
15336
  * targetColor?: string = #333 — color of the target marker
14846
15337
  * rangeColors?: string[] — fallback palette for ranges without their own color
14847
- * title?: string
14848
15338
  * showLabels?: boolean = true — value/target/range labels
14849
15339
  */
14850
- class BulletGauge extends Chart {
15340
+ class BulletGauge extends GaugeChart {
14851
15341
  static chartType = 'bullet';
15342
+ static defaultColor = '#5470c6';
14852
15343
 
14853
- _render() {
14854
- const data = this.data;
14855
- const options = this.options;
14856
- const box = ChartBox.fromRenderer(this.renderer);
14857
- const {
14858
- x = box.x, y = box.y, width = box.width, height = box.height * 0.4,
14859
- min = 0, max = 100,
14860
- target: optTarget, targetColor = '#333',
14861
- barColor = '#5470c6',
14862
- ranges: optRanges, rangeColors = ['#ff6b6b', '#ffd93d', '#6bcb77'],
14863
- title, showLabels = true,
14864
- } = options;
15344
+ /** Its numbers as they are ("65", not "65.0%"). */
15345
+ defaultFormat() { return (v) => String(v); }
14865
15346
 
14866
- const actual = typeof data === 'number' ? data : (data?.value ?? 0);
14867
- const target = optTarget != null ? optTarget : data?.target ?? null;
14868
- const ranges = optRanges ?? data?.ranges ?? [
15347
+ /** The data's ranges, else poor / satisfactory / good at 60% and 80% of the scale. */
15348
+ defaultBands() {
15349
+ const max = this.options.max ?? 100;
15350
+ return this.data?.bands ?? this.data?.ranges ?? [
14869
15351
  { max: max * 0.6, label: 'Poor' },
14870
15352
  { max: max * 0.8, label: 'Satisfactory' },
14871
- { max: max, label: 'Good' },
15353
+ { max, label: 'Good' },
14872
15354
  ];
15355
+ }
15356
+
15357
+ /** The bar keeps one colour (`barColor`): the bands behind it are the comparison. */
15358
+ valueColor(value) {
15359
+ const { barColor, color } = this.options;
15360
+ return barColor ?? (color != null ? super.valueColor(value) : this.constructor.defaultColor);
15361
+ }
14873
15362
 
14874
- const span = (max - min) || 1;
14875
- const toX = (v) => x + Math.min(Math.max((v - min) / span, 0), 1) * width;
15363
+ get box() {
15364
+ const box = ChartBox.fromRenderer(this.renderer);
15365
+ const { x = box.x, y = box.y, width = box.width, height = box.height * 0.4 } = this.options;
15366
+ return { x, y, width, height };
15367
+ }
15368
+
15369
+ track() {
15370
+ const { x, y, width, height } = this.box;
15371
+ return new HorizontalTrack({ x, y, length: width, thickness: height });
15372
+ }
15373
+
15374
+ _render() {
15375
+ const { title, showLabels = true, ticks } = this.options;
15376
+ const { x, y, width, height } = this.box;
15377
+ const track = this.track();
14876
15378
 
14877
15379
  new ChartLabels({ title }).draw(this.renderer, { x, y, width, height });
14878
15380
 
14879
- // Draw qualitative range bands
14880
- let rangeStart = min;
14881
- ranges.forEach((range, i) => {
14882
- const rangeEnd = Math.min(Math.max(range.max, min), max);
14883
- const rx = toX(rangeStart);
14884
- const rw = toX(rangeEnd) - rx;
14885
- const color = range.color ?? rangeColors[i % rangeColors.length];
14886
- this.renderer.rect(rx, y, rw, height, color, null, { opacity: 0.35 });
14887
- if (showLabels && range.label) {
14888
- this.renderer.text(range.label, rx + rw / 2, y + height + 14, {
14889
- textAnchor: 'middle', fontSize: '10px', fill: '#666',
14890
- });
15381
+ this.drawBackground(track);
15382
+ if (showLabels) {
15383
+ const below = y + height + (ticks ? 28 : 14);
15384
+ for (const band of this.bands.list) {
15385
+ if (!band.label) continue;
15386
+ const mid = track.point((this.scale.fraction(band.from) + this.scale.fraction(band.to)) / 2);
15387
+ this.renderer.text(band.label, mid.x, below, { textAnchor: 'middle', fontSize: '10px', fill: '#666' });
14891
15388
  }
14892
- rangeStart = rangeEnd;
14893
- });
15389
+ }
14894
15390
 
14895
- // Draw actual-value bar (thinner, centered vertically)
15391
+ // The actual value: a bar half as thick, centred on the bands.
14896
15392
  const barHeight = height * 0.5;
14897
- const barY = y + (height - barHeight) / 2;
14898
- const actualX = toX(min);
14899
- const actualW = toX(actual) - actualX;
14900
- this.renderer.rect(actualX, barY, actualW, barHeight, barColor, null, {});
14901
-
14902
- // Draw target marker (vertical line)
14903
- if (target != null) {
14904
- const tx = toX(target);
14905
- this.renderer.path(`M${tx},${y - 2} L${tx},${y + height + 2}`, 'none', { color: targetColor, width: 2 });
14906
- }
15393
+ this.drawValue(track, { thickness: barHeight });
15394
+ this.drawTarget(track, { overhang: 2 });
15395
+ this.drawTicks(track);
14907
15396
 
14908
15397
  if (showLabels) {
14909
- this.renderer.text(String(actual), x + width / 2, barY + barHeight / 2, {
14910
- textAnchor: 'middle', verticalAlign: 'middle', fontSize: '11px', fill: '#fff', fontWeight: 'bold',
14911
- });
15398
+ const { x: cx, y: cy } = track.center;
15399
+ this._renderValueLabel(cx, cy, { fontSize: '11px', fontWeight: 'bold' });
15400
+ const { target } = this.gaugeValue;
14912
15401
  if (target != null) {
14913
- this.renderer.text(String(target), toX(target) + 5, y - 8, {
14914
- textAnchor: 'start', fontSize: '10px', fill: targetColor,
15402
+ this.renderer.text(this.scale.label(target), track.point(this.scale.fraction(target)).x + 5, y - 8, {
15403
+ textAnchor: 'start', fontSize: '10px', fill: this.options.targetColor ?? '#333',
14915
15404
  });
14916
15405
  }
14917
15406
  }
@@ -14919,38 +15408,445 @@
14919
15408
  }
14920
15409
 
14921
15410
  /**
14922
- * DialGauge — a semicircular percentage gauge with a needle.
15411
+ * ArcTrack — a gauge track along a circle's arc: the dial's 270°, a half
15412
+ * circle, a full ring. Angles are degrees clockwise from 3 o'clock (the
15413
+ * screen's own direction), so 135° with a 270° sweep is the dial's
15414
+ * bottom-left to bottom-right, and 180° with 180° is a half circle over the top.
15415
+ */
15416
+ class ArcTrack extends GaugeTrack {
15417
+ /**
15418
+ * @param {Object} options
15419
+ * @param {number} options.cx
15420
+ * @param {number} options.cy
15421
+ * @param {number} options.r - The radius of the track's centre line
15422
+ * @param {number} options.thickness
15423
+ * @param {number} [options.startAngle=135]
15424
+ * @param {number} [options.sweep=270]
15425
+ */
15426
+ constructor({ cx, cy, r, thickness, startAngle = 135, sweep = 270 }) {
15427
+ super(thickness);
15428
+ Object.assign(this, { cx, cy, r, startAngle, sweep });
15429
+ }
15430
+
15431
+ /** The arc's length along its centre line, in pixels. */
15432
+ get length() { return this.r * Math.abs(this.sweep) * Math.PI / 180; }
15433
+
15434
+ /** The angle in degrees at a fraction along. */
15435
+ angleAt(fraction) { return this.startAngle + fraction * this.sweep; }
15436
+
15437
+ point(fraction, offset = 0) {
15438
+ const a = this.angleAt(fraction) * Math.PI / 180;
15439
+ const r = this.r + offset;
15440
+ return { x: this.cx + r * Math.cos(a), y: this.cy + r * Math.sin(a) };
15441
+ }
15442
+
15443
+ draw(renderer, from, to, color, { thickness = this.thickness, cap } = {}) {
15444
+ const rad = Math.PI / 180;
15445
+ const a0 = this.angleAt(Math.min(from, to)) * rad;
15446
+ const a1 = this.angleAt(Math.max(from, to)) * rad;
15447
+ renderer.path(Gauge.arcPath(this.cx, this.cy, this.r, a0, a1), 'none', { color, width: thickness, ...(cap ? { cap } : {}) });
15448
+ }
15449
+
15450
+ get center() { return { x: this.cx, y: this.cy }; }
15451
+
15452
+ /** A label outside the arc leans away from the centre: 'start' on the right, 'end' on the left. */
15453
+ anchorAt(fraction) {
15454
+ const dx = Math.cos(this.angleAt(fraction) * Math.PI / 180);
15455
+ return dx > 0.3 ? 'start' : dx < -0.3 ? 'end' : 'middle';
15456
+ }
15457
+ }
15458
+
15459
+ /**
15460
+ * DialGauge — an arc filled to the value, with a needle pointing at it.
15461
+ *
15462
+ * Data: a value, or several (`[{ value, label?, color? }]`, e.g. actual and
15463
+ * budget): a needle each, the first also filling the arc and labelled in
15464
+ * the middle, and a legend of them all under the title.
15465
+ * Options: cx, cy, r (100, 80, 60), thickness (12), startAngle and sweep in
15466
+ * degrees (135 and 270: bottom-left round to bottom-right), needleColors,
15467
+ * and every GaugeChart option (min, max, bands, target, ticks, segments, ...).
14923
15468
  */
14924
15469
  class DialGauge extends GaugeChart {
14925
15470
  static chartType = 'dialGauge';
15471
+ static LAYOUT = { cx: 100, cy: 80, r: 60, thickness: 12, startAngle: 135, sweep: 270 };
15472
+ static NEEDLE_COLORS = ['#2f3542', '#ff4757', '#1e90ff', '#ffa502'];
14926
15473
 
14927
- _render() {
14928
- const options = this.options;
14929
- const { cx = 100, cy = 80, r = 60, color = '#5352ed', title } = options;
14930
- const p = this._normalizePercent();
14931
- const resolvedColor = this._resolveColor(color, p);
14932
- const totalAngle = 270;
14933
- const startAngle = 135;
14934
- const endAngle = startAngle + totalAngle;
14935
- const needleAngle = startAngle + (p / 100) * totalAngle;
14936
- const rad = Math.PI / 180;
15474
+ track() { return new ArcTrack(this.layout); }
14937
15475
 
14938
- // Background Arc
14939
- this.renderer.path(Gauge.arcPath(cx, cy, r, startAngle * rad, endAngle * rad), 'none', { color: '#f1f2f6', width: 12, cap: 'round' });
14940
- // Progress Arc
14941
- this.renderer.path(Gauge.arcPath(cx, cy, r, startAngle * rad, needleAngle * rad), 'none', { color: resolvedColor, width: 12, cap: 'round' });
15476
+ /** One per value, the first the main one: {value, label, color}. */
15477
+ get needles() {
15478
+ const { needleColors = DialGauge.NEEDLE_COLORS } = this.options;
15479
+ const values = this.gaugeValue.values.length ? this.gaugeValue.values : [{ value: this.gaugeValue.value }];
15480
+ return values.map((v, i) => ({ ...v, value: this.scale.clamp(v.value), color: v.color ?? needleColors[i % needleColors.length] }));
15481
+ }
14942
15482
 
14943
- // Needle
14944
- const needleRad = needleAngle * rad;
14945
- const nx = cx + (r - 8) * Math.cos(needleRad);
14946
- const ny = cy + (r - 8) * Math.sin(needleRad);
14947
- this.renderer.path(`M${cx},${cy} L${nx},${ny}`, 'none', { color: '#2f3542', width: 3, cap: 'round' });
14948
- this.renderer.circle(cx, cy, 5, '#2f3542');
15483
+ _render() {
15484
+ const { title } = this.options;
15485
+ const track = this.track();
15486
+ const { cx, cy, r } = track;
15487
+
15488
+ this.drawBackground(track, { cap: 'round' });
15489
+ this.drawValue(track, { cap: 'round' });
15490
+ this.drawTarget(track);
15491
+ this.drawTicks(track);
15492
+ const needles = this.needles;
15493
+ // The main needle last, so it is on top.
15494
+ for (const needle of [...needles].reverse()) this.drawNeedle(track, needle);
15495
+ this.renderer.circle(cx, cy, 5, needles[0].color);
14949
15496
 
14950
- // Text
14951
15497
  this._renderTitle(title, cx, cy + r + 25, { textAnchor: 'middle', fill: '#747d8c', textTransform: 'uppercase' });
14952
- this._renderPercentageLabel(p, cx, cy + r - 15, { fontSize: '18px', fill: '#2f3542', fontWeight: 'bold' });
15498
+ this._renderValueLabel(cx, cy + r - 15, { fontSize: '18px', fill: '#2f3542', fontWeight: 'bold' });
15499
+ if (needles.length > 1) this.drawLegend(needles, cx, cy + r + (title ? 42 : 25));
15500
+ }
15501
+
15502
+ /** A needle from the centre to just inside the arc. */
15503
+ drawNeedle(track, { value, color }) {
15504
+ const tip = track.point(this.scale.fraction(value), -8);
15505
+ this.renderer.path(`M${track.cx},${track.cy} L${tip.x},${tip.y}`, 'none', { color, width: 3, cap: 'round' });
15506
+ }
15507
+
15508
+ /** Each needle's label and value on one line, centred. */
15509
+ drawLegend(needles, cx, y) {
15510
+ const text = needles.map((n, i) => `${n.label ?? `#${i + 1}`} ${this.scale.label(n.value)}`);
15511
+ const font = { fontSize: '11px' };
15512
+ const width = (t) => (this.renderer.measureText?.(t, font) ?? t.length * 6) + 22;
15513
+ let x = cx - text.reduce((sum, t) => sum + width(t), 0) / 2;
15514
+ needles.forEach((needle, i) => {
15515
+ this.renderer.circle(x + 4, y, 4, needle.color);
15516
+ this.renderer.text(text[i], x + 12, y, { ...font, textAnchor: 'start', verticalAlign: 'middle', fill: '#2f3542' });
15517
+ x += width(text[i]);
15518
+ });
15519
+ }
15520
+ }
15521
+
15522
+ /**
15523
+ * ArcGauge — an arc filled to the value, its number in the middle: a half
15524
+ * circle by default, or any start angle and sweep.
15525
+ *
15526
+ * Options: cx, cy, r (100, 100, 70), thickness (16), startAngle and sweep in
15527
+ * degrees (180 and 180: a half circle over the top), cap ('round' ends), and
15528
+ * every GaugeChart option (min, max, bands, target, ticks, ...). Without
15529
+ * ticks the scale's two ends are labelled.
15530
+ */
15531
+ class ArcGauge extends GaugeChart {
15532
+ static chartType = 'arcGauge';
15533
+
15534
+ static LAYOUT = { cx: 100, cy: 100, r: 70, thickness: 16, startAngle: 180, sweep: 180 };
15535
+
15536
+ track() { return new ArcTrack(this.layout); }
15537
+
15538
+ _render() {
15539
+ const { title, cap } = this.options;
15540
+ const track = this.track();
15541
+ const capOption = cap ? { cap } : {};
15542
+ this.drawBackground(track, capOption);
15543
+ this.drawValue(track, capOption);
15544
+ this.drawTarget(track);
15545
+ if (this.options.ticks) this.drawTicks(track);
15546
+ else this.drawEnds(track);
15547
+ this.drawLabels(track);
15548
+ this._renderTitle(title, track.cx, this.titleY(track), { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
15549
+ }
15550
+
15551
+ /** The value's number in the middle, a little above the centre for an open arc. */
15552
+ drawLabels(track) {
15553
+ const { cx, cy, r } = track;
15554
+ this._renderValueLabel(cx, cy - r * 0.15, { fontSize: `${Math.max(12, Math.round(r * 0.32))}px`, fill: '#2f3542', fontWeight: 'bold' });
15555
+ }
15556
+
15557
+ /** Below the arc's lowest point. */
15558
+ titleY(track) {
15559
+ const lowest = Math.max(track.point(0).y, track.point(1).y, track.cy);
15560
+ return lowest + track.thickness / 2 + 18;
15561
+ }
15562
+
15563
+ /** The scale's min and max under the arc's two ends. */
15564
+ drawEnds(track) {
15565
+ const below = track.thickness / 2 + 2;
15566
+ for (const f of [0, 1]) {
15567
+ const at = track.point(f);
15568
+ this.renderer.text(this.scale.tickLabel(this.scale.valueAt(f), 1), at.x, at.y + below + 8, {
15569
+ textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px', fill: '#747d8c',
15570
+ });
15571
+ }
15572
+ }
15573
+ }
15574
+
15575
+ /**
15576
+ * RingGauge — a progress ring: a whole circle filled clockwise from the top
15577
+ * to the value, its number in the middle.
15578
+ *
15579
+ * Options as ArcGauge's, drawn as a ring: cx, cy, r (100, 100, 60),
15580
+ * thickness (14), startAngle (-90, the top) and sweep (360).
15581
+ */
15582
+ class RingGauge extends ArcGauge {
15583
+ static chartType = 'ringGauge';
15584
+ static LAYOUT = { cx: 100, cy: 100, r: 60, thickness: 14, startAngle: -90, sweep: 360 };
15585
+
15586
+ /** The number in the very middle. */
15587
+ drawLabels(track) {
15588
+ this._renderValueLabel(track.cx, track.cy, { fontSize: `${Math.max(12, Math.round(track.r * 0.36))}px`, fill: '#2f3542', fontWeight: 'bold' });
15589
+ }
15590
+
15591
+ /** A ring's two ends are one point: no end labels. */
15592
+ drawEnds() {}
15593
+
15594
+ titleY(track) { return track.cy + track.r + track.thickness / 2 + 18; }
15595
+ }
15596
+
15597
+ /**
15598
+ * MultiRingGauge — several values as rings one inside the other ("activity
15599
+ * rings"), each filled to its value on its own scale, with a legend.
15600
+ *
15601
+ * Data: `[{ value, label?, color?, min?, max? }]` (or plain numbers), outer
15602
+ * ring first; each ring's min and max default to the options'.
15603
+ * Options: cx, cy, r (the outer ring: 100, 100, 70), thickness (12), gap (4),
15604
+ * startAngle (-90) and sweep (360), colors (a colour per ring without its
15605
+ * own), legend (true: each ring's label and value to the right), and the
15606
+ * GaugeChart options min, max, format, units.
15607
+ */
15608
+ class MultiRingGauge extends GaugeChart {
15609
+ static chartType = 'multiRingGauge';
15610
+ static LAYOUT = { cx: 100, cy: 100, r: 70, thickness: 12, gap: 4, startAngle: -90, sweep: 360 };
15611
+ static COLORS = ['#5352ed', '#2ed573', '#ffa502', '#ff4757', '#1e90ff'];
15612
+
15613
+ /** One per value: its track, scale and colour. */
15614
+ get rings() {
15615
+ const { cx, cy, r, thickness, gap, startAngle, sweep } = this.layout;
15616
+ const { min, max, format, units, colors = MultiRingGauge.COLORS } = this.options;
15617
+ return this.gaugeValue.values.map((v, i) => ({
15618
+ ...v,
15619
+ track: new ArcTrack({ cx, cy, r: r - i * (thickness + gap), thickness, startAngle, sweep }),
15620
+ scale: new GaugeScale({ min: v.min ?? min, max: v.max ?? max, format: format ?? null, units }),
15621
+ color: v.color ?? colors[i % colors.length],
15622
+ })).filter(ring => ring.track.r > ring.track.thickness / 2);
15623
+ }
15624
+
15625
+ _render() {
15626
+ const { title, legend = true } = this.options;
15627
+ const { cx, cy, r, thickness } = this.layout;
15628
+ const rings = this.rings;
15629
+ for (const ring of rings) {
15630
+ this.drawBackground(ring.track);
15631
+ this.drawValue(ring.track, { value: ring.scale.clamp(ring.value), scale: ring.scale, color: ring.color, cap: 'round' });
15632
+ }
15633
+ if (legend) {
15634
+ const x = cx + r + thickness / 2 + 16;
15635
+ const top = cy - ((rings.length - 1) * 18) / 2;
15636
+ rings.forEach((ring, i) => {
15637
+ const y = top + i * 18;
15638
+ this.renderer.circle(x + 4, y, 4, ring.color);
15639
+ this.renderer.text(`${ring.label ?? `#${i + 1}`} ${ring.scale.label(ring.scale.clamp(ring.value))}`, x + 14, y, {
15640
+ textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#2f3542',
15641
+ });
15642
+ });
15643
+ }
15644
+ this._renderTitle(title, cx, cy + r + thickness / 2 + 18, { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
15645
+ }
15646
+ }
15647
+
15648
+ /**
15649
+ * Sparkline — a small line of a series' recent values, fitted to a box:
15650
+ * the first value at the left, the last at the right, the lowest at the
15651
+ * bottom and the highest at the top.
15652
+ */
15653
+ class Sparkline {
15654
+ /**
15655
+ * @param {number[]} values - At least two to draw a line
15656
+ * @param {{x:number, y:number, width:number, height:number}} box
15657
+ */
15658
+ constructor(values, box) {
15659
+ this.values = values.map(Number).filter(Number.isFinite);
15660
+ this.box = box;
15661
+ }
15662
+
15663
+ /** Each value's point in the box (none for fewer than two values). */
15664
+ get points() {
15665
+ const { values, box } = this;
15666
+ if (values.length < 2) return [];
15667
+ const lo = Math.min(...values);
15668
+ const hi = Math.max(...values);
15669
+ const span = (hi - lo) || 1;
15670
+ // A flat series sits across the middle.
15671
+ const flat = hi === lo;
15672
+ return values.map((v, i) => ({
15673
+ x: box.x + (i / (values.length - 1)) * box.width,
15674
+ y: flat ? box.y + box.height / 2 : box.y + box.height - ((v - lo) / span) * box.height,
15675
+ }));
15676
+ }
15677
+
15678
+ /** The line as a path `d` (empty for fewer than two values). */
15679
+ get d() { return this.points.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' '); }
15680
+
15681
+ /** Draw the line and a dot on its last value. */
15682
+ draw(renderer, { color = '#5352ed', width = 1.5 } = {}) {
15683
+ const points = this.points;
15684
+ if (!points.length) return;
15685
+ renderer.path(this.d, 'none', { color, width });
15686
+ const last = points.at(-1);
15687
+ renderer.circle(last.x, last.y, 2.5, color);
15688
+ }
15689
+ }
15690
+
15691
+ /**
15692
+ * KpiTile — a key figure on a card: its title, its value large, the change
15693
+ * since the previous value (▲ / ▼, coloured good or bad), and a sparkline of
15694
+ * its history.
15695
+ *
15696
+ * Data: a number, or `{ value, previous?, history?, target? }` (`history` ends
15697
+ * with the current value; without `previous` the one before it is used).
15698
+ * Options: x, y, width, height (10, 10, 220, 120), format and units (a
15699
+ * number with thousands grouped by default), change ('percent', or 'value'
15700
+ * for the difference), upIsGood (true: up is green; false for costs, errors,
15701
+ * ...), bands (the value takes its band's colour), sparkColor, background,
15702
+ * and title.
15703
+ */
15704
+ class KpiTile extends GaugeChart {
15705
+ static chartType = 'kpiTile';
15706
+ static LAYOUT = { x: 10, y: 10, width: 220, height: 120 };
15707
+ static GOOD = '#2e9e5b';
15708
+ static BAD = '#d64545';
15709
+
15710
+ /** A figure reads as it is, its thousands grouped ("12,345"). */
15711
+ defaultFormat() { return { grouping: true }; }
15712
+
15713
+ /** The value as it is: a tile has no range to hold it to. */
15714
+ get value() { return this.gaugeValue.value; }
15715
+
15716
+ /** The value's colour: its band's when there are bands, else the text colour. */
15717
+ valueColor(value = this.value) {
15718
+ if (this.options.color != null || this.bands.length) return super.valueColor(value);
15719
+ return '#2f3542';
15720
+ }
15721
+
15722
+ /** The change line: "▲ 10.0%" or "▼ 3", and whether it is good; null without a previous value. */
15723
+ get changeText() {
15724
+ const { change, changeRatio } = this.gaugeValue;
15725
+ if (change == null) return null;
15726
+ const { change: as = 'percent', upIsGood = true } = this.options;
15727
+ const arrow = change > 0 ? '▲' : change < 0 ? '▼' : '■';
15728
+ const amount = as === 'value' || changeRatio == null
15729
+ ? this.scale.label(Math.abs(change))
15730
+ : `${(Math.abs(changeRatio) * 100).toFixed(1)}%`;
15731
+ const good = change === 0 ? null : (change > 0) === upIsGood;
15732
+ return { text: `${arrow} ${amount}`, good };
15733
+ }
15734
+
15735
+ _render() {
15736
+ const { x, y, width, height } = this.layout;
15737
+ const { title, background = '#ffffff', sparkColor = '#5352ed' } = this.options;
15738
+ const pad = 12;
15739
+ this.renderer.rect(x, y, width, height, background, { color: '#e1e4e8', width: 1 }, { rx: 8, ry: 8 });
15740
+ if (title) {
15741
+ this.renderer.text(title, x + pad, y + pad + 6, { textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#747d8c' });
15742
+ }
15743
+ const valueSize = Math.max(14, Math.round(height * 0.26));
15744
+ const valueY = y + pad + 18 + valueSize / 2;
15745
+ this.renderer.text(this.scale.label(this.value), x + pad, valueY, {
15746
+ textAnchor: 'start', verticalAlign: 'middle', fontSize: `${valueSize}px`, fontWeight: 'bold', fill: this.valueColor(),
15747
+ });
15748
+ const change = this.changeText;
15749
+ if (change) {
15750
+ const color = change.good == null ? '#747d8c' : change.good ? KpiTile.GOOD : KpiTile.BAD;
15751
+ this.renderer.text(change.text, x + width - pad, valueY, { textAnchor: 'end', verticalAlign: 'middle', fontSize: '12px', fontWeight: 'bold', fill: color });
15752
+ }
15753
+ const sparkTop = valueY + valueSize / 2 + 8;
15754
+ const sparkHeight = y + height - pad - sparkTop;
15755
+ if (sparkHeight > 4) {
15756
+ new Sparkline(this.gaugeValue.history, { x: x + pad, y: sparkTop, width: width - 2 * pad, height: sparkHeight }).draw(this.renderer, { color: sparkColor });
15757
+ }
15758
+ }
15759
+ }
15760
+
15761
+ /**
15762
+ * ThermometerGauge — a tube filled from a bulb at its foot up to the value.
15763
+ *
15764
+ * Options: x, y, width, height (the tube: 40, 20, 18, 200), bulb (its radius:
15765
+ * 16), and every GaugeChart option (min, max, units, bands, target, ticks,
15766
+ * segments, ...). The value's label sits under the bulb.
15767
+ */
15768
+ class ThermometerGauge extends GaugeChart {
15769
+ static chartType = 'thermometerGauge';
15770
+ static LAYOUT = { x: 40, y: 20, width: 18, height: 200, bulb: 16 };
15771
+ static defaultColor = '#ff4757';
15772
+
15773
+ track() {
15774
+ const { x, y, width, height } = this.layout;
15775
+ return new VerticalTrack({ x, y, length: height, thickness: width, radius: width / 2 });
15776
+ }
15777
+
15778
+ /** The bulb's centre: under the tube, overlapping its foot so the two join. */
15779
+ bulbCenter(track) {
15780
+ const { bulb } = this.layout;
15781
+ const foot = track.point(0);
15782
+ return { x: foot.x, y: foot.y + bulb * 0.75 };
15783
+ }
15784
+
15785
+ _render() {
15786
+ const { title } = this.options;
15787
+ const { bulb } = this.layout;
15788
+ const track = this.track();
15789
+ const centre = this.bulbCenter(track);
15790
+ this._renderTitle(title, centre.x, track.y - 10, { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
15791
+ this.drawBackground(track);
15792
+ this.drawValue(track);
15793
+ // The bulb is always full: the value's colour, round a lighter ring.
15794
+ this.renderer.circle(centre.x, centre.y, bulb, this.constructor.trackColor);
15795
+ this.renderer.circle(centre.x, centre.y, bulb - 3, this.valueColor());
15796
+ this.drawTarget(track);
15797
+ this.drawTicks(track);
15798
+ this._renderValueLabel(centre.x, centre.y + bulb + 14, { fontWeight: 'bold', fill: '#2f3542' });
15799
+ }
15800
+ }
15801
+
15802
+ /**
15803
+ * GaugeAnimation — eases a gauge from the value it shows to a new one, so a
15804
+ * needle or fill moves instead of jumping.
15805
+ *
15806
+ * Each frame draws the gauge from `GaugeValue.between(from, to, t)`: the
15807
+ * value, the target and each of several values move together; the history
15808
+ * and the previous value are the new ones throughout. The clock is
15809
+ * injectable (`schedule`, `now`) so it runs without a browser in tests.
15810
+ */
15811
+ class GaugeAnimation {
15812
+ /** Ease out (cubic): quick at first, settling on the value. */
15813
+ static ease(t) { return 1 - (1 - t) ** 3; }
15814
+
15815
+ /**
15816
+ * @param {Object} options
15817
+ * @param {GaugeValue} options.from
15818
+ * @param {GaugeValue} options.to
15819
+ * @param {number} [options.duration=400] - Milliseconds
15820
+ * @param {(data: Object) => void} options.draw - Draws one frame from its gauge data
15821
+ * @param {(callback: Function) => *} [options.schedule] - requestAnimationFrame, else a 16ms timer
15822
+ * @param {() => number} [options.now]
15823
+ */
15824
+ constructor({ from, to, duration = 400, draw, schedule = GaugeAnimation.defaultSchedule(), now = () => Date.now() }) {
15825
+ Object.assign(this, { from, to, duration, draw, schedule, now });
15826
+ this.stopped = false;
15827
+ }
15828
+
15829
+ static defaultSchedule() {
15830
+ return typeof globalThis.requestAnimationFrame === 'function'
15831
+ ? (callback) => globalThis.requestAnimationFrame(callback)
15832
+ : (callback) => setTimeout(callback, 16);
15833
+ }
15834
+
15835
+ /** Draw the first frame now and the rest on the clock, ending exactly on `to`. */
15836
+ start() {
15837
+ const started = this.now();
15838
+ const frame = () => {
15839
+ if (this.stopped) return;
15840
+ const t = this.duration > 0 ? Math.min(1, (this.now() - started) / this.duration) : 1;
15841
+ this.draw(GaugeValue.between(this.from, this.to, GaugeAnimation.ease(t)));
15842
+ if (t < 1) this.schedule(frame);
15843
+ };
15844
+ frame();
15845
+ return this;
14953
15846
  }
15847
+
15848
+ /** No more frames (a newer value has started its own). */
15849
+ stop() { this.stopped = true; }
14954
15850
  }
14955
15851
 
14956
15852
  /**
@@ -25080,20 +25976,14 @@
25080
25976
  };
25081
25977
  const style = this.textStyle;
25082
25978
  const font = TextStyle.svgAttributes(style, { defaultPixels: 12 });
25083
- const zLine = `Z:${this.zIndex || 0}`;
25084
25979
  if (TextStyle.placesText(style)) {
25085
25980
  // Placed in the shape's box by its alignment and position (centred, middle where unset).
25086
25981
  const at = TextStyle.block(style, this, 1, { pixels: font['font-size'], defaults: { textAlign: 'center', textPosition: 'middle' } });
25087
25982
  const placed = { 'text-anchor': at.textAnchor, 'dominant-baseline': 'auto' };
25088
25983
  if (this.name) text({ x: at.x, y: at.baseline, ...font, ...placed, fill: this.textColor }, this.name);
25089
- // Under the name, or over it when the name is at the bottom (so it stays in the shape).
25090
- const zY = style.textPosition === 'bottom' ? at.baseline - font['font-size'] - 2 : at.baseline + 12;
25091
- text({ x: at.x, y: zY, 'font-size': 10, fill: '#666', ...placed }, zLine);
25092
25984
  return;
25093
25985
  }
25094
25986
  if (this.name) text({ x, y, ...font, fill: this.textColor }, this.name);
25095
- // Below the name, however large it is set.
25096
- text({ x, y: y + Math.max(16, font['font-size'] / 2 + 10), 'font-size': 10, fill: '#666' }, zLine);
25097
25987
  }
25098
25988
 
25099
25989
  serialize() {
@@ -42849,14 +43739,11 @@
42849
43739
 
42850
43740
  /**
42851
43741
  * Renders a bar gauge.
42852
- * @param {number} value The value to display (0-100).
43742
+ * @param {number|{value:number, target?:number}} value The value to display (0-100 unless options.min/max are given).
42853
43743
  * @param {object} [options] Charting options.
42854
43744
  */
42855
43745
  barGauge(value, options = {}) {
42856
- if (!this.renderer) return;
42857
- this._chart3D = false; // leaving 3D mode — clears the SVG-zoom guard added for bubbleChart3D
42858
- this._chartState = { type: 'barGauge', data: value, options };
42859
- this._renderChart(new BarGauge(this.renderer, value, options));
43746
+ this._renderGauge(BarGauge, 'barGauge', value, options);
42860
43747
  }
42861
43748
 
42862
43749
  /**
@@ -42866,22 +43753,90 @@
42866
43753
  * @param {object} [options] Charting options.
42867
43754
  */
42868
43755
  bulletGauge(data, options = {}) {
42869
- if (!this.renderer) return;
42870
- this._chart3D = false;
42871
- this._chartState = { type: 'bullet', data, options };
42872
- this._renderChart(new BulletGauge(this.renderer, data, options));
43756
+ this._renderGauge(BulletGauge, 'bullet', data, options);
42873
43757
  }
42874
43758
 
42875
43759
  /**
42876
43760
  * Renders a dial gauge.
42877
- * @param {number} value The value to display (0-100).
43761
+ * @param {number|{value:number, target?:number}} value The value to display (0-100 unless options.min/max are given).
42878
43762
  * @param {object} [options] Charting options.
42879
43763
  */
42880
43764
  dialGauge(value, options = {}) {
43765
+ this._renderGauge(DialGauge, 'dialGauge', value, options);
43766
+ }
43767
+
43768
+ /**
43769
+ * Renders an arc gauge: a half circle (or any start angle and sweep) filled to the value.
43770
+ * @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
43771
+ * @param {object} [options] The gauge options (see REFERENCE.md, Gauges), cx, cy, r, thickness, startAngle, sweep, cap.
43772
+ */
43773
+ arcGauge(value, options = {}) {
43774
+ this._renderGauge(ArcGauge, 'arcGauge', value, options);
43775
+ }
43776
+
43777
+ /**
43778
+ * Renders a progress ring, its value in the middle.
43779
+ * @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
43780
+ * @param {object} [options] The gauge options, cx, cy, r, thickness.
43781
+ */
43782
+ ringGauge(value, options = {}) {
43783
+ this._renderGauge(RingGauge, 'ringGauge', value, options);
43784
+ }
43785
+
43786
+ /**
43787
+ * Renders several values as rings one inside the other, with a legend.
43788
+ * @param {Array<number|{value:number, label?:string, color?:string, min?:number, max?:number}>} values Outer ring first.
43789
+ * @param {object} [options] cx, cy, r, thickness, gap, colors, legend, min, max, format, units, title.
43790
+ */
43791
+ multiRingGauge(values, options = {}) {
43792
+ this._renderGauge(MultiRingGauge, 'multiRingGauge', values, options);
43793
+ }
43794
+
43795
+ /**
43796
+ * Renders a KPI tile: a figure, its change since the previous value, and a sparkline of its history.
43797
+ * @param {number|{value?:number, previous?:number, history?:number[]}} data
43798
+ * @param {object} [options] x, y, width, height, format, units, change ('percent'|'value'), upIsGood, bands, sparkColor, background, title.
43799
+ */
43800
+ kpiTile(data, options = {}) {
43801
+ this._renderGauge(KpiTile, 'kpiTile', data, options);
43802
+ }
43803
+
43804
+ /**
43805
+ * Renders a thermometer: a tube filled from a bulb up to the value.
43806
+ * @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
43807
+ * @param {object} [options] The gauge options, x, y, width, height, bulb.
43808
+ */
43809
+ thermometerGauge(value, options = {}) {
43810
+ this._renderGauge(ThermometerGauge, 'thermometerGauge', value, options);
43811
+ }
43812
+
43813
+ /**
43814
+ * Every gauge method draws through here. With `options.animate` (true:
43815
+ * 400ms, or milliseconds) a gauge of the same type already showing eases
43816
+ * from the value it shows to the new one; a newer call stops an older
43817
+ * animation and starts from where it got to.
43818
+ * @private
43819
+ */
43820
+ _renderGauge(GaugeType, type, data, options = {}) {
42881
43821
  if (!this.renderer) return;
42882
43822
  this._chart3D = false; // leaving 3D mode — clears the SVG-zoom guard added for bubbleChart3D
42883
- this._chartState = { type: 'dialGauge', data: value, options };
42884
- this._renderChart(new DialGauge(this.renderer, value, options));
43823
+ this._chartState = { type, data, options };
43824
+ this._gaugeAnimation?.stop();
43825
+ const shown = this._gaugeShown?.type === type ? this._gaugeShown.data : null;
43826
+ const draw = (frame) => {
43827
+ this._gaugeShown = { type, data: frame };
43828
+ this._renderChart(new GaugeType(this.renderer, frame, options));
43829
+ };
43830
+ if (!options.animate || shown == null) {
43831
+ draw(data);
43832
+ return;
43833
+ }
43834
+ this._gaugeAnimation = new GaugeAnimation({
43835
+ from: GaugeValue.from(shown, options),
43836
+ to: GaugeValue.from(data, options),
43837
+ duration: options.animate === true ? 400 : Number(options.animate),
43838
+ draw,
43839
+ }).start();
42885
43840
  }
42886
43841
 
42887
43842
  _arcPath(cx, cy, r, startAngle, endAngle) {