@internetstiftelsen/charts 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gauge-chart.js ADDED
@@ -0,0 +1,1041 @@
1
+ import { arc, select } from 'd3';
2
+ import { BaseChart } from './base-chart.js';
3
+ import { DEFAULT_COLOR_PALETTE } from './theme.js';
4
+ import { ChartValidator } from './validation.js';
5
+ const DEFAULT_START_ANGLE = -Math.PI * 0.75;
6
+ const DEFAULT_END_ANGLE = Math.PI * 0.75;
7
+ const DEFAULT_HALF_START_ANGLE = -Math.PI / 2;
8
+ const DEFAULT_HALF_END_ANGLE = Math.PI / 2;
9
+ const DEFAULT_VALUE_KEY = 'value';
10
+ const DEFAULT_HALF_CIRCLE = false;
11
+ const DEFAULT_MIN_VALUE = 0;
12
+ const DEFAULT_MAX_VALUE = 100;
13
+ const DEFAULT_INNER_RADIUS_RATIO = 0.68;
14
+ const DEFAULT_CORNER_RADIUS = 4;
15
+ const DEFAULT_TRACK_COLOR = '#e5e7eb';
16
+ const DEFAULT_TARGET_COLOR = '#111827';
17
+ const DEFAULT_SEGMENT_STYLE = 'solid';
18
+ const DEFAULT_SHOW_VALUE = true;
19
+ const DEFAULT_NEEDLE_SHOW = true;
20
+ const DEFAULT_THEME_PALETTE_INDEX = 0;
21
+ const DEFAULT_NEEDLE_WIDTH = 3;
22
+ const DEFAULT_NEEDLE_LENGTH_RATIO = 0.92;
23
+ const DEFAULT_NEEDLE_CAP_RADIUS = 6;
24
+ const DEFAULT_MARKER_WIDTH = 3;
25
+ const DEFAULT_TICK_COUNT = 5;
26
+ const DEFAULT_TICKS_SHOW = true;
27
+ const DEFAULT_TICKS_SHOW_LINES = true;
28
+ const DEFAULT_TICKS_SHOW_LABELS = true;
29
+ const DEFAULT_TICK_SIZE = 8;
30
+ const DEFAULT_TICK_LABEL_OFFSET = 12;
31
+ const DEFAULT_TICK_LABEL_FONT_SIZE = 11;
32
+ const DEFAULT_TICK_LABEL_FONT_WEIGHT = 'normal';
33
+ const DEFAULT_TICK_LABEL_COLOR = '#4b5563';
34
+ const DEFAULT_VALUE_LABEL_FONT_SIZE = 28;
35
+ const DEFAULT_VALUE_LABEL_FONT_WEIGHT = '700';
36
+ const DEFAULT_VALUE_LABEL_COLOR = '#111827';
37
+ const DEFAULT_HALF_CIRCLE_VALUE_SPACE_WITH_LABEL = 46;
38
+ const DEFAULT_HALF_CIRCLE_VALUE_SPACE_WITHOUT_LABEL = 12;
39
+ const DEFAULT_TICK_LINE_COLOR = '#6b7280';
40
+ const DEFAULT_TICK_LINE_STROKE_WIDTH = 1.5;
41
+ const DEFAULT_TARGET_MARKER_STROKE_WIDTH = 2.5;
42
+ const DEFAULT_PROGRESS_RADIUS_INSET = 2;
43
+ const MIN_PROGRESS_BAND_THICKNESS = 1;
44
+ const DEFAULT_HALF_CIRCLE_VALUE_TEXT_Y = 32;
45
+ const DEFAULT_FULL_CIRCLE_VALUE_TEXT_MIN_Y = 22;
46
+ const TOOLTIP_OFFSET_PX = 12;
47
+ const EDGE_MARGIN_PX = 10;
48
+ const DUMMY_ARC_DATUM = {
49
+ innerRadius: 0,
50
+ outerRadius: 0,
51
+ startAngle: 0,
52
+ endAngle: 0,
53
+ };
54
+ export class GaugeChart extends BaseChart {
55
+ constructor(config) {
56
+ super(config);
57
+ Object.defineProperty(this, "configuredValue", {
58
+ enumerable: true,
59
+ configurable: true,
60
+ writable: true,
61
+ value: void 0
62
+ });
63
+ Object.defineProperty(this, "configuredTargetValue", {
64
+ enumerable: true,
65
+ configurable: true,
66
+ writable: true,
67
+ value: void 0
68
+ });
69
+ Object.defineProperty(this, "configuredSegments", {
70
+ enumerable: true,
71
+ configurable: true,
72
+ writable: true,
73
+ value: void 0
74
+ });
75
+ Object.defineProperty(this, "valueKey", {
76
+ enumerable: true,
77
+ configurable: true,
78
+ writable: true,
79
+ value: void 0
80
+ });
81
+ Object.defineProperty(this, "targetValueKey", {
82
+ enumerable: true,
83
+ configurable: true,
84
+ writable: true,
85
+ value: void 0
86
+ });
87
+ Object.defineProperty(this, "minValue", {
88
+ enumerable: true,
89
+ configurable: true,
90
+ writable: true,
91
+ value: void 0
92
+ });
93
+ Object.defineProperty(this, "maxValue", {
94
+ enumerable: true,
95
+ configurable: true,
96
+ writable: true,
97
+ value: void 0
98
+ });
99
+ Object.defineProperty(this, "halfCircle", {
100
+ enumerable: true,
101
+ configurable: true,
102
+ writable: true,
103
+ value: void 0
104
+ });
105
+ Object.defineProperty(this, "startAngle", {
106
+ enumerable: true,
107
+ configurable: true,
108
+ writable: true,
109
+ value: void 0
110
+ });
111
+ Object.defineProperty(this, "endAngle", {
112
+ enumerable: true,
113
+ configurable: true,
114
+ writable: true,
115
+ value: void 0
116
+ });
117
+ Object.defineProperty(this, "innerRadiusRatio", {
118
+ enumerable: true,
119
+ configurable: true,
120
+ writable: true,
121
+ value: void 0
122
+ });
123
+ Object.defineProperty(this, "thickness", {
124
+ enumerable: true,
125
+ configurable: true,
126
+ writable: true,
127
+ value: void 0
128
+ });
129
+ Object.defineProperty(this, "cornerRadius", {
130
+ enumerable: true,
131
+ configurable: true,
132
+ writable: true,
133
+ value: void 0
134
+ });
135
+ Object.defineProperty(this, "trackColor", {
136
+ enumerable: true,
137
+ configurable: true,
138
+ writable: true,
139
+ value: void 0
140
+ });
141
+ Object.defineProperty(this, "progressColor", {
142
+ enumerable: true,
143
+ configurable: true,
144
+ writable: true,
145
+ value: void 0
146
+ });
147
+ Object.defineProperty(this, "targetColor", {
148
+ enumerable: true,
149
+ configurable: true,
150
+ writable: true,
151
+ value: void 0
152
+ });
153
+ Object.defineProperty(this, "segmentStyle", {
154
+ enumerable: true,
155
+ configurable: true,
156
+ writable: true,
157
+ value: void 0
158
+ });
159
+ Object.defineProperty(this, "valueFormatter", {
160
+ enumerable: true,
161
+ configurable: true,
162
+ writable: true,
163
+ value: void 0
164
+ });
165
+ Object.defineProperty(this, "showValue", {
166
+ enumerable: true,
167
+ configurable: true,
168
+ writable: true,
169
+ value: void 0
170
+ });
171
+ Object.defineProperty(this, "needle", {
172
+ enumerable: true,
173
+ configurable: true,
174
+ writable: true,
175
+ value: void 0
176
+ });
177
+ Object.defineProperty(this, "marker", {
178
+ enumerable: true,
179
+ configurable: true,
180
+ writable: true,
181
+ value: void 0
182
+ });
183
+ Object.defineProperty(this, "ticks", {
184
+ enumerable: true,
185
+ configurable: true,
186
+ writable: true,
187
+ value: void 0
188
+ });
189
+ Object.defineProperty(this, "tickLabelStyle", {
190
+ enumerable: true,
191
+ configurable: true,
192
+ writable: true,
193
+ value: void 0
194
+ });
195
+ Object.defineProperty(this, "valueLabelStyle", {
196
+ enumerable: true,
197
+ configurable: true,
198
+ writable: true,
199
+ value: void 0
200
+ });
201
+ Object.defineProperty(this, "segments", {
202
+ enumerable: true,
203
+ configurable: true,
204
+ writable: true,
205
+ value: []
206
+ });
207
+ Object.defineProperty(this, "value", {
208
+ enumerable: true,
209
+ configurable: true,
210
+ writable: true,
211
+ value: 0
212
+ });
213
+ Object.defineProperty(this, "targetValue", {
214
+ enumerable: true,
215
+ configurable: true,
216
+ writable: true,
217
+ value: null
218
+ });
219
+ Object.defineProperty(this, "defaultFormat", {
220
+ enumerable: true,
221
+ configurable: true,
222
+ writable: true,
223
+ value: (value) => {
224
+ if (Number.isInteger(value)) {
225
+ return String(value);
226
+ }
227
+ return value.toFixed(1);
228
+ }
229
+ });
230
+ const gauge = config.gauge ?? {};
231
+ this.configuredValue = gauge.value;
232
+ this.configuredTargetValue = gauge.targetValue;
233
+ this.configuredSegments = gauge.segments ?? [];
234
+ this.valueKey = config.valueKey ?? DEFAULT_VALUE_KEY;
235
+ this.targetValueKey = config.targetValueKey;
236
+ this.minValue = gauge.min ?? DEFAULT_MIN_VALUE;
237
+ this.maxValue = gauge.max ?? DEFAULT_MAX_VALUE;
238
+ this.halfCircle = gauge.halfCircle ?? DEFAULT_HALF_CIRCLE;
239
+ this.startAngle =
240
+ gauge.startAngle ??
241
+ (this.halfCircle ? DEFAULT_HALF_START_ANGLE : DEFAULT_START_ANGLE);
242
+ this.endAngle =
243
+ gauge.endAngle ??
244
+ (this.halfCircle ? DEFAULT_HALF_END_ANGLE : DEFAULT_END_ANGLE);
245
+ this.innerRadiusRatio = gauge.innerRadius ?? DEFAULT_INNER_RADIUS_RATIO;
246
+ this.thickness = gauge.thickness ?? null;
247
+ this.cornerRadius = gauge.cornerRadius ?? DEFAULT_CORNER_RADIUS;
248
+ this.trackColor = gauge.trackColor ?? DEFAULT_TRACK_COLOR;
249
+ this.progressColor =
250
+ gauge.progressColor ??
251
+ this.getThemePaletteColor(DEFAULT_THEME_PALETTE_INDEX);
252
+ this.targetColor = gauge.targetColor ?? DEFAULT_TARGET_COLOR;
253
+ this.segmentStyle = gauge.segmentStyle ?? DEFAULT_SEGMENT_STYLE;
254
+ this.valueFormatter = gauge.valueFormatter ?? this.defaultFormat;
255
+ this.showValue = gauge.showValue ?? DEFAULT_SHOW_VALUE;
256
+ this.needle = this.normalizeNeedleConfig(gauge.needle);
257
+ this.marker = this.normalizeMarkerConfig(gauge.marker, !this.needle.show);
258
+ this.ticks = this.normalizeTickConfig(gauge.ticks);
259
+ this.tickLabelStyle = this.normalizeTickLabelStyle(gauge.ticks?.labelStyle);
260
+ this.valueLabelStyle = this.normalizeValueLabelStyle(gauge.valueLabelStyle);
261
+ this.validateGaugeConfig();
262
+ this.segments = this.prepareSegments();
263
+ this.refreshResolvedValues();
264
+ }
265
+ normalizeNeedleConfig(config) {
266
+ if (config === false) {
267
+ return {
268
+ show: false,
269
+ color: DEFAULT_TARGET_COLOR,
270
+ width: DEFAULT_NEEDLE_WIDTH,
271
+ lengthRatio: DEFAULT_NEEDLE_LENGTH_RATIO,
272
+ capRadius: DEFAULT_NEEDLE_CAP_RADIUS,
273
+ };
274
+ }
275
+ if (config === true || config === undefined) {
276
+ return {
277
+ show: true,
278
+ color: DEFAULT_TARGET_COLOR,
279
+ width: DEFAULT_NEEDLE_WIDTH,
280
+ lengthRatio: DEFAULT_NEEDLE_LENGTH_RATIO,
281
+ capRadius: DEFAULT_NEEDLE_CAP_RADIUS,
282
+ };
283
+ }
284
+ return {
285
+ show: config.show ?? DEFAULT_NEEDLE_SHOW,
286
+ color: config.color ?? DEFAULT_TARGET_COLOR,
287
+ width: config.width ?? DEFAULT_NEEDLE_WIDTH,
288
+ lengthRatio: config.lengthRatio ?? DEFAULT_NEEDLE_LENGTH_RATIO,
289
+ capRadius: config.capRadius ?? DEFAULT_NEEDLE_CAP_RADIUS,
290
+ };
291
+ }
292
+ normalizeMarkerConfig(config, defaultShow) {
293
+ if (config === false) {
294
+ return {
295
+ show: false,
296
+ color: this.getThemePaletteColor(DEFAULT_THEME_PALETTE_INDEX),
297
+ width: DEFAULT_MARKER_WIDTH,
298
+ };
299
+ }
300
+ if (config === true || config === undefined) {
301
+ return {
302
+ show: defaultShow,
303
+ color: this.getThemePaletteColor(DEFAULT_THEME_PALETTE_INDEX),
304
+ width: DEFAULT_MARKER_WIDTH,
305
+ };
306
+ }
307
+ return {
308
+ show: config.show ?? defaultShow,
309
+ color: config.color ?? this.getThemePaletteColor(DEFAULT_THEME_PALETTE_INDEX),
310
+ width: config.width ?? DEFAULT_MARKER_WIDTH,
311
+ };
312
+ }
313
+ getThemePaletteColor(index) {
314
+ const palette = this.theme.colorPalette.length > 0
315
+ ? this.theme.colorPalette
316
+ : DEFAULT_COLOR_PALETTE;
317
+ return palette[index % palette.length];
318
+ }
319
+ normalizeTickConfig(config) {
320
+ return {
321
+ count: config?.count ?? DEFAULT_TICK_COUNT,
322
+ show: config?.show ?? DEFAULT_TICKS_SHOW,
323
+ showLines: config?.showLines ?? DEFAULT_TICKS_SHOW_LINES,
324
+ showLabels: config?.showLabels ?? DEFAULT_TICKS_SHOW_LABELS,
325
+ size: config?.size ?? DEFAULT_TICK_SIZE,
326
+ labelOffset: config?.labelOffset ?? DEFAULT_TICK_LABEL_OFFSET,
327
+ formatter: config?.formatter ?? this.defaultFormat,
328
+ };
329
+ }
330
+ normalizeTickLabelStyle(config) {
331
+ return {
332
+ fontSize: config?.fontSize ?? DEFAULT_TICK_LABEL_FONT_SIZE,
333
+ fontFamily: config?.fontFamily ?? this.theme.axis.fontFamily,
334
+ fontWeight: config?.fontWeight ??
335
+ this.theme.axis.fontWeight ??
336
+ DEFAULT_TICK_LABEL_FONT_WEIGHT,
337
+ color: config?.color ?? DEFAULT_TICK_LABEL_COLOR,
338
+ };
339
+ }
340
+ normalizeValueLabelStyle(config) {
341
+ return {
342
+ fontSize: config?.fontSize ?? DEFAULT_VALUE_LABEL_FONT_SIZE,
343
+ fontFamily: config?.fontFamily ?? this.theme.axis.fontFamily,
344
+ fontWeight: config?.fontWeight ?? DEFAULT_VALUE_LABEL_FONT_WEIGHT,
345
+ color: config?.color ?? DEFAULT_VALUE_LABEL_COLOR,
346
+ };
347
+ }
348
+ validateGaugeConfig() {
349
+ if (!Number.isFinite(this.minValue) ||
350
+ !Number.isFinite(this.maxValue)) {
351
+ throw new Error(`GaugeChart: gauge.min and gauge.max must be finite numbers, received min='${this.minValue}' and max='${this.maxValue}'`);
352
+ }
353
+ if (this.minValue >= this.maxValue) {
354
+ throw new Error(`GaugeChart: gauge.min must be less than gauge.max, received min='${this.minValue}' and max='${this.maxValue}'`);
355
+ }
356
+ if (!Number.isFinite(this.startAngle) ||
357
+ !Number.isFinite(this.endAngle)) {
358
+ throw new Error(`GaugeChart: gauge.startAngle and gauge.endAngle must be finite numbers, received startAngle='${this.startAngle}' and endAngle='${this.endAngle}'`);
359
+ }
360
+ if (this.endAngle <= this.startAngle) {
361
+ throw new Error(`GaugeChart: gauge.endAngle must be greater than gauge.startAngle, received startAngle='${this.startAngle}' and endAngle='${this.endAngle}'`);
362
+ }
363
+ if (this.innerRadiusRatio < 0 || this.innerRadiusRatio >= 1) {
364
+ throw new Error(`GaugeChart: gauge.innerRadius must be between 0 and <1, received '${this.innerRadiusRatio}'`);
365
+ }
366
+ if (this.cornerRadius < 0) {
367
+ throw new Error(`GaugeChart: gauge.cornerRadius must be >= 0, received '${this.cornerRadius}'`);
368
+ }
369
+ if (this.thickness !== null &&
370
+ (!Number.isFinite(this.thickness) || this.thickness <= 0)) {
371
+ throw new Error(`GaugeChart: gauge.thickness must be > 0 when provided, received '${this.thickness}'`);
372
+ }
373
+ if (!Number.isInteger(this.ticks.count) || this.ticks.count < 0) {
374
+ throw new Error(`GaugeChart: gauge.ticks.count must be a non-negative integer, received '${this.ticks.count}'`);
375
+ }
376
+ if (this.ticks.size < 0) {
377
+ throw new Error(`GaugeChart: gauge.ticks.size must be >= 0, received '${this.ticks.size}'`);
378
+ }
379
+ if (this.ticks.labelOffset < 0) {
380
+ throw new Error(`GaugeChart: gauge.ticks.labelOffset must be >= 0, received '${this.ticks.labelOffset}'`);
381
+ }
382
+ if (this.needle.width <= 0) {
383
+ throw new Error(`GaugeChart: gauge.needle.width must be > 0, received '${this.needle.width}'`);
384
+ }
385
+ if (this.needle.lengthRatio <= 0 || this.needle.lengthRatio > 1.2) {
386
+ throw new Error(`GaugeChart: gauge.needle.lengthRatio must be > 0 and <= 1.2, received '${this.needle.lengthRatio}'`);
387
+ }
388
+ if (this.needle.capRadius < 0) {
389
+ throw new Error(`GaugeChart: gauge.needle.capRadius must be >= 0, received '${this.needle.capRadius}'`);
390
+ }
391
+ if (this.marker.width <= 0) {
392
+ throw new Error(`GaugeChart: gauge.marker.width must be > 0, received '${this.marker.width}'`);
393
+ }
394
+ this.validateSegments(this.configuredSegments);
395
+ }
396
+ validateSegments(segments) {
397
+ if (segments.length === 0) {
398
+ return;
399
+ }
400
+ segments.forEach((segment, index) => {
401
+ if (!Number.isFinite(segment.from) ||
402
+ !Number.isFinite(segment.to)) {
403
+ throw new Error(`GaugeChart: gauge.segments[${index}] must use finite from/to values, received from='${segment.from}' and to='${segment.to}'`);
404
+ }
405
+ if (segment.from >= segment.to) {
406
+ throw new Error(`GaugeChart: gauge.segments[${index}] must have from < to, received from='${segment.from}' and to='${segment.to}'`);
407
+ }
408
+ if (segment.from < this.minValue || segment.to > this.maxValue) {
409
+ throw new Error(`GaugeChart: gauge.segments[${index}] must be within [${this.minValue}, ${this.maxValue}], received from='${segment.from}' and to='${segment.to}'`);
410
+ }
411
+ });
412
+ const sortedSegments = [...segments].sort((left, right) => {
413
+ return left.from - right.from;
414
+ });
415
+ for (let index = 1; index < sortedSegments.length; index += 1) {
416
+ const previous = sortedSegments[index - 1];
417
+ const current = sortedSegments[index];
418
+ if (current.from < previous.to) {
419
+ throw new Error(`GaugeChart: gauge.segments overlap between [${previous.from}, ${previous.to}] and [${current.from}, ${current.to}]`);
420
+ }
421
+ }
422
+ }
423
+ refreshResolvedValues() {
424
+ this.value = this.resolveValue(this.configuredValue);
425
+ this.targetValue = this.resolveTargetValue(this.configuredTargetValue);
426
+ }
427
+ resolveValue(configuredValue) {
428
+ if (configuredValue !== undefined) {
429
+ const parsed = this.parseFiniteNumber(configuredValue, 'gauge.value');
430
+ return this.clampToDomain(parsed, 'value');
431
+ }
432
+ ChartValidator.validateDataKey(this.data, this.valueKey, 'GaugeChart');
433
+ this.warnIfMultipleRows('value');
434
+ const rawValue = this.data[0][this.valueKey];
435
+ const parsed = this.parseFiniteNumber(rawValue, `data['${this.valueKey}']`);
436
+ return this.clampToDomain(parsed, 'value');
437
+ }
438
+ resolveTargetValue(configuredTargetValue) {
439
+ if (configuredTargetValue !== undefined) {
440
+ const parsed = this.parseFiniteNumber(configuredTargetValue, 'gauge.targetValue');
441
+ return this.clampToDomain(parsed, 'targetValue');
442
+ }
443
+ if (!this.targetValueKey) {
444
+ return null;
445
+ }
446
+ ChartValidator.validateDataKey(this.data, this.targetValueKey, 'GaugeChart');
447
+ this.warnIfMultipleRows('targetValue');
448
+ const rawTargetValue = this.data[0][this.targetValueKey];
449
+ if (rawTargetValue === undefined ||
450
+ rawTargetValue === null ||
451
+ rawTargetValue === '') {
452
+ return null;
453
+ }
454
+ const parsed = this.parseFiniteNumber(rawTargetValue, `data['${this.targetValueKey}']`);
455
+ return this.clampToDomain(parsed, 'targetValue');
456
+ }
457
+ warnIfMultipleRows(field) {
458
+ if (this.data.length <= 1) {
459
+ return;
460
+ }
461
+ ChartValidator.warn(`GaugeChart: received ${this.data.length} rows; using the first row for ${field}`);
462
+ }
463
+ parseFiniteNumber(rawValue, fieldLabel) {
464
+ const parsed = typeof rawValue === 'number'
465
+ ? rawValue
466
+ : typeof rawValue === 'string'
467
+ ? parseFloat(rawValue)
468
+ : NaN;
469
+ if (!Number.isFinite(parsed)) {
470
+ throw new Error(`GaugeChart: ${fieldLabel} must be a finite number, received '${rawValue}'`);
471
+ }
472
+ return parsed;
473
+ }
474
+ clampToDomain(value, label) {
475
+ const clamped = Math.min(this.maxValue, Math.max(this.minValue, value));
476
+ if (clamped !== value) {
477
+ ChartValidator.warn(`GaugeChart: clamped ${label} from '${value}' to '${clamped}' within [${this.minValue}, ${this.maxValue}]`);
478
+ }
479
+ return clamped;
480
+ }
481
+ prepareSegments() {
482
+ if (this.configuredSegments.length === 0) {
483
+ return [];
484
+ }
485
+ const sortedSegments = [...this.configuredSegments].sort((left, right) => {
486
+ return left.from - right.from;
487
+ });
488
+ const labelCount = new Map();
489
+ return sortedSegments.map((segment, index) => {
490
+ const baseLabel = segment.label?.trim()
491
+ ? segment.label.trim()
492
+ : `${this.defaultFormat(segment.from)}-${this.defaultFormat(segment.to)}`;
493
+ const existingCount = labelCount.get(baseLabel) ?? 0;
494
+ labelCount.set(baseLabel, existingCount + 1);
495
+ return {
496
+ ...segment,
497
+ color: segment.color ??
498
+ this.getThemePaletteColor(index),
499
+ legendLabel: existingCount === 0
500
+ ? baseLabel
501
+ : `${baseLabel} (${existingCount + 1})`,
502
+ };
503
+ });
504
+ }
505
+ addChild(component) {
506
+ const type = component.type;
507
+ if (type === 'tooltip') {
508
+ this.tooltip = component;
509
+ }
510
+ else if (type === 'legend') {
511
+ this.legend = component;
512
+ this.legend.setToggleCallback(() => this.update(this.data));
513
+ }
514
+ else if (type === 'title') {
515
+ this.title = component;
516
+ }
517
+ return this;
518
+ }
519
+ getExportComponents() {
520
+ const components = [];
521
+ if (this.title) {
522
+ components.push(this.title);
523
+ }
524
+ if (this.tooltip) {
525
+ components.push(this.tooltip);
526
+ }
527
+ if (this.legend) {
528
+ components.push(this.legend);
529
+ }
530
+ return components;
531
+ }
532
+ update(data) {
533
+ this.data = data;
534
+ this.refreshResolvedValues();
535
+ super.update(data);
536
+ }
537
+ getLayoutComponents() {
538
+ const components = [];
539
+ if (this.title) {
540
+ components.push(this.title);
541
+ }
542
+ if (this.legend) {
543
+ components.push(this.legend);
544
+ }
545
+ return components;
546
+ }
547
+ createExportChart() {
548
+ return new GaugeChart({
549
+ data: this.data,
550
+ theme: this.theme,
551
+ valueKey: this.valueKey,
552
+ targetValueKey: this.targetValueKey,
553
+ gauge: {
554
+ value: this.configuredValue,
555
+ targetValue: this.configuredTargetValue,
556
+ min: this.minValue,
557
+ max: this.maxValue,
558
+ halfCircle: this.halfCircle,
559
+ startAngle: this.startAngle,
560
+ endAngle: this.endAngle,
561
+ innerRadius: this.innerRadiusRatio,
562
+ thickness: this.thickness ?? undefined,
563
+ cornerRadius: this.cornerRadius,
564
+ trackColor: this.trackColor,
565
+ progressColor: this.progressColor,
566
+ targetColor: this.targetColor,
567
+ segmentStyle: this.segmentStyle,
568
+ segments: this.configuredSegments,
569
+ needle: this.needle,
570
+ marker: this.marker,
571
+ showValue: this.showValue,
572
+ valueFormatter: this.valueFormatter,
573
+ valueLabelStyle: this.valueLabelStyle,
574
+ ticks: {
575
+ ...this.ticks,
576
+ labelStyle: this.tickLabelStyle,
577
+ },
578
+ },
579
+ });
580
+ }
581
+ renderChart() {
582
+ if (!this.plotArea || !this.svg || !this.plotGroup) {
583
+ throw new Error('Plot area not calculated');
584
+ }
585
+ this.svg.attr('role', 'img').attr('aria-label', this.buildAriaLabel());
586
+ if (this.title) {
587
+ const titlePosition = this.layoutManager.getComponentPosition(this.title);
588
+ this.title.render(this.svg, this.theme, this.width, titlePosition.x, titlePosition.y);
589
+ }
590
+ if (this.tooltip) {
591
+ this.tooltip.initialize(this.theme);
592
+ }
593
+ const labelAllowance = this.ticks.show && this.ticks.showLabels
594
+ ? this.ticks.size + this.ticks.labelOffset + 14
595
+ : this.ticks.show && this.ticks.showLines
596
+ ? this.ticks.size + 10
597
+ : 8;
598
+ let outerRadius;
599
+ const centerX = this.plotArea.left + this.plotArea.width / 2;
600
+ let centerY;
601
+ if (this.halfCircle) {
602
+ const valueSpace = this.showValue
603
+ ? DEFAULT_HALF_CIRCLE_VALUE_SPACE_WITH_LABEL
604
+ : DEFAULT_HALF_CIRCLE_VALUE_SPACE_WITHOUT_LABEL;
605
+ const maxHorizontalRadius = this.plotArea.width / 2 - labelAllowance - 8;
606
+ const maxVerticalRadius = this.plotArea.height - valueSpace - labelAllowance - 8;
607
+ outerRadius = Math.max(24, Math.min(maxHorizontalRadius, maxVerticalRadius));
608
+ centerY = this.plotArea.top + labelAllowance + outerRadius + 4;
609
+ }
610
+ else {
611
+ const maxRadius = Math.min(this.plotArea.width, this.plotArea.height) / 2;
612
+ outerRadius = Math.max(24, maxRadius - labelAllowance);
613
+ centerY = this.plotArea.top + this.plotArea.height / 2;
614
+ }
615
+ const innerRadius = this.thickness !== null
616
+ ? Math.max(0, outerRadius - Math.min(this.thickness, outerRadius - 1))
617
+ : Math.max(8, Math.min(outerRadius - 4, outerRadius * this.innerRadiusRatio));
618
+ const gaugeGroup = this.plotGroup
619
+ .append('g')
620
+ .attr('class', 'gauge')
621
+ .attr('transform', `translate(${centerX}, ${centerY})`);
622
+ this.renderTrack(gaugeGroup, innerRadius, outerRadius);
623
+ const visibleSegments = this.getVisibleSegments();
624
+ if (visibleSegments.length > 0) {
625
+ if (this.segmentStyle === 'gradient') {
626
+ this.renderGradientSegments(gaugeGroup, visibleSegments, innerRadius, outerRadius);
627
+ }
628
+ else {
629
+ this.renderSegments(gaugeGroup, visibleSegments, innerRadius, outerRadius);
630
+ }
631
+ }
632
+ const progressColor = this.resolveProgressColor(visibleSegments);
633
+ const shouldRenderProgress = visibleSegments.length === 0;
634
+ if (shouldRenderProgress) {
635
+ const progressRadii = this.getProgressRadii(innerRadius, outerRadius);
636
+ this.renderProgress(gaugeGroup, progressColor, progressRadii.inner, progressRadii.outer);
637
+ }
638
+ if (this.ticks.show &&
639
+ this.ticks.count > 0 &&
640
+ (this.ticks.showLines || this.ticks.showLabels)) {
641
+ this.renderTicks(gaugeGroup, outerRadius);
642
+ }
643
+ if (this.showValue) {
644
+ this.renderValueText(gaugeGroup, outerRadius);
645
+ }
646
+ if (this.targetValue !== null) {
647
+ this.renderTargetMarker(gaugeGroup, innerRadius, outerRadius);
648
+ }
649
+ if (this.needle.show) {
650
+ this.renderNeedle(gaugeGroup, innerRadius, outerRadius);
651
+ }
652
+ else if (this.marker.show) {
653
+ this.renderCurrentValueMarker(gaugeGroup, innerRadius, outerRadius);
654
+ }
655
+ if (this.tooltip) {
656
+ this.attachTooltipLayer(gaugeGroup, innerRadius, outerRadius, progressColor);
657
+ }
658
+ this.renderLegend();
659
+ }
660
+ buildAriaLabel() {
661
+ const statusLabel = this.findSegmentStatusLabel();
662
+ const targetText = this.targetValue === null
663
+ ? ''
664
+ : ` Target ${this.valueFormatter(this.targetValue)}.`;
665
+ const statusText = statusLabel ? ` Status: ${statusLabel}.` : '';
666
+ return `Gauge chart. Value ${this.valueFormatter(this.value)} on scale ${this.valueFormatter(this.minValue)} to ${this.valueFormatter(this.maxValue)}.${targetText}${statusText}`;
667
+ }
668
+ findSegmentStatusLabel() {
669
+ if (this.segments.length === 0) {
670
+ return null;
671
+ }
672
+ for (const segment of this.segments) {
673
+ if (this.value >= segment.from && this.value <= segment.to) {
674
+ return segment.label ?? segment.legendLabel;
675
+ }
676
+ }
677
+ return null;
678
+ }
679
+ getVisibleSegments() {
680
+ if (!this.legend) {
681
+ return this.segments;
682
+ }
683
+ return this.segments.filter((segment) => {
684
+ return this.legend.isSeriesVisible(segment.legendLabel);
685
+ });
686
+ }
687
+ resolveProgressColor(segments) {
688
+ for (const segment of segments) {
689
+ if (this.value >= segment.from && this.value <= segment.to) {
690
+ return segment.color;
691
+ }
692
+ }
693
+ return this.progressColor;
694
+ }
695
+ valueToAngle(value) {
696
+ const ratio = (value - this.minValue) / (this.maxValue - this.minValue);
697
+ const clampedRatio = Math.min(1, Math.max(0, ratio));
698
+ return (this.startAngle + clampedRatio * (this.endAngle - this.startAngle));
699
+ }
700
+ getProgressRadii(innerRadius, outerRadius) {
701
+ const bandThickness = Math.max(0, outerRadius - innerRadius);
702
+ const maxInset = Math.max(0, (bandThickness - MIN_PROGRESS_BAND_THICKNESS) / 2);
703
+ const inset = Math.min(DEFAULT_PROGRESS_RADIUS_INSET, maxInset);
704
+ return {
705
+ inner: innerRadius + inset,
706
+ outer: outerRadius - inset,
707
+ };
708
+ }
709
+ pointAt(angle, radius) {
710
+ return {
711
+ x: Math.sin(angle) * radius,
712
+ y: -Math.cos(angle) * radius,
713
+ };
714
+ }
715
+ renderTrack(gaugeGroup, innerRadius, outerRadius) {
716
+ const trackArc = arc()
717
+ .innerRadius(innerRadius)
718
+ .outerRadius(outerRadius)
719
+ .startAngle(this.startAngle)
720
+ .endAngle(this.endAngle)
721
+ .cornerRadius(this.cornerRadius);
722
+ gaugeGroup
723
+ .append('path')
724
+ .attr('class', 'gauge-track')
725
+ .attr('fill', this.trackColor)
726
+ .attr('d', trackArc(DUMMY_ARC_DATUM));
727
+ }
728
+ renderSegments(gaugeGroup, segments, innerRadius, outerRadius) {
729
+ const segmentArc = arc()
730
+ .innerRadius(innerRadius)
731
+ .outerRadius(outerRadius)
732
+ .startAngle((segment) => this.valueToAngle(segment.from))
733
+ .endAngle((segment) => this.valueToAngle(segment.to))
734
+ .cornerRadius(this.cornerRadius);
735
+ gaugeGroup
736
+ .append('g')
737
+ .attr('class', 'gauge-segments')
738
+ .selectAll('.gauge-segment')
739
+ .data(segments)
740
+ .join('path')
741
+ .attr('class', 'gauge-segment')
742
+ .attr('fill', (segment) => segment.color)
743
+ .attr('opacity', 0.92)
744
+ .attr('d', (segment) => segmentArc(segment));
745
+ }
746
+ renderGradientSegments(gaugeGroup, segments, innerRadius, outerRadius) {
747
+ if (!this.svg || segments.length === 0) {
748
+ return;
749
+ }
750
+ const defs = this.svg.select('defs').empty()
751
+ ? this.svg.append('defs')
752
+ : this.svg.select('defs');
753
+ const gradientId = `gauge-gradient-${Math.random()
754
+ .toString(36)
755
+ .slice(2, 10)}`;
756
+ const midRadius = (innerRadius + outerRadius) / 2;
757
+ const startPoint = this.pointAt(this.startAngle, midRadius);
758
+ const endPoint = this.pointAt(this.endAngle, midRadius);
759
+ const strokeWidth = Math.max(1, outerRadius - innerRadius);
760
+ const sortedSegments = [...segments].sort((left, right) => {
761
+ return left.from - right.from;
762
+ });
763
+ const gradient = defs
764
+ .append('linearGradient')
765
+ .attr('id', gradientId)
766
+ .attr('gradientUnits', 'userSpaceOnUse')
767
+ .attr('x1', startPoint.x)
768
+ .attr('y1', startPoint.y)
769
+ .attr('x2', endPoint.x)
770
+ .attr('y2', endPoint.y);
771
+ if (sortedSegments.length > 0) {
772
+ const firstSegment = sortedSegments[0];
773
+ gradient
774
+ .append('stop')
775
+ .attr('offset', '0%')
776
+ .attr('stop-color', firstSegment.color);
777
+ sortedSegments.forEach((segment) => {
778
+ const toOffset = ((segment.to - this.minValue) /
779
+ (this.maxValue - this.minValue)) *
780
+ 100;
781
+ gradient
782
+ .append('stop')
783
+ .attr('offset', `${Math.min(100, Math.max(0, toOffset)).toFixed(3)}%`)
784
+ .attr('stop-color', segment.color);
785
+ });
786
+ }
787
+ const sweep = this.endAngle - this.startAngle;
788
+ const largeArcFlag = Math.abs(sweep) > Math.PI ? 1 : 0;
789
+ const sweepFlag = sweep >= 0 ? 1 : 0;
790
+ const gradientPath = `M${startPoint.x},${startPoint.y} A${midRadius},${midRadius} 0 ${largeArcFlag},${sweepFlag} ${endPoint.x},${endPoint.y}`;
791
+ gaugeGroup
792
+ .append('path')
793
+ .attr('class', 'gauge-gradient-ring')
794
+ .attr('d', gradientPath)
795
+ .attr('fill', 'none')
796
+ .attr('stroke', `url(#${gradientId})`)
797
+ .attr('stroke-width', strokeWidth)
798
+ .attr('stroke-linecap', 'butt')
799
+ .attr('stroke-opacity', 0.95)
800
+ .style('pointer-events', 'none');
801
+ }
802
+ renderProgress(gaugeGroup, progressColor, innerRadius, outerRadius) {
803
+ const progressArc = arc()
804
+ .innerRadius(innerRadius)
805
+ .outerRadius(outerRadius)
806
+ .startAngle(this.startAngle)
807
+ .endAngle(this.valueToAngle(this.value))
808
+ .cornerRadius(this.cornerRadius);
809
+ gaugeGroup
810
+ .append('path')
811
+ .attr('class', 'gauge-progress')
812
+ .attr('fill', progressColor)
813
+ .attr('d', progressArc(DUMMY_ARC_DATUM));
814
+ }
815
+ renderTicks(gaugeGroup, outerRadius) {
816
+ const tickGroup = gaugeGroup.append('g').attr('class', 'gauge-ticks');
817
+ for (let tickIndex = 0; tickIndex <= this.ticks.count; tickIndex += 1) {
818
+ const ratio = tickIndex / this.ticks.count;
819
+ const value = this.minValue + ratio * (this.maxValue - this.minValue);
820
+ const angle = this.valueToAngle(value);
821
+ const innerTickRadius = outerRadius + 2;
822
+ const outerTickRadius = innerTickRadius + this.ticks.size;
823
+ const innerPoint = this.pointAt(angle, innerTickRadius);
824
+ const outerPoint = this.pointAt(angle, outerTickRadius);
825
+ if (this.ticks.showLines) {
826
+ tickGroup
827
+ .append('line')
828
+ .attr('class', 'gauge-tick')
829
+ .attr('x1', innerPoint.x)
830
+ .attr('y1', innerPoint.y)
831
+ .attr('x2', outerPoint.x)
832
+ .attr('y2', outerPoint.y)
833
+ .attr('stroke', DEFAULT_TICK_LINE_COLOR)
834
+ .attr('stroke-width', DEFAULT_TICK_LINE_STROKE_WIDTH);
835
+ }
836
+ if (!this.ticks.showLabels) {
837
+ continue;
838
+ }
839
+ const labelRadius = outerTickRadius + this.ticks.labelOffset;
840
+ const labelPoint = this.pointAt(angle, labelRadius);
841
+ const alignment = Math.abs(labelPoint.x) < 10
842
+ ? 'middle'
843
+ : labelPoint.x > 0
844
+ ? 'start'
845
+ : 'end';
846
+ tickGroup
847
+ .append('text')
848
+ .attr('class', 'gauge-tick-label')
849
+ .attr('x', labelPoint.x)
850
+ .attr('y', labelPoint.y)
851
+ .attr('text-anchor', alignment)
852
+ .attr('dominant-baseline', 'middle')
853
+ .attr('font-size', this.tickLabelStyle.fontSize)
854
+ .attr('font-family', this.tickLabelStyle.fontFamily)
855
+ .attr('font-weight', this.tickLabelStyle.fontWeight)
856
+ .attr('fill', this.tickLabelStyle.color)
857
+ .text(this.ticks.formatter(value));
858
+ }
859
+ }
860
+ renderTargetMarker(gaugeGroup, innerRadius, outerRadius) {
861
+ if (this.targetValue === null) {
862
+ return;
863
+ }
864
+ const targetAngle = this.valueToAngle(this.targetValue);
865
+ const markerInner = innerRadius + 1;
866
+ const markerOuter = Math.max(markerInner + 1, outerRadius - 1);
867
+ const innerPoint = this.pointAt(targetAngle, markerInner);
868
+ const outerPoint = this.pointAt(targetAngle, markerOuter);
869
+ gaugeGroup
870
+ .append('line')
871
+ .attr('class', 'gauge-target-marker')
872
+ .attr('x1', innerPoint.x)
873
+ .attr('y1', innerPoint.y)
874
+ .attr('x2', outerPoint.x)
875
+ .attr('y2', outerPoint.y)
876
+ .attr('stroke', this.targetColor)
877
+ .attr('stroke-width', DEFAULT_TARGET_MARKER_STROKE_WIDTH)
878
+ .attr('stroke-linecap', 'round');
879
+ }
880
+ renderNeedle(gaugeGroup, innerRadius, outerRadius) {
881
+ const needleAngle = this.valueToAngle(this.value);
882
+ const maxLength = Math.max(innerRadius + 2, outerRadius - 2);
883
+ const length = maxLength * this.needle.lengthRatio;
884
+ const needlePoint = this.pointAt(needleAngle, length);
885
+ gaugeGroup
886
+ .append('line')
887
+ .attr('class', 'gauge-needle')
888
+ .attr('x1', 0)
889
+ .attr('y1', 0)
890
+ .attr('x2', needlePoint.x)
891
+ .attr('y2', needlePoint.y)
892
+ .attr('stroke', this.needle.color)
893
+ .attr('stroke-width', this.needle.width)
894
+ .attr('stroke-linecap', 'round');
895
+ gaugeGroup
896
+ .append('circle')
897
+ .attr('class', 'gauge-needle-cap')
898
+ .attr('cx', 0)
899
+ .attr('cy', 0)
900
+ .attr('r', this.needle.capRadius)
901
+ .attr('fill', this.needle.color);
902
+ }
903
+ renderCurrentValueMarker(gaugeGroup, innerRadius, outerRadius) {
904
+ const markerAngle = this.valueToAngle(this.value);
905
+ const markerInner = innerRadius + 1;
906
+ const markerOuter = Math.max(markerInner + 1, outerRadius - 1);
907
+ const innerPoint = this.pointAt(markerAngle, markerInner);
908
+ const outerPoint = this.pointAt(markerAngle, markerOuter);
909
+ gaugeGroup
910
+ .append('line')
911
+ .attr('class', 'gauge-marker')
912
+ .attr('x1', innerPoint.x)
913
+ .attr('y1', innerPoint.y)
914
+ .attr('x2', outerPoint.x)
915
+ .attr('y2', outerPoint.y)
916
+ .attr('stroke', this.marker.color)
917
+ .attr('stroke-width', this.marker.width)
918
+ .attr('stroke-linecap', 'round');
919
+ }
920
+ renderValueText(gaugeGroup, outerRadius) {
921
+ const mainValueY = this.halfCircle
922
+ ? DEFAULT_HALF_CIRCLE_VALUE_TEXT_Y
923
+ : Math.max(DEFAULT_FULL_CIRCLE_VALUE_TEXT_MIN_Y, outerRadius * 0.58);
924
+ gaugeGroup
925
+ .append('text')
926
+ .attr('class', 'gauge-value')
927
+ .attr('x', 0)
928
+ .attr('y', mainValueY)
929
+ .attr('text-anchor', 'middle')
930
+ .attr('font-size', this.valueLabelStyle.fontSize)
931
+ .attr('font-weight', this.valueLabelStyle.fontWeight)
932
+ .attr('font-family', this.valueLabelStyle.fontFamily)
933
+ .attr('fill', this.valueLabelStyle.color)
934
+ .text(this.valueFormatter(this.value));
935
+ }
936
+ attachTooltipLayer(gaugeGroup, innerRadius, outerRadius, progressColor) {
937
+ const interactionArc = arc()
938
+ .innerRadius(Math.max(0, innerRadius - 20))
939
+ .outerRadius(outerRadius + 20)
940
+ .startAngle(this.startAngle)
941
+ .endAngle(this.endAngle);
942
+ gaugeGroup
943
+ .append('path')
944
+ .attr('class', 'gauge-interaction-layer')
945
+ .attr('fill', 'transparent')
946
+ .attr('d', interactionArc(DUMMY_ARC_DATUM))
947
+ .style('pointer-events', 'all')
948
+ .style('cursor', 'pointer')
949
+ .on('mouseenter', (event) => {
950
+ const tooltipDiv = this.resolveTooltipDiv();
951
+ if (!tooltipDiv) {
952
+ return;
953
+ }
954
+ tooltipDiv
955
+ .style('visibility', 'visible')
956
+ .html(this.buildTooltipContent(progressColor));
957
+ this.positionTooltip(event, tooltipDiv);
958
+ })
959
+ .on('mousemove', (event) => {
960
+ const tooltipDiv = this.resolveTooltipDiv();
961
+ if (!tooltipDiv) {
962
+ return;
963
+ }
964
+ tooltipDiv
965
+ .style('visibility', 'visible')
966
+ .html(this.buildTooltipContent(progressColor));
967
+ this.positionTooltip(event, tooltipDiv);
968
+ })
969
+ .on('mouseleave', () => {
970
+ const tooltipDiv = this.resolveTooltipDiv();
971
+ if (!tooltipDiv) {
972
+ return;
973
+ }
974
+ tooltipDiv.style('visibility', 'hidden');
975
+ });
976
+ }
977
+ resolveTooltipDiv() {
978
+ if (!this.tooltip) {
979
+ return null;
980
+ }
981
+ return select(`#${this.tooltip.id}`);
982
+ }
983
+ buildTooltipContent(progressColor) {
984
+ const payload = {
985
+ value: this.value,
986
+ targetValue: this.targetValue,
987
+ min: this.minValue,
988
+ max: this.maxValue,
989
+ };
990
+ if (this.tooltip?.customFormatter) {
991
+ const series = [
992
+ { dataKey: 'value', fill: progressColor },
993
+ { dataKey: 'targetValue', stroke: this.targetColor },
994
+ ];
995
+ return this.tooltip.customFormatter(payload, series);
996
+ }
997
+ if (this.tooltip?.formatter) {
998
+ let content = '<strong>Gauge</strong><br/>';
999
+ content += this.tooltip.formatter('value', this.value, payload);
1000
+ if (this.targetValue !== null) {
1001
+ content += '<br/>';
1002
+ content += this.tooltip.formatter('targetValue', this.targetValue, payload);
1003
+ }
1004
+ return content;
1005
+ }
1006
+ let content = `<strong>Value</strong>: ${this.valueFormatter(this.value)}`;
1007
+ if (this.targetValue !== null) {
1008
+ content += `<br/><strong>Target</strong>: ${this.valueFormatter(this.targetValue)}`;
1009
+ }
1010
+ return content;
1011
+ }
1012
+ positionTooltip(event, tooltipDiv) {
1013
+ const node = tooltipDiv.node();
1014
+ if (!node) {
1015
+ return;
1016
+ }
1017
+ const rect = node.getBoundingClientRect();
1018
+ let x = event.pageX + TOOLTIP_OFFSET_PX;
1019
+ let y = event.pageY - rect.height / 2;
1020
+ if (x + rect.width > window.innerWidth - EDGE_MARGIN_PX) {
1021
+ x = event.pageX - rect.width - TOOLTIP_OFFSET_PX;
1022
+ }
1023
+ x = Math.max(EDGE_MARGIN_PX, x);
1024
+ y = Math.max(EDGE_MARGIN_PX, Math.min(y, window.innerHeight +
1025
+ window.scrollY -
1026
+ rect.height -
1027
+ EDGE_MARGIN_PX));
1028
+ tooltipDiv.style('left', `${x}px`).style('top', `${y}px`);
1029
+ }
1030
+ renderLegend() {
1031
+ if (!this.legend || !this.svg || this.segments.length === 0) {
1032
+ return;
1033
+ }
1034
+ const legendPosition = this.layoutManager.getComponentPosition(this.legend);
1035
+ const legendSeries = this.segments.map((segment) => ({
1036
+ dataKey: segment.legendLabel,
1037
+ fill: segment.color,
1038
+ }));
1039
+ this.legend.render(this.svg, legendSeries, this.theme, this.width, legendPosition.x, legendPosition.y);
1040
+ }
1041
+ }