@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.
@@ -17,7 +17,7 @@
17
17
  <script src="../dist/renderengine.umd.js"></script>
18
18
  <script src="../dist/renderengine-workspace.umd.js"></script>
19
19
  <script>
20
- const { RectangleTemplate, StadiumTemplate, DiamondTemplate } = window.RenderEngine;
20
+ const { RectangleTemplate, StadiumTemplate, DiamondTemplate, ParallelogramTemplate, Edge } = window.RenderEngine;
21
21
  const { el, registerBuiltinParts, DiagramEditorPart } = window.RenderEngineWorkspace;
22
22
 
23
23
  const ws = document.body.appendChild(el({
@@ -35,16 +35,21 @@
35
35
  ws.registerPerspective('Diagrams', { type: 'part', partId: 'models' });
36
36
  ws.activatePerspective('Diagrams');
37
37
 
38
- // A small flowchart to start from.
38
+ // A small flowchart to start from: its shapes, and the lines between them.
39
39
  const editorPart = ws.getPart('editor');
40
- const { templates } = editorPart.model.currentView;
40
+ const { templates, edges } = editorPart.model.currentView;
41
41
  for (const t of [
42
42
  new StadiumTemplate('start', 'Order received', { x: 160, y: 80, fillColor: '#dcfce7' }),
43
43
  new RectangleTemplate('check', 'Check stock', { x: 160, y: 200, fillColor: '#dbeafe' }),
44
44
  new DiamondTemplate('decide', 'In stock?', { x: 160, y: 330, fillColor: '#fef9c3' }),
45
45
  new RectangleTemplate('ship', 'Ship order', { x: 400, y: 330, fillColor: '#dbeafe' }),
46
- new StadiumTemplate('end', 'Done', { x: 400, y: 460, fillColor: '#dcfce7' }),
46
+ new ParallelogramTemplate('reorder', 'Reorder stock', { x: 160, y: 470, fillColor: '#ede9fe' }),
47
+ new StadiumTemplate('end', 'Done', { x: 400, y: 470, fillColor: '#dcfce7' }),
47
48
  ]) templates.set(t.id, t);
49
+ for (const [id, from, to, label = ''] of [
50
+ ['e1', 'start', 'check'], ['e2', 'check', 'decide'], ['e3', 'decide', 'ship', 'yes'],
51
+ ['e4', 'decide', 'reorder', 'no'], ['e5', 'ship', 'end'], ['e6', 'reorder', 'ship'],
52
+ ]) edges.set(id, new Edge(id, from, to, { endMarker: 'arrow', label }));
48
53
  editorPart.editor.render();
49
54
  </script>
50
55
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -28,7 +28,8 @@
28
28
  "diagram": "node scripts/open.mjs examples/diagram-editor.html",
29
29
  "test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
30
30
  "build": "rollup -c",
31
- "prepublishOnly": "npm test && npm run build"
31
+ "prepublishOnly": "npm test && npm run build",
32
+ "screenshots": "node scripts/screenshots/screenshots.mjs"
32
33
  },
33
34
  "keywords": [
34
35
  "archimate",
@@ -461,18 +461,18 @@ export const CHART_CATALOG = [
461
461
  key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
462
462
  coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
463
463
  axes: [
464
- { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] },
464
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
465
465
  ],
466
- dataShape: 'number (0-100) — no category axis; the only role IS the value',
466
+ dataShape: 'number, {value, target?}, or [{value, label?, color?}] (a needle each) — no category axis; the only role IS the value (0-100 unless min/max are given)',
467
467
  streamShape: 'none', // no array/window at all — update by calling dialGauge(newValue) again directly
468
468
  },
469
469
  {
470
470
  key: 'gauge-bar', label: 'Gauge (Bar)', method: 'barGauge', category: 'gauge',
471
471
  coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
472
472
  axes: [
473
- { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] },
473
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
474
474
  ],
475
- dataShape: 'number (0-100)',
475
+ dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
476
476
  streamShape: 'none', // no array/window at all — update by calling barGauge(newValue) again directly
477
477
  },
478
478
  {
@@ -485,6 +485,51 @@ export const CHART_CATALOG = [
485
485
  dataShape: 'number or {value, target?, ranges?:Array<{max, label?, color?}>}',
486
486
  streamShape: 'none',
487
487
  },
488
+ {
489
+ key: 'gauge-arc', label: 'Gauge (Arc)', method: 'arcGauge', category: 'gauge',
490
+ coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
491
+ axes: [
492
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
493
+ ],
494
+ dataShape: 'number, or {value, target?} (0-100 unless min/max are given) — a half circle by default, any startAngle/sweep',
495
+ streamShape: 'none',
496
+ },
497
+ {
498
+ key: 'gauge-ring', label: 'Gauge (Ring)', method: 'ringGauge', category: 'gauge',
499
+ coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
500
+ axes: [
501
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
502
+ ],
503
+ dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
504
+ streamShape: 'none',
505
+ },
506
+ {
507
+ key: 'gauge-rings', label: 'Gauge (Rings)', method: 'multiRingGauge', category: 'gauge',
508
+ coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
509
+ axes: [
510
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
511
+ ],
512
+ dataShape: 'Array<number|{value, label?, color?, min?, max?}> — one ring per value, outer first, each on its own scale',
513
+ streamShape: 'none',
514
+ },
515
+ {
516
+ key: 'gauge-thermometer', label: 'Gauge (Thermometer)', method: 'thermometerGauge', category: 'gauge',
517
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
518
+ axes: [
519
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
520
+ ],
521
+ dataShape: 'number, or {value, target?} (0-100 unless min/max are given)',
522
+ streamShape: 'none',
523
+ },
524
+ {
525
+ key: 'kpi-tile', label: 'KPI Tile', method: 'kpiTile', category: 'gauge',
526
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
527
+ axes: [
528
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.FILL_PERCENT, required: true, range: [0, 100] }, // the default; the min/max options change it
529
+ ],
530
+ dataShape: 'number, or {value?, previous?, history?:number[]} — the figure, its change since the previous value, a sparkline of the history',
531
+ streamShape: 'none',
532
+ },
488
533
  {
489
534
  key: 'bar3D', label: 'Bar (3D)', method: 'barChart3D', category: '3d',
490
535
  coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.ISOMETRIC,
@@ -38,6 +38,12 @@ export class Gauge {
38
38
  * Angles are in radians.
39
39
  */
40
40
  static arcPath(cx, cy, r, startAngle, endAngle) {
41
+ // A whole circle starts and ends on one point, which an SVG arc draws as nothing: two halves.
42
+ if (endAngle - startAngle >= 2 * Math.PI - 1e-9) {
43
+ const mid = startAngle + Math.PI;
44
+ const at = (a) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
45
+ return `M ${at(startAngle)} A ${r} ${r} 0 0 1 ${at(mid)} A ${r} ${r} 0 0 1 ${at(startAngle)}`;
46
+ }
41
47
  const largeArcFlag = endAngle - startAngle <= Math.PI ? '0' : '1';
42
48
  const start = {
43
49
  x: cx + r * Math.cos(startAngle),
@@ -0,0 +1,55 @@
1
+ import { GaugeChart } from './GaugeChart.js';
2
+ import { ArcTrack } from '../gauges/ArcTrack.js';
3
+
4
+ /**
5
+ * ArcGauge — an arc filled to the value, its number in the middle: a half
6
+ * circle by default, or any start angle and sweep.
7
+ *
8
+ * Options: cx, cy, r (100, 100, 70), thickness (16), startAngle and sweep in
9
+ * degrees (180 and 180: a half circle over the top), cap ('round' ends), and
10
+ * every GaugeChart option (min, max, bands, target, ticks, ...). Without
11
+ * ticks the scale's two ends are labelled.
12
+ */
13
+ export class ArcGauge extends GaugeChart {
14
+ static chartType = 'arcGauge';
15
+
16
+ static LAYOUT = { cx: 100, cy: 100, r: 70, thickness: 16, startAngle: 180, sweep: 180 };
17
+
18
+ track() { return new ArcTrack(this.layout); }
19
+
20
+ _render() {
21
+ const { title, cap } = this.options;
22
+ const track = this.track();
23
+ const capOption = cap ? { cap } : {};
24
+ this.drawBackground(track, capOption);
25
+ this.drawValue(track, capOption);
26
+ this.drawTarget(track);
27
+ if (this.options.ticks) this.drawTicks(track);
28
+ else this.drawEnds(track);
29
+ this.drawLabels(track);
30
+ this._renderTitle(title, track.cx, this.titleY(track), { textAnchor: 'middle', fontSize: '12px', fill: '#747d8c' });
31
+ }
32
+
33
+ /** The value's number in the middle, a little above the centre for an open arc. */
34
+ drawLabels(track) {
35
+ const { cx, cy, r } = track;
36
+ this._renderValueLabel(cx, cy - r * 0.15, { fontSize: `${Math.max(12, Math.round(r * 0.32))}px`, fill: '#2f3542', fontWeight: 'bold' });
37
+ }
38
+
39
+ /** Below the arc's lowest point. */
40
+ titleY(track) {
41
+ const lowest = Math.max(track.point(0).y, track.point(1).y, track.cy);
42
+ return lowest + track.thickness / 2 + 18;
43
+ }
44
+
45
+ /** The scale's min and max under the arc's two ends. */
46
+ drawEnds(track) {
47
+ const below = track.thickness / 2 + 2;
48
+ for (const f of [0, 1]) {
49
+ const at = track.point(f);
50
+ this.renderer.text(this.scale.tickLabel(this.scale.valueAt(f), 1), at.x, at.y + below + 8, {
51
+ textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px', fill: '#747d8c',
52
+ });
53
+ }
54
+ }
55
+ }
@@ -1,21 +1,42 @@
1
1
  import { GaugeChart } from './GaugeChart.js';
2
+ import { HorizontalTrack } from '../gauges/HorizontalTrack.js';
3
+ import { VerticalTrack } from '../gauges/VerticalTrack.js';
2
4
 
3
5
  /**
4
- * BarGauge — a simple horizontal percentage bar.
6
+ * BarGauge — a straight bar filled to the value, left to right or (with
7
+ * `orientation: 'vertical'`) bottom to top.
8
+ *
9
+ * Options: x, y, width, height (the bar's box; 200 × 30 across, 30 × 200 up),
10
+ * orientation, and every GaugeChart option (min, max, bands, target, ticks, ...).
5
11
  */
6
12
  export class BarGauge extends GaugeChart {
7
13
  static chartType = 'barGauge';
8
14
 
9
- _render() {
10
- const options = this.options;
11
- const { x = 10, y = 10, width = 200, height = 30, color = '#5352ed', title } = options;
12
- const p = this._normalizePercent();
13
- const barWidth = (p / 100) * width;
14
- const resolvedColor = this._resolveColor(color, p);
15
+ get vertical() { return this.options.orientation === 'vertical'; }
16
+
17
+ track() {
18
+ const { x = 10, y = 10 } = this.options;
19
+ const { width = this.vertical ? 30 : 200, height = this.vertical ? 200 : 30 } = this.options;
20
+ return this.vertical
21
+ ? new VerticalTrack({ x, y, length: height, thickness: width, radius: 4 })
22
+ : new HorizontalTrack({ x, y, length: width, thickness: height, radius: 4 });
23
+ }
15
24
 
16
- this._renderTitle(title, x, y - 5);
17
- this.renderer.rect(x, y, width, height, '#f1f2f6', null, { rx: 4, ry: 4 });
18
- this.renderer.rect(x, y, barWidth, height, resolvedColor, null, { rx: 4, ry: 4 });
19
- this._renderPercentageLabel(p, x + width / 2, y + height / 2, { fontWeight: 'bold' });
25
+ _render() {
26
+ const { x = 10, y = 10, title } = this.options;
27
+ const track = this.track();
28
+ this._renderTitle(title, x, y - (this.vertical ? 8 : 5));
29
+ this.drawBackground(track);
30
+ this.drawValue(track);
31
+ this.drawTarget(track);
32
+ this.drawTicks(track);
33
+ if (this.vertical) {
34
+ // A vertical bar is too narrow for its label: it goes underneath.
35
+ const below = track.point(0, 0);
36
+ this._renderValueLabel(below.x, below.y + 14, { fontWeight: 'bold', fill: '#2f3542' });
37
+ } else {
38
+ const { x: cx, y: cy } = track.center;
39
+ this._renderValueLabel(cx, cy, { fontWeight: 'bold' });
40
+ }
20
41
  }
21
42
  }
@@ -1,4 +1,5 @@
1
- import { Chart } from '../Chart.js';
1
+ import { GaugeChart } from './GaugeChart.js';
2
+ import { HorizontalTrack } from '../gauges/HorizontalTrack.js';
2
3
  import { ChartBox, ChartLabels } from '../ChartBox.js';
3
4
 
4
5
  /**
@@ -8,82 +9,77 @@ import { ChartBox, ChartLabels } from '../ChartBox.js';
8
9
  * Data may be a plain number (the actual value) or an object
9
10
  * `{ value: number, target?: number, ranges?: Array<{max:number, label?, color?}> }`.
10
11
  *
11
- * Options:
12
- * target?: number — target value (overrides data.target)
13
- * ranges?: Range[] — qualitative ranges (overrides data.ranges)
14
- * min?: number = 0 — scale minimum
15
- * max?: number = 100 — scale maximum
12
+ * Options (with every GaugeChart option: min, max, bands, target, ticks, ...):
13
+ * ranges?: Range[] — the bands, by their older name (overrides data.ranges)
16
14
  * barColor?: string — color of the actual-value bar
17
15
  * targetColor?: string = #333 — color of the target marker
18
16
  * rangeColors?: string[] — fallback palette for ranges without their own color
19
- * title?: string
20
17
  * showLabels?: boolean = true — value/target/range labels
21
18
  */
22
- export class BulletGauge extends Chart {
19
+ export class BulletGauge extends GaugeChart {
23
20
  static chartType = 'bullet';
21
+ static defaultColor = '#5470c6';
24
22
 
25
- _render() {
26
- const data = this.data;
27
- const options = this.options;
28
- const box = ChartBox.fromRenderer(this.renderer);
29
- const {
30
- x = box.x, y = box.y, width = box.width, height = box.height * 0.4,
31
- min = 0, max = 100,
32
- target: optTarget, targetColor = '#333',
33
- barColor = '#5470c6',
34
- ranges: optRanges, rangeColors = ['#ff6b6b', '#ffd93d', '#6bcb77'],
35
- title, showLabels = true,
36
- } = options;
23
+ /** Its numbers as they are ("65", not "65.0%"). */
24
+ defaultFormat() { return (v) => String(v); }
37
25
 
38
- const actual = typeof data === 'number' ? data : (data?.value ?? 0);
39
- const target = optTarget != null ? optTarget : data?.target ?? null;
40
- const ranges = optRanges ?? data?.ranges ?? [
26
+ /** The data's ranges, else poor / satisfactory / good at 60% and 80% of the scale. */
27
+ defaultBands() {
28
+ const max = this.options.max ?? 100;
29
+ return this.data?.bands ?? this.data?.ranges ?? [
41
30
  { max: max * 0.6, label: 'Poor' },
42
31
  { max: max * 0.8, label: 'Satisfactory' },
43
- { max: max, label: 'Good' },
32
+ { max, label: 'Good' },
44
33
  ];
34
+ }
35
+
36
+ /** The bar keeps one colour (`barColor`): the bands behind it are the comparison. */
37
+ valueColor(value) {
38
+ const { barColor, color } = this.options;
39
+ return barColor ?? (color != null ? super.valueColor(value) : this.constructor.defaultColor);
40
+ }
41
+
42
+ get box() {
43
+ const box = ChartBox.fromRenderer(this.renderer);
44
+ const { x = box.x, y = box.y, width = box.width, height = box.height * 0.4 } = this.options;
45
+ return { x, y, width, height };
46
+ }
45
47
 
46
- const span = (max - min) || 1;
47
- const toX = (v) => x + Math.min(Math.max((v - min) / span, 0), 1) * width;
48
+ track() {
49
+ const { x, y, width, height } = this.box;
50
+ return new HorizontalTrack({ x, y, length: width, thickness: height });
51
+ }
52
+
53
+ _render() {
54
+ const { title, showLabels = true, ticks } = this.options;
55
+ const { x, y, width, height } = this.box;
56
+ const track = this.track();
48
57
 
49
58
  new ChartLabels({ title }).draw(this.renderer, { x, y, width, height });
50
59
 
51
- // Draw qualitative range bands
52
- let rangeStart = min;
53
- ranges.forEach((range, i) => {
54
- const rangeEnd = Math.min(Math.max(range.max, min), max);
55
- const rx = toX(rangeStart);
56
- const rw = toX(rangeEnd) - rx;
57
- const color = range.color ?? rangeColors[i % rangeColors.length];
58
- this.renderer.rect(rx, y, rw, height, color, null, { opacity: 0.35 });
59
- if (showLabels && range.label) {
60
- this.renderer.text(range.label, rx + rw / 2, y + height + 14, {
61
- textAnchor: 'middle', fontSize: '10px', fill: '#666',
62
- });
60
+ this.drawBackground(track);
61
+ if (showLabels) {
62
+ const below = y + height + (ticks ? 28 : 14);
63
+ for (const band of this.bands.list) {
64
+ if (!band.label) continue;
65
+ const mid = track.point((this.scale.fraction(band.from) + this.scale.fraction(band.to)) / 2);
66
+ this.renderer.text(band.label, mid.x, below, { textAnchor: 'middle', fontSize: '10px', fill: '#666' });
63
67
  }
64
- rangeStart = rangeEnd;
65
- });
68
+ }
66
69
 
67
- // Draw actual-value bar (thinner, centered vertically)
70
+ // The actual value: a bar half as thick, centred on the bands.
68
71
  const barHeight = height * 0.5;
69
- const barY = y + (height - barHeight) / 2;
70
- const actualX = toX(min);
71
- const actualW = toX(actual) - actualX;
72
- this.renderer.rect(actualX, barY, actualW, barHeight, barColor, null, {});
73
-
74
- // Draw target marker (vertical line)
75
- if (target != null) {
76
- const tx = toX(target);
77
- this.renderer.path(`M${tx},${y - 2} L${tx},${y + height + 2}`, 'none', { color: targetColor, width: 2 });
78
- }
72
+ this.drawValue(track, { thickness: barHeight });
73
+ this.drawTarget(track, { overhang: 2 });
74
+ this.drawTicks(track);
79
75
 
80
76
  if (showLabels) {
81
- this.renderer.text(String(actual), x + width / 2, barY + barHeight / 2, {
82
- textAnchor: 'middle', verticalAlign: 'middle', fontSize: '11px', fill: '#fff', fontWeight: 'bold',
83
- });
77
+ const { x: cx, y: cy } = track.center;
78
+ this._renderValueLabel(cx, cy, { fontSize: '11px', fontWeight: 'bold' });
79
+ const { target } = this.gaugeValue;
84
80
  if (target != null) {
85
- this.renderer.text(String(target), toX(target) + 5, y - 8, {
86
- textAnchor: 'start', fontSize: '10px', fill: targetColor,
81
+ this.renderer.text(this.scale.label(target), track.point(this.scale.fraction(target)).x + 5, y - 8, {
82
+ textAnchor: 'start', fontSize: '10px', fill: this.options.targetColor ?? '#333',
87
83
  });
88
84
  }
89
85
  }
@@ -1,37 +1,65 @@
1
- import { Gauge } from '../Gauge.js';
2
1
  import { GaugeChart } from './GaugeChart.js';
2
+ import { ArcTrack } from '../gauges/ArcTrack.js';
3
3
 
4
4
  /**
5
- * DialGauge — a semicircular percentage gauge with a needle.
5
+ * DialGauge — an arc filled to the value, with a needle pointing at it.
6
+ *
7
+ * Data: a value, or several (`[{ value, label?, color? }]`, e.g. actual and
8
+ * budget): a needle each, the first also filling the arc and labelled in
9
+ * the middle, and a legend of them all under the title.
10
+ * Options: cx, cy, r (100, 80, 60), thickness (12), startAngle and sweep in
11
+ * degrees (135 and 270: bottom-left round to bottom-right), needleColors,
12
+ * and every GaugeChart option (min, max, bands, target, ticks, segments, ...).
6
13
  */
7
14
  export class DialGauge extends GaugeChart {
8
15
  static chartType = 'dialGauge';
16
+ static LAYOUT = { cx: 100, cy: 80, r: 60, thickness: 12, startAngle: 135, sweep: 270 };
17
+ static NEEDLE_COLORS = ['#2f3542', '#ff4757', '#1e90ff', '#ffa502'];
18
+
19
+ track() { return new ArcTrack(this.layout); }
20
+
21
+ /** One per value, the first the main one: {value, label, color}. */
22
+ get needles() {
23
+ const { needleColors = DialGauge.NEEDLE_COLORS } = this.options;
24
+ const values = this.gaugeValue.values.length ? this.gaugeValue.values : [{ value: this.gaugeValue.value }];
25
+ return values.map((v, i) => ({ ...v, value: this.scale.clamp(v.value), color: v.color ?? needleColors[i % needleColors.length] }));
26
+ }
9
27
 
10
28
  _render() {
11
- const options = this.options;
12
- const { cx = 100, cy = 80, r = 60, color = '#5352ed', title } = options;
13
- const p = this._normalizePercent();
14
- const resolvedColor = this._resolveColor(color, p);
15
- const totalAngle = 270;
16
- const startAngle = 135;
17
- const endAngle = startAngle + totalAngle;
18
- const needleAngle = startAngle + (p / 100) * totalAngle;
19
- const rad = Math.PI / 180;
20
-
21
- // Background Arc
22
- this.renderer.path(Gauge.arcPath(cx, cy, r, startAngle * rad, endAngle * rad), 'none', { color: '#f1f2f6', width: 12, cap: 'round' });
23
- // Progress Arc
24
- this.renderer.path(Gauge.arcPath(cx, cy, r, startAngle * rad, needleAngle * rad), 'none', { color: resolvedColor, width: 12, cap: 'round' });
25
-
26
- // Needle
27
- const needleRad = needleAngle * rad;
28
- const nx = cx + (r - 8) * Math.cos(needleRad);
29
- const ny = cy + (r - 8) * Math.sin(needleRad);
30
- this.renderer.path(`M${cx},${cy} L${nx},${ny}`, 'none', { color: '#2f3542', width: 3, cap: 'round' });
31
- this.renderer.circle(cx, cy, 5, '#2f3542');
32
-
33
- // Text
29
+ const { title } = this.options;
30
+ const track = this.track();
31
+ const { cx, cy, r } = track;
32
+
33
+ this.drawBackground(track, { cap: 'round' });
34
+ this.drawValue(track, { cap: 'round' });
35
+ this.drawTarget(track);
36
+ this.drawTicks(track);
37
+ const needles = this.needles;
38
+ // The main needle last, so it is on top.
39
+ for (const needle of [...needles].reverse()) this.drawNeedle(track, needle);
40
+ this.renderer.circle(cx, cy, 5, needles[0].color);
41
+
34
42
  this._renderTitle(title, cx, cy + r + 25, { textAnchor: 'middle', fill: '#747d8c', textTransform: 'uppercase' });
35
- this._renderPercentageLabel(p, cx, cy + r - 15, { fontSize: '18px', fill: '#2f3542', fontWeight: 'bold' });
43
+ this._renderValueLabel(cx, cy + r - 15, { fontSize: '18px', fill: '#2f3542', fontWeight: 'bold' });
44
+ if (needles.length > 1) this.drawLegend(needles, cx, cy + r + (title ? 42 : 25));
45
+ }
46
+
47
+ /** A needle from the centre to just inside the arc. */
48
+ drawNeedle(track, { value, color }) {
49
+ const tip = track.point(this.scale.fraction(value), -8);
50
+ this.renderer.path(`M${track.cx},${track.cy} L${tip.x},${tip.y}`, 'none', { color, width: 3, cap: 'round' });
51
+ }
52
+
53
+ /** Each needle's label and value on one line, centred. */
54
+ drawLegend(needles, cx, y) {
55
+ const text = needles.map((n, i) => `${n.label ?? `#${i + 1}`} ${this.scale.label(n.value)}`);
56
+ const font = { fontSize: '11px' };
57
+ const width = (t) => (this.renderer.measureText?.(t, font) ?? t.length * 6) + 22;
58
+ let x = cx - text.reduce((sum, t) => sum + width(t), 0) / 2;
59
+ needles.forEach((needle, i) => {
60
+ this.renderer.circle(x + 4, y, 4, needle.color);
61
+ this.renderer.text(text[i], x + 12, y, { ...font, textAnchor: 'start', verticalAlign: 'middle', fill: '#2f3542' });
62
+ x += width(text[i]);
63
+ });
36
64
  }
37
65
  }