@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.
- package/dist/charts/funnel/index.d.ts.map +1 -1
- package/dist/charts/funnel/index.js +158 -13
- package/dist/charts/funnel/index.js.map +1 -1
- package/dist/charts/gauge/index.d.ts +17 -0
- package/dist/charts/gauge/index.d.ts.map +1 -1
- package/dist/charts/gauge/index.js +104 -27
- package/dist/charts/gauge/index.js.map +1 -1
- package/dist/charts/gauge/types.d.ts +10 -5
- package/dist/charts/gauge/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/charts/funnel/index.ts +206 -8
- package/src/charts/gauge/index.ts +304 -74
- package/src/charts/gauge/types.ts +10 -5
|
@@ -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
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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 ??
|
|
315
|
+
const lastColor = sorted[sorted.length - 1]?.color ?? palette.fill
|
|
172
316
|
const segStart = startRad + prevRatio * totalSweep
|
|
173
|
-
|
|
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,
|
|
177
|
-
this.ctx.strokeStyle =
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
456
|
+
// Pivot — concentric dots so the centerpiece reads in any theme
|
|
240
457
|
this.ctx.beginPath()
|
|
241
|
-
this.ctx.arc(cx, cy,
|
|
242
|
-
this.ctx.fillStyle =
|
|
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
|
-
|
|
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 =
|
|
258
|
-
this.ctx.font =
|
|
259
|
-
this.ctx.fillText(valueText, cx,
|
|
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 =
|
|
486
|
+
this.ctx.fillStyle = palette.muted
|
|
264
487
|
this.ctx.font = LABEL_FONT
|
|
265
|
-
this.ctx.fillText(this.label, cx,
|
|
488
|
+
this.ctx.fillText(this.label, cx, labelY)
|
|
266
489
|
}
|
|
267
490
|
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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.
|
|
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) *
|
|
284
|
-
const maxY = cy + Math.sin(endRad) *
|
|
285
|
-
this.ctx.fillText(formatNumber(this.max), maxX, maxY
|
|
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
|
|
34
|
+
* @default 20
|
|
35
35
|
*/
|
|
36
36
|
arcWidth?: number
|
|
37
37
|
/**
|
|
38
|
-
* Start angle of the arc in
|
|
39
|
-
*
|
|
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
|
|
44
|
-
*
|
|
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
|
/**
|