@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
|
@@ -24,17 +24,22 @@ export interface GaugeChartConfig extends BaseChartConfig {
|
|
|
24
24
|
thresholds?: GaugeThreshold[];
|
|
25
25
|
/**
|
|
26
26
|
* Width of the gauge arc in pixels.
|
|
27
|
-
* @default
|
|
27
|
+
* @default 20
|
|
28
28
|
*/
|
|
29
29
|
arcWidth?: number;
|
|
30
30
|
/**
|
|
31
|
-
* Start angle of the arc in
|
|
32
|
-
*
|
|
31
|
+
* Start angle of the arc in degrees. Angles follow the standard Canvas
|
|
32
|
+
* convention where 0 points along the positive X axis and angles increase
|
|
33
|
+
* clockwise, so 135 places the start at the lower-left.
|
|
34
|
+
* @default 135
|
|
33
35
|
*/
|
|
34
36
|
startAngle?: number;
|
|
35
37
|
/**
|
|
36
|
-
* End angle of the arc in
|
|
37
|
-
*
|
|
38
|
+
* End angle of the arc in degrees. Should be greater than `startAngle`; the
|
|
39
|
+
* default of 405 produces a 270 degree sweep ending at the lower-right.
|
|
40
|
+
* Values where `endAngle <= startAngle` are silently normalized by adding
|
|
41
|
+
* one or more full turns so the arc still sweeps in the intended direction.
|
|
42
|
+
* @default 405
|
|
38
43
|
*/
|
|
39
44
|
endAngle?: number;
|
|
40
45
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/charts/gauge/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAE/C,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACvD,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,UAAU,CAAC,EAAE,cAAc,EAAE,CAAA;IAC7B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/charts/gauge/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAE/C,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACvD,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,UAAU,CAAC,EAAE,cAAc,EAAE,CAAA;IAC7B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@countermeasure-platform/web-components",
|
|
3
|
-
"version": "1.2.2-dev.
|
|
3
|
+
"version": "1.2.2-dev.18.1",
|
|
4
4
|
"description": "Shared web components for CounterMeasure applications - consolidates common frontend functionality across projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -8,11 +8,16 @@ import { defaultColors, hexToRgba, formatNumber } from '../utils'
|
|
|
8
8
|
import { ChartTooltip } from '../tooltip'
|
|
9
9
|
import type { FunnelChartConfig, FunnelSegment } from './types'
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const LABEL_FONT = '12px system-ui, sans-serif'
|
|
14
|
-
const VALUE_FONT = '11px system-ui, sans-serif'
|
|
11
|
+
const LABEL_FONT = '600 12px ui-sans-serif, system-ui, -apple-system, sans-serif'
|
|
12
|
+
const VALUE_FONT = '500 11px ui-sans-serif, system-ui, -apple-system, sans-serif'
|
|
15
13
|
const HOVER_ALPHA_DELTA = 0.12
|
|
14
|
+
const NAMED_COLOR_RGB = new Map<string, [number, number, number]>([
|
|
15
|
+
['black', [0, 0, 0]],
|
|
16
|
+
['white', [255, 255, 255]],
|
|
17
|
+
['navy', [0, 0, 128]],
|
|
18
|
+
['rebeccapurple', [102, 51, 153]],
|
|
19
|
+
['transparent', [0, 0, 0]],
|
|
20
|
+
])
|
|
16
21
|
|
|
17
22
|
interface ResolvedSegment {
|
|
18
23
|
label: string
|
|
@@ -24,6 +29,196 @@ interface ResolvedSegment {
|
|
|
24
29
|
percentage: number
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Pick a readable text foreground/shadow pair for a given segment fill color.
|
|
34
|
+
*
|
|
35
|
+
* Funnel labels sit on top of saturated brand colors that vary segment-to-
|
|
36
|
+
* segment, so we can't just use a single theme-foreground value — light text
|
|
37
|
+
* vanishes on yellow/orange, dark text vanishes on navy/teal. Compute the
|
|
38
|
+
* fill's perceived luminance (sRGB → relative-luminance approximation) and
|
|
39
|
+
* pick white-on-shadow or black-on-shadow accordingly.
|
|
40
|
+
*/
|
|
41
|
+
function pickReadableText(color: string): { text: string; shadow: string } {
|
|
42
|
+
const rgb = parseColorToRgb(color)
|
|
43
|
+
if (rgb === null) {
|
|
44
|
+
return { text: '#0f172a', shadow: 'rgba(255, 255, 255, 0.45)' }
|
|
45
|
+
}
|
|
46
|
+
const [r, g, b] = rgb
|
|
47
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
48
|
+
if (luminance > 0.6) {
|
|
49
|
+
return { text: '#0f172a', shadow: 'rgba(255, 255, 255, 0.45)' }
|
|
50
|
+
}
|
|
51
|
+
return { text: '#ffffff', shadow: 'rgba(0, 0, 0, 0.45)' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseColorToRgb(color: string): [number, number, number] | null {
|
|
55
|
+
const trimmed = color.trim()
|
|
56
|
+
const parsedLiteral = parseColorLiteralToRgb(trimmed)
|
|
57
|
+
if (parsedLiteral !== null) {
|
|
58
|
+
return parsedLiteral
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const named = NAMED_COLOR_RGB.get(trimmed.toLowerCase())
|
|
62
|
+
if (named !== undefined) {
|
|
63
|
+
return named
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// For anything else — modern CSS syntaxes (oklch, color-mix, lab, color())
|
|
67
|
+
// and the long tail of CSS named colors — round-trip through canvas to get
|
|
68
|
+
// a canonical rgb()/rgba() string we can then parse with our literal parser.
|
|
69
|
+
const normalized = normalizeColorViaCanvas(trimmed)
|
|
70
|
+
if (normalized === null) {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
return parseColorLiteralToRgb(normalized)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseColorLiteralToRgb(trimmed: string): [number, number, number] | null {
|
|
77
|
+
if (trimmed.startsWith('#')) {
|
|
78
|
+
const hex = trimmed.slice(1)
|
|
79
|
+
if (hex.length === 3) {
|
|
80
|
+
const r = parseInt(hex[0]! + hex[0], 16)
|
|
81
|
+
const g = parseInt(hex[1]! + hex[1], 16)
|
|
82
|
+
const b = parseInt(hex[2]! + hex[2], 16)
|
|
83
|
+
return [r, g, b]
|
|
84
|
+
}
|
|
85
|
+
if (hex.length === 6) {
|
|
86
|
+
const r = parseInt(hex.slice(0, 2), 16)
|
|
87
|
+
const g = parseInt(hex.slice(2, 4), 16)
|
|
88
|
+
const b = parseInt(hex.slice(4, 6), 16)
|
|
89
|
+
return [r, g, b]
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
const rgbMatch = trimmed.match(/^rgba?\(([^)]+)\)$/i)
|
|
94
|
+
if (rgbMatch) {
|
|
95
|
+
const parts = parseColorFunctionArgs(rgbMatch[1]!)
|
|
96
|
+
const r = parseRgbChannel(parts[0])
|
|
97
|
+
const g = parseRgbChannel(parts[1])
|
|
98
|
+
const b = parseRgbChannel(parts[2])
|
|
99
|
+
if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
|
|
100
|
+
return [r, g, b]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const hslMatch = trimmed.match(/^hsla?\(([^)]+)\)$/i)
|
|
104
|
+
if (hslMatch) {
|
|
105
|
+
const parts = parseColorFunctionArgs(hslMatch[1]!)
|
|
106
|
+
const h = parseHue(parts[0])
|
|
107
|
+
const s = parsePercentage(parts[1])
|
|
108
|
+
const l = parsePercentage(parts[2])
|
|
109
|
+
if (Number.isFinite(h) && Number.isFinite(s) && Number.isFinite(l)) {
|
|
110
|
+
return hslToRgb(h, s, l)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Lazily-allocated 1×1 canvas reused across color normalization calls. The
|
|
117
|
+
// canvas itself is stateless w.r.t. fillStyle (each call writes both sentinel
|
|
118
|
+
// and the input), so it's safe to share — and re-creating one per call would
|
|
119
|
+
// produce a constant stream of small DOM nodes for funnels with many segments
|
|
120
|
+
// that re-render on hover.
|
|
121
|
+
let normalizeCanvasCtx: CanvasRenderingContext2D | null | undefined
|
|
122
|
+
|
|
123
|
+
function getNormalizeCanvasCtx(): CanvasRenderingContext2D | null {
|
|
124
|
+
if (normalizeCanvasCtx !== undefined) return normalizeCanvasCtx
|
|
125
|
+
if (typeof document === 'undefined') {
|
|
126
|
+
normalizeCanvasCtx = null
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
const canvas = document.createElement('canvas')
|
|
130
|
+
canvas.width = 1
|
|
131
|
+
canvas.height = 1
|
|
132
|
+
normalizeCanvasCtx = canvas.getContext('2d')
|
|
133
|
+
return normalizeCanvasCtx
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Round-trip an arbitrary CSS color string through a canvas 2D context's
|
|
138
|
+
* `fillStyle` so the browser canonicalizes it to `rgb(...)` or `rgba(...)`.
|
|
139
|
+
* Returns `null` if the color is invalid (canvas keeps the previous value
|
|
140
|
+
* when assignment is rejected — we detect this by comparing against a known
|
|
141
|
+
* sentinel written immediately before the actual color).
|
|
142
|
+
*/
|
|
143
|
+
function normalizeColorViaCanvas(color: string): string | null {
|
|
144
|
+
const ctx = getNormalizeCanvasCtx()
|
|
145
|
+
if (ctx === null) return null
|
|
146
|
+
|
|
147
|
+
// Write a known sentinel and read it back to get its canonical form, then
|
|
148
|
+
// write the candidate color. If canvas rejected `color` as invalid, the
|
|
149
|
+
// post-assignment `fillStyle` will still equal the canonicalized sentinel.
|
|
150
|
+
// Reading between writes also prevents linters from flagging the first
|
|
151
|
+
// write as dead.
|
|
152
|
+
ctx.fillStyle = '#010203'
|
|
153
|
+
const baseline = ctx.fillStyle
|
|
154
|
+
ctx.fillStyle = color
|
|
155
|
+
const normalized = ctx.fillStyle
|
|
156
|
+
if (normalized === baseline && color.trim().toLowerCase() !== '#010203') {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
return typeof normalized === 'string' ? normalized : null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseColorFunctionArgs(input: string): string[] {
|
|
163
|
+
const channels = input.split('/')[0]!.trim()
|
|
164
|
+
return channels.includes(',')
|
|
165
|
+
? channels.split(',').map(part => part.trim())
|
|
166
|
+
: channels.split(/\s+/).map(part => part.trim())
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseRgbChannel(part: string | undefined): number {
|
|
170
|
+
if (part === undefined) return Number.NaN
|
|
171
|
+
if (part.endsWith('%')) {
|
|
172
|
+
const value = Number(part.slice(0, -1))
|
|
173
|
+
return Number.isFinite(value) ? Math.round((value / 100) * 255) : Number.NaN
|
|
174
|
+
}
|
|
175
|
+
return Number(part)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseHue(part: string | undefined): number {
|
|
179
|
+
if (part === undefined) return Number.NaN
|
|
180
|
+
if (part.endsWith('turn')) {
|
|
181
|
+
const value = Number(part.slice(0, -4))
|
|
182
|
+
return Number.isFinite(value) ? value % 1 : Number.NaN
|
|
183
|
+
}
|
|
184
|
+
if (part.endsWith('rad')) {
|
|
185
|
+
const value = Number(part.slice(0, -3))
|
|
186
|
+
return Number.isFinite(value) ? (value / (Math.PI * 2)) % 1 : Number.NaN
|
|
187
|
+
}
|
|
188
|
+
const raw = part.endsWith('deg') ? part.slice(0, -3) : part
|
|
189
|
+
const value = Number(raw)
|
|
190
|
+
return Number.isFinite(value) ? (((value % 360) + 360) % 360) / 360 : Number.NaN
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parsePercentage(part: string | undefined): number {
|
|
194
|
+
if (part === undefined || !part.endsWith('%')) return Number.NaN
|
|
195
|
+
const value = Number(part.slice(0, -1))
|
|
196
|
+
return Number.isFinite(value) ? value / 100 : Number.NaN
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
200
|
+
if (s === 0) {
|
|
201
|
+
const value = Math.round(l * 255)
|
|
202
|
+
return [value, value, value]
|
|
203
|
+
}
|
|
204
|
+
const hue2rgb = (p: number, q: number, t: number): number => {
|
|
205
|
+
let hue = t
|
|
206
|
+
if (hue < 0) hue += 1
|
|
207
|
+
if (hue > 1) hue -= 1
|
|
208
|
+
if (hue < 1 / 6) return p + (q - p) * 6 * hue
|
|
209
|
+
if (hue < 1 / 2) return q
|
|
210
|
+
if (hue < 2 / 3) return p + (q - p) * (2 / 3 - hue) * 6
|
|
211
|
+
return p
|
|
212
|
+
}
|
|
213
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
|
214
|
+
const p = 2 * l - q
|
|
215
|
+
return [
|
|
216
|
+
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
|
217
|
+
Math.round(hue2rgb(p, q, h) * 255),
|
|
218
|
+
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
|
|
27
222
|
export class FunnelChart extends BaseChart {
|
|
28
223
|
private segments: FunnelSegment[]
|
|
29
224
|
private resolved: ResolvedSegment[] = []
|
|
@@ -239,24 +434,27 @@ export class FunnelChart extends BaseChart {
|
|
|
239
434
|
}
|
|
240
435
|
|
|
241
436
|
private drawSegmentText(cx: number, cy: number, seg: ResolvedSegment): void {
|
|
437
|
+
const { text, shadow } = pickReadableText(seg.color)
|
|
438
|
+
|
|
242
439
|
this.ctx.save()
|
|
243
440
|
this.ctx.textAlign = 'center'
|
|
244
441
|
this.ctx.textBaseline = 'middle'
|
|
442
|
+
this.ctx.shadowColor = shadow
|
|
443
|
+
this.ctx.shadowBlur = 4
|
|
444
|
+
this.ctx.shadowOffsetY = 1
|
|
445
|
+
this.ctx.fillStyle = text
|
|
245
446
|
|
|
246
447
|
if (this.showLabels && this.showValues) {
|
|
247
|
-
this.ctx.fillStyle = LABEL_COLOR
|
|
248
448
|
this.ctx.font = LABEL_FONT
|
|
249
449
|
this.ctx.fillText(seg.label, cx, cy - 8)
|
|
250
450
|
|
|
251
|
-
this.ctx.fillStyle = VALUE_COLOR
|
|
252
451
|
this.ctx.font = VALUE_FONT
|
|
452
|
+
this.ctx.globalAlpha = 0.9
|
|
253
453
|
this.ctx.fillText(`${formatNumber(seg.value)} (${seg.percentage.toFixed(1)}%)`, cx, cy + 8)
|
|
254
454
|
} else if (this.showLabels) {
|
|
255
|
-
this.ctx.fillStyle = LABEL_COLOR
|
|
256
455
|
this.ctx.font = LABEL_FONT
|
|
257
456
|
this.ctx.fillText(seg.label, cx, cy)
|
|
258
457
|
} else if (this.showValues) {
|
|
259
|
-
this.ctx.fillStyle = VALUE_COLOR
|
|
260
458
|
this.ctx.font = VALUE_FONT
|
|
261
459
|
this.ctx.fillText(formatNumber(seg.value), cx, cy)
|
|
262
460
|
}
|