@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.
@@ -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 12
27
+ * @default 20
28
28
  */
29
29
  arcWidth?: number;
30
30
  /**
31
- * Start angle of the arc in radians.
32
- * @default -Math.PI
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 radians.
37
- * @default 0
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;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB"}
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.16.1",
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 LABEL_COLOR = '#334155'
12
- const VALUE_COLOR = '#64748b'
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
  }