@countermeasure-platform/web-components 1.2.2-dev.16.1 → 1.2.2-dev.18.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.
@@ -8,12 +8,36 @@ import { defaultColors, clamp, formatNumber } from '../utils'
8
8
  import type { GaugeChartConfig, GaugeThreshold } from './types'
9
9
 
10
10
  const DEG_TO_RAD = Math.PI / 180
11
- const BG_ARC_COLOR = '#e2e8f0'
12
- const NEEDLE_COLOR = '#1e293b'
13
- const VALUE_FONT = 'bold 28px system-ui, sans-serif'
14
- const UNIT_FONT = '14px system-ui, sans-serif'
15
- const LABEL_FONT = '13px system-ui, sans-serif'
16
- const LABEL_COLOR = '#64748b'
11
+
12
+ const VALUE_FONT_DEFAULT = '700 32px ui-sans-serif, system-ui, -apple-system, sans-serif'
13
+ const LABEL_FONT = '500 13px ui-sans-serif, system-ui, -apple-system, sans-serif'
14
+ const MINMAX_FONT = '500 11px ui-sans-serif, system-ui, -apple-system, sans-serif'
15
+
16
+ // Shared alpha constants — used both for the resolved-token hsl(...) path and
17
+ // the literal FALLBACK colors below so the rendered translucency matches
18
+ // regardless of whether theme tokens are present.
19
+ const TRACK_ALPHA = 0.18
20
+ const TRACK_INNER_ALPHA = 0.08
21
+ const NEEDLE_SOFT_ALPHA = 0.3
22
+ const SHADOW_ALPHA = 0.25
23
+
24
+ const FALLBACK = {
25
+ track: `rgba(148, 163, 184, ${String(TRACK_ALPHA)})`,
26
+ trackInner: `rgba(148, 163, 184, ${String(TRACK_INNER_ALPHA)})`,
27
+ foreground: '#0f172a',
28
+ muted: '#64748b',
29
+ needle: '#0f172a',
30
+ needleSoft: `rgba(15, 23, 42, ${String(NEEDLE_SOFT_ALPHA)})`,
31
+ fill: '#3b82f6',
32
+ shadow: `rgba(15, 23, 42, ${String(SHADOW_ALPHA)})`,
33
+ }
34
+ const DEFAULT_FILL = defaultColors()[0] ?? FALLBACK.fill
35
+
36
+ // Hard cap on full-turn additions so a pathological caller (e.g. start=NaN or
37
+ // `Number.MAX_SAFE_INTEGER`) can never spin the normalization loop forever. In
38
+ // practice angles are within a turn or two; 32 turns is far more than needed
39
+ // while still being a tight finite bound.
40
+ const MAX_ANGLE_NORMALIZE_TURNS = 32
17
41
 
18
42
  export class GaugeChart extends BaseChart {
19
43
  private value: number
@@ -38,6 +62,11 @@ export class GaugeChart extends BaseChart {
38
62
  this.arcWidth = config.arcWidth ?? 20
39
63
  this.startAngle = config.startAngle ?? 135
40
64
  this.endAngle = config.endAngle ?? 405
65
+ // Normalize: `endAngle` must produce a positive sweep. If a consumer passes
66
+ // an `endAngle <= startAngle`, advance it by full turns so Canvas draws
67
+ // the intended arc rather than wrapping the wrong way around — even when
68
+ // the consumer-supplied values are more than one turn apart.
69
+ this.normalizeAngles()
41
70
  this.showValue = config.showValue ?? true
42
71
 
43
72
  this.render()
@@ -78,48 +107,161 @@ export class GaugeChart extends BaseChart {
78
107
  if (config.endAngle !== undefined) {
79
108
  this.endAngle = config.endAngle
80
109
  }
110
+ // Re-normalize after either angle changes so consumers updating one without
111
+ // the other can't accidentally invert the sweep — and so that inputs more
112
+ // than one full turn apart still resolve to a positive sweep.
113
+ if (config.startAngle !== undefined || config.endAngle !== undefined) {
114
+ this.normalizeAngles()
115
+ }
81
116
  if (config.showValue !== undefined) {
82
117
  this.showValue = config.showValue
83
118
  }
84
119
  this.render()
85
120
  }
86
121
 
122
+ /**
123
+ * Advance `endAngle` by whole 360° turns until it exceeds `startAngle`, so
124
+ * Canvas always draws a positive sweep regardless of how the consumer
125
+ * supplied the angles. Capped at `MAX_ANGLE_NORMALIZE_TURNS` iterations to
126
+ * guarantee termination on pathological inputs (NaN, ±Infinity, very large
127
+ * negative deltas).
128
+ */
129
+ private normalizeAngles(): void {
130
+ for (let i = 0; i < MAX_ANGLE_NORMALIZE_TURNS && this.endAngle <= this.startAngle; i++) {
131
+ this.endAngle += 360
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Resolve a tonal palette from the container's computed styles so the chart
137
+ * follows the active theme (light / dark / monokai / glass). Falls back to
138
+ * neutral light-mode values when the chart isn't attached to the document
139
+ * or when the variable is unset.
140
+ */
141
+ private resolvePalette(): typeof FALLBACK {
142
+ const target = this.container
143
+ // `ownerDocument` is non-nullable on HTMLElement — detached elements still
144
+ // carry their original document. `isConnected` is the right signal for
145
+ // "actually in a rendered tree where getComputedStyle is meaningful".
146
+ if (!(target instanceof HTMLElement) || !target.isConnected) {
147
+ return FALLBACK
148
+ }
149
+ const cs = target.ownerDocument.defaultView?.getComputedStyle(target)
150
+ if (cs === undefined) {
151
+ return FALLBACK
152
+ }
153
+
154
+ // CSS tokens in this codebase frequently chain through `var(...)` aliases
155
+ // (e.g. `--muted-foreground: var(--foreground-secondary)`), so we have to
156
+ // follow the chain until we reach a literal value before composing
157
+ // `hsl(...)`. Computed-style on a single property will return the literal
158
+ // text — including the `var(...)` reference — when the chain isn't resolved
159
+ // at the matched rule.
160
+ const MAX_DEPTH = 5
161
+ const VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)\s*$/
162
+ const resolveVar = (name: string, depth = 0): string => {
163
+ if (depth >= MAX_DEPTH) return ''
164
+ const raw = cs.getPropertyValue(name).trim()
165
+ if (raw === '') return ''
166
+ const match = VAR_RE.exec(raw)
167
+ if (match !== null) {
168
+ const ref = match[1]
169
+ const fallback = match[2]?.trim() ?? ''
170
+ if (ref !== undefined) {
171
+ const next = resolveVar(ref, depth + 1)
172
+ if (next !== '') return next
173
+ }
174
+ return fallback
175
+ }
176
+ return raw
177
+ }
178
+
179
+ const hsl = (name: string, fallback: string, alpha = 1): string => {
180
+ const raw = resolveVar(name)
181
+ if (raw === '') return fallback
182
+ // If the resolved token is already a full color (hex, rgb(), hsl(),
183
+ // named color, etc.) pass it through unchanged so themes can override
184
+ // tokens with literal colors without producing invalid `hsl(#abcdef)`.
185
+ if (
186
+ raw.startsWith('#') ||
187
+ raw.startsWith('rgb(') ||
188
+ raw.startsWith('rgba(') ||
189
+ raw.startsWith('hsl(') ||
190
+ raw.startsWith('hsla(') ||
191
+ raw.startsWith('oklch(') ||
192
+ raw.startsWith('oklab(') ||
193
+ raw.startsWith('color(')
194
+ ) {
195
+ // We can't reliably re-apply alpha to an arbitrary color string from
196
+ // Canvas without parsing it — for those themes we trade the alpha
197
+ // (track translucency) for correctness. The fallback alpha values are
198
+ // applied only when the token resolves to space-separated HSL parts.
199
+ return raw
200
+ }
201
+ return alpha === 1 ? `hsl(${raw})` : `hsl(${raw} / ${alpha.toString()})`
202
+ }
203
+
204
+ const fg = hsl('--foreground', FALLBACK.foreground)
205
+ return {
206
+ track: hsl('--muted-foreground', FALLBACK.track, TRACK_ALPHA),
207
+ trackInner: hsl('--muted-foreground', FALLBACK.trackInner, TRACK_INNER_ALPHA),
208
+ foreground: fg,
209
+ muted: hsl('--muted-foreground', FALLBACK.muted),
210
+ needle: fg,
211
+ needleSoft: hsl('--foreground', FALLBACK.needleSoft, NEEDLE_SOFT_ALPHA),
212
+ fill: DEFAULT_FILL,
213
+ shadow: hsl('--foreground', FALLBACK.shadow, SHADOW_ALPHA),
214
+ }
215
+ }
216
+
87
217
  render(): void {
88
218
  this.clear()
89
219
 
220
+ const palette = this.resolvePalette()
90
221
  const plot = this.plotArea
91
222
  const cx = plot.x + plot.width / 2
92
- const cy = plot.y + plot.height * 0.6
223
+ const cy = plot.y + plot.height * 0.58
93
224
  const radius = Math.min(plot.width / 2, plot.height * 0.55) - this.arcWidth / 2
94
225
 
95
226
  const startRad = this.startAngle * DEG_TO_RAD
96
227
  const endRad = this.endAngle * DEG_TO_RAD
97
228
  const totalSweep = endRad - startRad
98
229
 
99
- // Draw background arc
230
+ // Track a thin inner ring then the main track on top for a subtle inset
231
+ this.ctx.save()
232
+ this.ctx.beginPath()
233
+ this.ctx.arc(cx, cy, radius, startRad, endRad)
234
+ this.ctx.strokeStyle = palette.trackInner
235
+ this.ctx.lineWidth = this.arcWidth + 4
236
+ this.ctx.lineCap = 'round'
237
+ this.ctx.stroke()
238
+ this.ctx.restore()
239
+
100
240
  this.ctx.save()
101
241
  this.ctx.beginPath()
102
242
  this.ctx.arc(cx, cy, radius, startRad, endRad)
103
- this.ctx.strokeStyle = BG_ARC_COLOR
243
+ this.ctx.strokeStyle = palette.track
104
244
  this.ctx.lineWidth = this.arcWidth
105
245
  this.ctx.lineCap = 'round'
106
246
  this.ctx.stroke()
107
247
  this.ctx.restore()
108
248
 
109
- // Draw threshold segments or single fill arc
249
+ // Active fill drawn over the track. Thresholds layer below, fill arc on top.
110
250
  if (this.thresholds.length > 0) {
111
- this.drawThresholdArcs(cx, cy, radius, startRad, totalSweep)
251
+ this.drawThresholdArcs(cx, cy, radius, startRad, totalSweep, palette)
112
252
  } else {
113
- this.drawFillArc(cx, cy, radius, startRad, totalSweep)
253
+ this.drawFillArc(cx, cy, radius, startRad, totalSweep, palette)
114
254
  }
115
255
 
116
- // Draw needle
117
- this.drawNeedle(cx, cy, radius, startRad, totalSweep)
256
+ // Needle floats above the dial — keep it subtle so it doesn't obscure the value
257
+ this.drawNeedle(cx, cy, radius, startRad, totalSweep, palette)
118
258
 
119
- // Draw center value
259
+ // Centered value + label sit in the dial's open core
120
260
  if (this.showValue) {
121
- this.drawCenterText(cx, cy)
261
+ this.drawCenterText(cx, cy, palette)
122
262
  }
263
+
264
+ this.drawMinMaxLabels(cx, cy, radius, startRad, endRad, palette)
123
265
  }
124
266
 
125
267
  override destroy(): void {
@@ -131,55 +273,117 @@ export class GaugeChart extends BaseChart {
131
273
  cy: number,
132
274
  radius: number,
133
275
  startRad: number,
134
- totalSweep: number
276
+ totalSweep: number,
277
+ palette: typeof FALLBACK
135
278
  ): void {
136
- // Sort thresholds by value
137
279
  const sorted = [...this.thresholds].sort((a, b) => a.value - b.value)
138
280
  const range = this.max - this.min
139
281
  if (range <= 0) return
140
282
 
141
283
  const clampedValue = clamp(this.value, this.min, this.max)
142
284
  const valueRatio = (clampedValue - this.min) / range
143
-
285
+ const fillEnd = startRad + valueRatio * totalSweep
286
+
287
+ // First pass: build the list of visible segments. We need to know how many
288
+ // segments will actually be drawn before we render so that only the first
289
+ // gets a rounded start-cap and only the last (the active head) gets a
290
+ // rounded end-cap; everything in between is `butt` to prevent overlapping
291
+ // semicircular caps from bleeding into adjacent threshold colors.
292
+ interface Segment {
293
+ start: number
294
+ end: number
295
+ color: string
296
+ }
297
+ const segments: Segment[] = []
144
298
  let prevRatio = 0
145
299
 
146
300
  for (const threshold of sorted) {
147
301
  const thresholdRatio = clamp((threshold.value - this.min) / range, 0, 1)
148
302
  const segStart = startRad + prevRatio * totalSweep
149
303
  const segEnd = startRad + thresholdRatio * totalSweep
150
-
151
- // Only draw segments up to the current value
152
- const fillEnd = startRad + valueRatio * totalSweep
153
304
  const drawEnd = Math.min(segEnd, fillEnd)
154
305
 
155
306
  if (drawEnd > segStart) {
156
- this.ctx.save()
157
- this.ctx.beginPath()
158
- this.ctx.arc(cx, cy, radius, segStart, drawEnd)
159
- this.ctx.strokeStyle = threshold.color
160
- this.ctx.lineWidth = this.arcWidth
161
- this.ctx.lineCap = 'butt'
162
- this.ctx.stroke()
163
- this.ctx.restore()
307
+ segments.push({ start: segStart, end: drawEnd, color: threshold.color })
164
308
  }
165
-
166
309
  prevRatio = thresholdRatio
167
310
  }
168
311
 
169
- // Fill remaining from last threshold to max (if value extends beyond last threshold)
312
+ // Overflow tail: value extends past the highest threshold extend the
313
+ // last threshold's color out to the value ratio.
170
314
  if (prevRatio < valueRatio) {
171
- const lastColor = sorted[sorted.length - 1]?.color ?? defaultColors()[0] ?? '#3b82f6'
315
+ const lastColor = sorted[sorted.length - 1]?.color ?? palette.fill
172
316
  const segStart = startRad + prevRatio * totalSweep
173
- const fillEnd = startRad + valueRatio * totalSweep
317
+ segments.push({ start: segStart, end: fillEnd, color: lastColor })
318
+ }
319
+
320
+ if (segments.length === 0) return
321
+
322
+ const lastIdx = segments.length - 1
323
+ // Single visible segment: keep the simple rounded sweep — no junctions to
324
+ // worry about so both caps get the soft rounded edge.
325
+ if (lastIdx === 0) {
326
+ const only = segments[0]
327
+ if (only !== undefined) {
328
+ this.drawCapArc(cx, cy, radius, only.start, only.end, only.color, palette)
329
+ }
330
+ return
331
+ }
332
+
333
+ // Multi-segment: render every segment with `butt` caps so adjacent
334
+ // threshold colors don't tint each other through overlapping rounded
335
+ // semicircles at the junctions, then add tiny `round`-capped sub-arcs at
336
+ // the outer head and tail so the active sweep's outermost edges stay soft.
337
+ for (const seg of segments) {
174
338
  this.ctx.save()
339
+ this.ctx.shadowColor = palette.shadow
340
+ this.ctx.shadowBlur = 6
341
+ this.ctx.shadowOffsetY = 1
175
342
  this.ctx.beginPath()
176
- this.ctx.arc(cx, cy, radius, segStart, fillEnd)
177
- this.ctx.strokeStyle = lastColor
343
+ this.ctx.arc(cx, cy, radius, seg.start, seg.end)
344
+ this.ctx.strokeStyle = seg.color
178
345
  this.ctx.lineWidth = this.arcWidth
179
346
  this.ctx.lineCap = 'butt'
180
347
  this.ctx.stroke()
181
348
  this.ctx.restore()
182
349
  }
350
+
351
+ const head = segments[0]
352
+ const tail = segments[lastIdx]
353
+ if (head !== undefined) {
354
+ const capLen = Math.min(0.001, head.end - head.start)
355
+ if (capLen > 0) {
356
+ this.drawCapArc(cx, cy, radius, head.start, head.start + capLen, head.color, palette)
357
+ }
358
+ }
359
+ if (tail !== undefined) {
360
+ const capLen = Math.min(0.001, tail.end - tail.start)
361
+ if (capLen > 0) {
362
+ this.drawCapArc(cx, cy, radius, tail.end - capLen, tail.end, tail.color, palette)
363
+ }
364
+ }
365
+ }
366
+
367
+ private drawCapArc(
368
+ cx: number,
369
+ cy: number,
370
+ radius: number,
371
+ start: number,
372
+ end: number,
373
+ color: string,
374
+ palette: typeof FALLBACK
375
+ ): void {
376
+ this.ctx.save()
377
+ this.ctx.shadowColor = palette.shadow
378
+ this.ctx.shadowBlur = 6
379
+ this.ctx.shadowOffsetY = 1
380
+ this.ctx.beginPath()
381
+ this.ctx.arc(cx, cy, radius, start, end)
382
+ this.ctx.strokeStyle = color
383
+ this.ctx.lineWidth = this.arcWidth
384
+ this.ctx.lineCap = 'round'
385
+ this.ctx.stroke()
386
+ this.ctx.restore()
183
387
  }
184
388
 
185
389
  private drawFillArc(
@@ -187,7 +391,8 @@ export class GaugeChart extends BaseChart {
187
391
  cy: number,
188
392
  radius: number,
189
393
  startRad: number,
190
- totalSweep: number
394
+ totalSweep: number,
395
+ palette: typeof FALLBACK
191
396
  ): void {
192
397
  const range = this.max - this.min
193
398
  if (range <= 0) return
@@ -195,12 +400,14 @@ export class GaugeChart extends BaseChart {
195
400
  const clampedValue = clamp(this.value, this.min, this.max)
196
401
  const ratio = (clampedValue - this.min) / range
197
402
  const fillEnd = startRad + ratio * totalSweep
198
- const color = defaultColors()[0] ?? '#3b82f6'
199
403
 
200
404
  this.ctx.save()
405
+ this.ctx.shadowColor = palette.shadow
406
+ this.ctx.shadowBlur = 8
407
+ this.ctx.shadowOffsetY = 2
201
408
  this.ctx.beginPath()
202
409
  this.ctx.arc(cx, cy, radius, startRad, fillEnd)
203
- this.ctx.strokeStyle = color
410
+ this.ctx.strokeStyle = palette.fill
204
411
  this.ctx.lineWidth = this.arcWidth
205
412
  this.ctx.lineCap = 'round'
206
413
  this.ctx.stroke()
@@ -212,7 +419,8 @@ export class GaugeChart extends BaseChart {
212
419
  cy: number,
213
420
  radius: number,
214
421
  startRad: number,
215
- totalSweep: number
422
+ totalSweep: number,
423
+ palette: typeof FALLBACK
216
424
  ): void {
217
425
  const range = this.max - this.min
218
426
  if (range <= 0) return
@@ -220,69 +428,91 @@ export class GaugeChart extends BaseChart {
220
428
  const clampedValue = clamp(this.value, this.min, this.max)
221
429
  const ratio = (clampedValue - this.min) / range
222
430
  const needleAngle = startRad + ratio * totalSweep
223
- const needleLength = radius - this.arcWidth / 2 - 4
431
+ const needleLength = Math.max(4, radius - this.arcWidth - 2)
432
+
433
+ const tipX = cx + Math.cos(needleAngle) * needleLength
434
+ const tipY = cy + Math.sin(needleAngle) * needleLength
224
435
 
225
436
  this.ctx.save()
226
437
 
227
- // Needle line
438
+ // Faint trail so the needle reads at a glance
439
+ this.ctx.beginPath()
440
+ this.ctx.moveTo(cx, cy)
441
+ this.ctx.lineTo(tipX, tipY)
442
+ this.ctx.strokeStyle = palette.needleSoft
443
+ this.ctx.lineWidth = 3
444
+ this.ctx.lineCap = 'round'
445
+ this.ctx.stroke()
446
+
447
+ // Crisp tip
228
448
  this.ctx.beginPath()
229
449
  this.ctx.moveTo(cx, cy)
230
- this.ctx.lineTo(
231
- cx + Math.cos(needleAngle) * needleLength,
232
- cy + Math.sin(needleAngle) * needleLength
233
- )
234
- this.ctx.strokeStyle = NEEDLE_COLOR
235
- this.ctx.lineWidth = 2.5
450
+ this.ctx.lineTo(tipX, tipY)
451
+ this.ctx.strokeStyle = palette.needle
452
+ this.ctx.lineWidth = 1.5
236
453
  this.ctx.lineCap = 'round'
237
454
  this.ctx.stroke()
238
455
 
239
- // Center dot
456
+ // Pivot — concentric dots so the centerpiece reads in any theme
240
457
  this.ctx.beginPath()
241
- this.ctx.arc(cx, cy, 5, 0, Math.PI * 2)
242
- this.ctx.fillStyle = NEEDLE_COLOR
458
+ this.ctx.arc(cx, cy, 6, 0, Math.PI * 2)
459
+ this.ctx.fillStyle = palette.needle
460
+ this.ctx.fill()
461
+ this.ctx.beginPath()
462
+ this.ctx.arc(cx, cy, 2.5, 0, Math.PI * 2)
463
+ this.ctx.fillStyle = palette.fill
243
464
  this.ctx.fill()
244
465
 
245
466
  this.ctx.restore()
246
467
  }
247
468
 
248
- private drawCenterText(cx: number, cy: number): void {
469
+ private drawCenterText(cx: number, cy: number, palette: typeof FALLBACK): void {
249
470
  this.ctx.save()
250
471
  this.ctx.textAlign = 'center'
251
472
  this.ctx.textBaseline = 'middle'
252
473
 
253
- // Value text
474
+ const hasLabel = this.label !== undefined
475
+ const valueY = hasLabel ? cy - 14 : cy - 4
476
+ const labelY = cy + 16
477
+
254
478
  const valueText =
255
479
  this.unit !== undefined ? `${formatNumber(this.value)}${this.unit}` : formatNumber(this.value)
256
480
 
257
- this.ctx.fillStyle = NEEDLE_COLOR
258
- this.ctx.font = VALUE_FONT
259
- this.ctx.fillText(valueText, cx, cy + 30)
481
+ this.ctx.fillStyle = palette.foreground
482
+ this.ctx.font = VALUE_FONT_DEFAULT
483
+ this.ctx.fillText(valueText, cx, valueY)
260
484
 
261
- // Optional label below value
262
485
  if (this.label !== undefined) {
263
- this.ctx.fillStyle = LABEL_COLOR
486
+ this.ctx.fillStyle = palette.muted
264
487
  this.ctx.font = LABEL_FONT
265
- this.ctx.fillText(this.label, cx, cy + 52)
488
+ this.ctx.fillText(this.label, cx, labelY)
266
489
  }
267
490
 
268
- // Min and max labels
269
- this.ctx.fillStyle = LABEL_COLOR
270
- this.ctx.font = UNIT_FONT
271
-
272
- const plot = this.plotArea
273
- const gaugeRadius = Math.min(plot.width / 2, plot.height * 0.55) - this.arcWidth / 2
274
-
275
- const startRad = this.startAngle * DEG_TO_RAD
276
- const endRad = this.endAngle * DEG_TO_RAD
491
+ this.ctx.restore()
492
+ }
277
493
 
278
- const minX = cx + Math.cos(startRad) * (gaugeRadius + this.arcWidth)
279
- const minY = cy + Math.sin(startRad) * (gaugeRadius + this.arcWidth)
494
+ private drawMinMaxLabels(
495
+ cx: number,
496
+ cy: number,
497
+ radius: number,
498
+ startRad: number,
499
+ endRad: number,
500
+ palette: typeof FALLBACK
501
+ ): void {
502
+ this.ctx.save()
503
+ this.ctx.fillStyle = palette.muted
504
+ this.ctx.font = MINMAX_FONT
280
505
  this.ctx.textAlign = 'center'
281
- this.ctx.fillText(formatNumber(this.min), minX, minY + 14)
506
+ this.ctx.textBaseline = 'middle'
507
+
508
+ const labelDistance = radius + this.arcWidth / 2 + 14
509
+ const minX = cx + Math.cos(startRad) * labelDistance
510
+ const minY = cy + Math.sin(startRad) * labelDistance
511
+ this.ctx.fillText(formatNumber(this.min), minX, minY)
282
512
 
283
- const maxX = cx + Math.cos(endRad) * (gaugeRadius + this.arcWidth)
284
- const maxY = cy + Math.sin(endRad) * (gaugeRadius + this.arcWidth)
285
- this.ctx.fillText(formatNumber(this.max), maxX, maxY + 14)
513
+ const maxX = cx + Math.cos(endRad) * labelDistance
514
+ const maxY = cy + Math.sin(endRad) * labelDistance
515
+ this.ctx.fillText(formatNumber(this.max), maxX, maxY)
286
516
 
287
517
  this.ctx.restore()
288
518
  }
@@ -31,17 +31,22 @@ export interface GaugeChartConfig extends BaseChartConfig {
31
31
  thresholds?: GaugeThreshold[]
32
32
  /**
33
33
  * Width of the gauge arc in pixels.
34
- * @default 12
34
+ * @default 20
35
35
  */
36
36
  arcWidth?: number
37
37
  /**
38
- * Start angle of the arc in radians.
39
- * @default -Math.PI
38
+ * Start angle of the arc in degrees. Angles follow the standard Canvas
39
+ * convention where 0 points along the positive X axis and angles increase
40
+ * clockwise, so 135 places the start at the lower-left.
41
+ * @default 135
40
42
  */
41
43
  startAngle?: number
42
44
  /**
43
- * End angle of the arc in radians.
44
- * @default 0
45
+ * End angle of the arc in degrees. Should be greater than `startAngle`; the
46
+ * default of 405 produces a 270 degree sweep ending at the lower-right.
47
+ * Values where `endAngle <= startAngle` are silently normalized by adding
48
+ * one or more full turns so the arc still sweeps in the intended direction.
49
+ * @default 405
45
50
  */
46
51
  endAngle?: number
47
52
  /**