@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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * GaugeValue — what a gauge's data says: the value, and what it is compared
3
+ * with.
4
+ *
5
+ * Data is a plain number, or `{ value, target?, previous?, history?, values? }`;
6
+ * options of the same names win over the data's. `values` is for gauges that
7
+ * show several (one ring each): numbers, or `{ value, label?, color?, min?, max? }`;
8
+ * an array as the data is read as `values`.
9
+ */
10
+ export class GaugeValue {
11
+ constructor({ value = 0, target = null, previous = null, history = [], values = [] } = {}) {
12
+ this.value = Number(value) || 0;
13
+ this.target = target == null ? null : Number(target);
14
+ this.history = (history ?? []).map(Number).filter(Number.isFinite);
15
+ // The value before this one: given, or the one before the last in the history.
16
+ this.previous = previous != null ? Number(previous) : (this.history.length > 1 ? this.history.at(-2) : null);
17
+ this.values = (values ?? []).map(v => (typeof v === 'object' && v !== null ? { ...v, value: Number(v.value) || 0 } : { value: Number(v) || 0 }));
18
+ }
19
+
20
+ /** The change from the previous value, or null without one. */
21
+ get change() { return this.previous == null ? null : this.value - this.previous; }
22
+
23
+ /** The change as a fraction of the previous value (0.1 is up 10%), or null. */
24
+ get changeRatio() { return this.previous ? this.change / Math.abs(this.previous) : null; }
25
+
26
+ /** As gauge data again (`{ value, target, previous, history, values }`). */
27
+ toData() {
28
+ const { value, target, previous, history, values } = this;
29
+ return { value, target, previous, history: [...history], values: values.map(v => ({ ...v })) };
30
+ }
31
+
32
+ /**
33
+ * The data part of the way (t, 0-1) from one value to another: the value,
34
+ * the target and each of several values in between (one that is new
35
+ * starts from the first value); the rest as `to` has them.
36
+ */
37
+ static between(from, to, t) {
38
+ const mix = (a, b) => (a == null || b == null ? b : a + (b - a) * t);
39
+ const data = to.toData();
40
+ data.value = mix(from.value, to.value);
41
+ data.target = mix(from.target, to.target);
42
+ data.values = to.values.map((v, i) => ({ ...v, value: mix(from.values[i]?.value ?? from.value, v.value) }));
43
+ return data;
44
+ }
45
+
46
+ static from(data, options = {}) {
47
+ const given = Array.isArray(data) ? { values: data } : (typeof data === 'object' && data !== null ? data : { value: data });
48
+ const pick = (key) => options[key] ?? given[key];
49
+ const values = pick('values') ?? [];
50
+ const history = pick('history') ?? [];
51
+ // Without a value: the first of several, else the last of the history.
52
+ const first = values.length ? (typeof values[0] === 'object' ? values[0].value : values[0]) : history.at(-1);
53
+ return new GaugeValue({
54
+ value: given.value ?? first ?? 0,
55
+ target: pick('target'),
56
+ previous: pick('previous'),
57
+ history,
58
+ values,
59
+ });
60
+ }
61
+ }
@@ -0,0 +1,14 @@
1
+ import { LinearTrack } from './LinearTrack.js';
2
+
3
+ /** A track left to right. */
4
+ export class HorizontalTrack extends LinearTrack {
5
+ point(fraction, offset = 0) {
6
+ return { x: this.x + fraction * this.length, y: this.y + this.thickness / 2 + offset };
7
+ }
8
+
9
+ rectangle(from, to, thickness) {
10
+ return { x: this.x + from * this.length, y: this.y + (this.thickness - thickness) / 2, width: (to - from) * this.length, height: thickness };
11
+ }
12
+
13
+ get center() { return this.point(0.5); }
14
+ }
@@ -0,0 +1,29 @@
1
+ import { GaugeTrack } from './GaugeTrack.js';
2
+
3
+ /**
4
+ * LinearTrack — a straight gauge track: a box `length` long and `thickness`
5
+ * across, its segments drawn as rectangles. A subclass gives the direction.
6
+ */
7
+ export class LinearTrack extends GaugeTrack {
8
+ /**
9
+ * @param {Object} options
10
+ * @param {number} options.x - The box's left
11
+ * @param {number} options.y - The box's top
12
+ * @param {number} options.length
13
+ * @param {number} options.thickness
14
+ * @param {number} [options.radius=0] - Corner radius of the drawn segments
15
+ */
16
+ constructor({ x, y, length, thickness, radius = 0 }) {
17
+ super(thickness);
18
+ if (new.target === LinearTrack) throw new TypeError('LinearTrack is abstract — use HorizontalTrack or VerticalTrack');
19
+ Object.assign(this, { x, y, length, radius });
20
+ }
21
+
22
+ /** The rectangle covering from one fraction to another, `thickness` across and centred. */
23
+ rectangle(from, to, thickness) { throw new Error(`${this.constructor.name} must implement rectangle()`); }
24
+
25
+ draw(renderer, from, to, color, { thickness = this.thickness } = {}) {
26
+ const r = this.rectangle(Math.min(from, to), Math.max(from, to), thickness);
27
+ renderer.rect(r.x, r.y, r.width, r.height, color, null, this.radius ? { rx: this.radius, ry: this.radius } : {});
28
+ }
29
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Sparkline — a small line of a series' recent values, fitted to a box:
3
+ * the first value at the left, the last at the right, the lowest at the
4
+ * bottom and the highest at the top.
5
+ */
6
+ export class Sparkline {
7
+ /**
8
+ * @param {number[]} values - At least two to draw a line
9
+ * @param {{x:number, y:number, width:number, height:number}} box
10
+ */
11
+ constructor(values, box) {
12
+ this.values = values.map(Number).filter(Number.isFinite);
13
+ this.box = box;
14
+ }
15
+
16
+ /** Each value's point in the box (none for fewer than two values). */
17
+ get points() {
18
+ const { values, box } = this;
19
+ if (values.length < 2) return [];
20
+ const lo = Math.min(...values);
21
+ const hi = Math.max(...values);
22
+ const span = (hi - lo) || 1;
23
+ // A flat series sits across the middle.
24
+ const flat = hi === lo;
25
+ return values.map((v, i) => ({
26
+ x: box.x + (i / (values.length - 1)) * box.width,
27
+ y: flat ? box.y + box.height / 2 : box.y + box.height - ((v - lo) / span) * box.height,
28
+ }));
29
+ }
30
+
31
+ /** The line as a path `d` (empty for fewer than two values). */
32
+ get d() { return this.points.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' '); }
33
+
34
+ /** Draw the line and a dot on its last value. */
35
+ draw(renderer, { color = '#5352ed', width = 1.5 } = {}) {
36
+ const points = this.points;
37
+ if (!points.length) return;
38
+ renderer.path(this.d, 'none', { color, width });
39
+ const last = points.at(-1);
40
+ renderer.circle(last.x, last.y, 2.5, color);
41
+ }
42
+ }
@@ -0,0 +1,17 @@
1
+ import { LinearTrack } from './LinearTrack.js';
2
+
3
+ /** A track bottom to top. */
4
+ export class VerticalTrack extends LinearTrack {
5
+ point(fraction, offset = 0) {
6
+ return { x: this.x + this.thickness / 2 + offset, y: this.y + this.length - fraction * this.length };
7
+ }
8
+
9
+ rectangle(from, to, thickness) {
10
+ return { x: this.x + (this.thickness - thickness) / 2, y: this.y + this.length - to * this.length, width: thickness, height: (to - from) * this.length };
11
+ }
12
+
13
+ get center() { return this.point(0.5); }
14
+
15
+ /** Labels beside a vertical track start at their point. */
16
+ anchorAt() { return 'start'; }
17
+ }
@@ -170,20 +170,14 @@ export class Template {
170
170
  };
171
171
  const style = this.textStyle;
172
172
  const font = TextStyle.svgAttributes(style, { defaultPixels: 12 });
173
- const zLine = `Z:${this.zIndex || 0}`;
174
173
  if (TextStyle.placesText(style)) {
175
174
  // Placed in the shape's box by its alignment and position (centred, middle where unset).
176
175
  const at = TextStyle.block(style, this, 1, { pixels: font['font-size'], defaults: { textAlign: 'center', textPosition: 'middle' } });
177
176
  const placed = { 'text-anchor': at.textAnchor, 'dominant-baseline': 'auto' };
178
177
  if (this.name) text({ x: at.x, y: at.baseline, ...font, ...placed, fill: this.textColor }, this.name);
179
- // Under the name, or over it when the name is at the bottom (so it stays in the shape).
180
- const zY = style.textPosition === 'bottom' ? at.baseline - font['font-size'] - 2 : at.baseline + 12;
181
- text({ x: at.x, y: zY, 'font-size': 10, fill: '#666', ...placed }, zLine);
182
178
  return;
183
179
  }
184
180
  if (this.name) text({ x, y, ...font, fill: this.textColor }, this.name);
185
- // Below the name, however large it is set.
186
- text({ x, y: y + Math.max(16, font['font-size'] / 2 + 10), 'font-size': 10, fill: '#666' }, zLine);
187
181
  }
188
182
 
189
183
  serialize() {
package/src/index.js CHANGED
@@ -39,6 +39,13 @@ import { WaterfallChart } from './charting/charts/WaterfallChart.js';
39
39
  import { BarGauge } from './charting/charts/BarGauge.js';
40
40
  import { BulletGauge } from './charting/charts/BulletGauge.js';
41
41
  import { DialGauge } from './charting/charts/DialGauge.js';
42
+ import { ArcGauge } from './charting/charts/ArcGauge.js';
43
+ import { RingGauge } from './charting/charts/RingGauge.js';
44
+ import { MultiRingGauge } from './charting/charts/MultiRingGauge.js';
45
+ import { KpiTile } from './charting/charts/KpiTile.js';
46
+ import { ThermometerGauge } from './charting/charts/ThermometerGauge.js';
47
+ import { GaugeAnimation } from './charting/gauges/GaugeAnimation.js';
48
+ import { GaugeValue } from './charting/gauges/GaugeValue.js';
42
49
  import { BarChart3D } from './charting/charts/BarChart3D.js';
43
50
  import { BubbleChart3D } from './charting/charts/BubbleChart3D.js';
44
51
  import { BarChart3DOrbit } from './charting/charts/BarChart3DOrbit.js';
@@ -1657,14 +1664,11 @@ export class RenderEngine {
1657
1664
 
1658
1665
  /**
1659
1666
  * Renders a bar gauge.
1660
- * @param {number} value The value to display (0-100).
1667
+ * @param {number|{value:number, target?:number}} value The value to display (0-100 unless options.min/max are given).
1661
1668
  * @param {object} [options] Charting options.
1662
1669
  */
1663
1670
  barGauge(value, options = {}) {
1664
- if (!this.renderer) return;
1665
- this._chart3D = false; // leaving 3D mode — clears the SVG-zoom guard added for bubbleChart3D
1666
- this._chartState = { type: 'barGauge', data: value, options };
1667
- this._renderChart(new BarGauge(this.renderer, value, options));
1671
+ this._renderGauge(BarGauge, 'barGauge', value, options);
1668
1672
  }
1669
1673
 
1670
1674
  /**
@@ -1674,22 +1678,90 @@ export class RenderEngine {
1674
1678
  * @param {object} [options] Charting options.
1675
1679
  */
1676
1680
  bulletGauge(data, options = {}) {
1677
- if (!this.renderer) return;
1678
- this._chart3D = false;
1679
- this._chartState = { type: 'bullet', data, options };
1680
- this._renderChart(new BulletGauge(this.renderer, data, options));
1681
+ this._renderGauge(BulletGauge, 'bullet', data, options);
1681
1682
  }
1682
1683
 
1683
1684
  /**
1684
1685
  * Renders a dial gauge.
1685
- * @param {number} value The value to display (0-100).
1686
+ * @param {number|{value:number, target?:number}} value The value to display (0-100 unless options.min/max are given).
1686
1687
  * @param {object} [options] Charting options.
1687
1688
  */
1688
1689
  dialGauge(value, options = {}) {
1690
+ this._renderGauge(DialGauge, 'dialGauge', value, options);
1691
+ }
1692
+
1693
+ /**
1694
+ * Renders an arc gauge: a half circle (or any start angle and sweep) filled to the value.
1695
+ * @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
1696
+ * @param {object} [options] The gauge options (see REFERENCE.md, Gauges), cx, cy, r, thickness, startAngle, sweep, cap.
1697
+ */
1698
+ arcGauge(value, options = {}) {
1699
+ this._renderGauge(ArcGauge, 'arcGauge', value, options);
1700
+ }
1701
+
1702
+ /**
1703
+ * Renders a progress ring, its value in the middle.
1704
+ * @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
1705
+ * @param {object} [options] The gauge options, cx, cy, r, thickness.
1706
+ */
1707
+ ringGauge(value, options = {}) {
1708
+ this._renderGauge(RingGauge, 'ringGauge', value, options);
1709
+ }
1710
+
1711
+ /**
1712
+ * Renders several values as rings one inside the other, with a legend.
1713
+ * @param {Array<number|{value:number, label?:string, color?:string, min?:number, max?:number}>} values Outer ring first.
1714
+ * @param {object} [options] cx, cy, r, thickness, gap, colors, legend, min, max, format, units, title.
1715
+ */
1716
+ multiRingGauge(values, options = {}) {
1717
+ this._renderGauge(MultiRingGauge, 'multiRingGauge', values, options);
1718
+ }
1719
+
1720
+ /**
1721
+ * Renders a KPI tile: a figure, its change since the previous value, and a sparkline of its history.
1722
+ * @param {number|{value?:number, previous?:number, history?:number[]}} data
1723
+ * @param {object} [options] x, y, width, height, format, units, change ('percent'|'value'), upIsGood, bands, sparkColor, background, title.
1724
+ */
1725
+ kpiTile(data, options = {}) {
1726
+ this._renderGauge(KpiTile, 'kpiTile', data, options);
1727
+ }
1728
+
1729
+ /**
1730
+ * Renders a thermometer: a tube filled from a bulb up to the value.
1731
+ * @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
1732
+ * @param {object} [options] The gauge options, x, y, width, height, bulb.
1733
+ */
1734
+ thermometerGauge(value, options = {}) {
1735
+ this._renderGauge(ThermometerGauge, 'thermometerGauge', value, options);
1736
+ }
1737
+
1738
+ /**
1739
+ * Every gauge method draws through here. With `options.animate` (true:
1740
+ * 400ms, or milliseconds) a gauge of the same type already showing eases
1741
+ * from the value it shows to the new one; a newer call stops an older
1742
+ * animation and starts from where it got to.
1743
+ * @private
1744
+ */
1745
+ _renderGauge(GaugeType, type, data, options = {}) {
1689
1746
  if (!this.renderer) return;
1690
1747
  this._chart3D = false; // leaving 3D mode — clears the SVG-zoom guard added for bubbleChart3D
1691
- this._chartState = { type: 'dialGauge', data: value, options };
1692
- this._renderChart(new DialGauge(this.renderer, value, options));
1748
+ this._chartState = { type, data, options };
1749
+ this._gaugeAnimation?.stop();
1750
+ const shown = this._gaugeShown?.type === type ? this._gaugeShown.data : null;
1751
+ const draw = (frame) => {
1752
+ this._gaugeShown = { type, data: frame };
1753
+ this._renderChart(new GaugeType(this.renderer, frame, options));
1754
+ };
1755
+ if (!options.animate || shown == null) {
1756
+ draw(data);
1757
+ return;
1758
+ }
1759
+ this._gaugeAnimation = new GaugeAnimation({
1760
+ from: GaugeValue.from(shown, options),
1761
+ to: GaugeValue.from(data, options),
1762
+ duration: options.animate === true ? 400 : Number(options.animate),
1763
+ draw,
1764
+ }).start();
1693
1765
  }
1694
1766
 
1695
1767
  _arcPath(cx, cy, r, startAngle, endAngle) {