@sentropic/design-system-svelte 0.19.0 → 0.20.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.
@@ -0,0 +1,479 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * BulletChart - Tableau bullet graph (mesure vs cible + bandes qualitatives).
4
+ * API canonique (référence Svelte, React/Vue doivent s'aligner)
5
+ *
6
+ * Props obligatoires :
7
+ * data BulletChartDatum[] - tableau {label, value, target, ranges?}
8
+ * label string - aria-label du graphique
9
+ *
10
+ * Props optionnelles :
11
+ * orientation "horizontal"|"vertical" (défaut "horizontal")
12
+ * width number (défaut 480)
13
+ * height number (défaut 240)
14
+ * class string
15
+ */
16
+ export type BulletChartDatum = {
17
+ label: string;
18
+ value: number;
19
+ target: number;
20
+ ranges?: number[];
21
+ };
22
+ </script>
23
+
24
+ <script lang="ts">
25
+ import ChartDataList from "./ChartDataList.svelte";
26
+
27
+ type BulletChartProps = {
28
+ data: BulletChartDatum[];
29
+ label: string;
30
+ orientation?: "horizontal" | "vertical";
31
+ width?: number;
32
+ height?: number;
33
+ class?: string;
34
+ };
35
+
36
+ let {
37
+ data = [],
38
+ label,
39
+ orientation = "horizontal",
40
+ width = 480,
41
+ height = 240,
42
+ class: className
43
+ }: BulletChartProps = $props();
44
+
45
+ const MARGIN = { top: 12, right: 24, bottom: 36, left: 80 };
46
+ const RANGE_OPACITIES = [0.18, 0.30, 0.44];
47
+
48
+ function scaleLinear(v: number, d0: number, d1: number, r0: number, r1: number) {
49
+ if (d1 === d0) return r0;
50
+ return r0 + ((v - d0) * (r1 - r0)) / (d1 - d0);
51
+ }
52
+
53
+ function niceTicks(min: number, max: number, target = 5): number[] {
54
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) {
55
+ const base = Number.isFinite(max) ? max : 0;
56
+ return [base];
57
+ }
58
+ const range = max - min;
59
+ const rough = range / Math.max(target - 1, 1);
60
+ const pow = Math.pow(10, Math.floor(Math.log10(rough)));
61
+ const norm = rough / pow;
62
+ let step: number;
63
+ if (norm < 1.5) step = 1 * pow;
64
+ else if (norm < 3) step = 2 * pow;
65
+ else if (norm < 7) step = 5 * pow;
66
+ else step = 10 * pow;
67
+ const start = Math.floor(min / step) * step;
68
+ const end = Math.ceil(max / step) * step;
69
+ const ticks: number[] = [];
70
+ for (let v = start; v <= end + step / 2; v += step) {
71
+ ticks.push(Number(v.toFixed(10)));
72
+ }
73
+ return ticks;
74
+ }
75
+
76
+ function formatTick(v: number): string {
77
+ if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}k`;
78
+ if (Number.isInteger(v)) return String(v);
79
+ return v.toFixed(1);
80
+ }
81
+
82
+ let hoveredIndex: number | null = $state(null);
83
+
84
+ const plotWidth = $derived(Math.max(width - MARGIN.left - MARGIN.right, 1));
85
+ const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
86
+
87
+ // FIX #3 : filtre les données invalides (value/target non-finis → skip)
88
+ const validData = $derived(
89
+ data.filter((d) => Number.isFinite(d.value) && Number.isFinite(d.target))
90
+ );
91
+
92
+ // FIX #3 : domaine inclut 0, values, targets ET ranges ; domaine négatif supporté
93
+ const domainBounds = $derived.by(() => {
94
+ const allValues: number[] = [0]; // baseline à 0
95
+ for (const d of validData) {
96
+ allValues.push(d.value, d.target);
97
+ for (const r of d.ranges ?? []) {
98
+ if (Number.isFinite(r)) allValues.push(r);
99
+ }
100
+ }
101
+ const rawMin = Math.min(...allValues);
102
+ const rawMax = Math.max(...allValues);
103
+ // Domaine plat → fallback +1
104
+ return { rawMin, rawMax: rawMax === rawMin ? rawMin + 1 : rawMax };
105
+ });
106
+
107
+ const ticks = $derived(niceTicks(domainBounds.rawMin, domainBounds.rawMax, 5));
108
+ const tickDomainMin = $derived(ticks[0] ?? domainBounds.rawMin);
109
+ const tickDomainMax = $derived(ticks[ticks.length - 1] ?? domainBounds.rawMax);
110
+
111
+ // Position de la baseline (valeur 0) en pixels
112
+ const baselineX = $derived(MARGIN.left + scaleLinear(0, tickDomainMin, tickDomainMax, 0, plotWidth));
113
+ const baselineY = $derived(MARGIN.top + scaleLinear(0, tickDomainMin, tickDomainMax, plotHeight, 0));
114
+
115
+ const bullets = $derived.by(() => {
116
+ const bandCount = validData.length;
117
+ if (bandCount === 0) return [];
118
+
119
+ if (orientation === "horizontal") {
120
+ const bandH = plotHeight / bandCount;
121
+ const barH = bandH * 0.35;
122
+ const rangeH = bandH * 0.65;
123
+
124
+ return validData.map((d, i) => {
125
+ const ranges = (d.ranges ?? [tickDomainMax]).filter(Number.isFinite).slice(0, 3);
126
+ const sortedRanges = [...ranges].sort((a, b) => a - b);
127
+
128
+ const bandY = MARGIN.top + i * bandH;
129
+ const cx = MARGIN.left + scaleLinear(d.value, tickDomainMin, tickDomainMax, 0, plotWidth);
130
+ const targetX = MARGIN.left + scaleLinear(d.target, tickDomainMin, tickDomainMax, 0, plotWidth);
131
+
132
+ const rangeBands = sortedRanges.map((r, ri) => {
133
+ const prevR = ri === 0 ? tickDomainMin : sortedRanges[ri - 1];
134
+ return {
135
+ x: MARGIN.left + scaleLinear(prevR, tickDomainMin, tickDomainMax, 0, plotWidth),
136
+ width: scaleLinear(r, tickDomainMin, tickDomainMax, 0, plotWidth) -
137
+ scaleLinear(prevR, tickDomainMin, tickDomainMax, 0, plotWidth),
138
+ opacity: RANGE_OPACITIES[ri] ?? 0.44,
139
+ y: bandY + (bandH - rangeH) / 2,
140
+ height: rangeH
141
+ };
142
+ });
143
+
144
+ // FIX #3 : la barre part de la baseline (0), pas du bord gauche
145
+ const zeroX = baselineX;
146
+ const barLeft = Math.min(zeroX, cx);
147
+ const barRight = Math.max(zeroX, cx);
148
+
149
+ return {
150
+ datum: d,
151
+ index: i,
152
+ barX: barLeft,
153
+ barY: bandY + (bandH - barH) / 2,
154
+ barW: Math.max(barRight - barLeft, 0.5),
155
+ barH,
156
+ targetX,
157
+ targetY: bandY + (bandH - rangeH) / 2,
158
+ targetH: rangeH,
159
+ labelY: bandY + bandH / 2,
160
+ labelX: 0,
161
+ rangeBands,
162
+ tooltipX: cx,
163
+ tooltipY: bandY + bandH / 2
164
+ };
165
+ });
166
+ }
167
+
168
+ // vertical
169
+ const bandW = plotWidth / bandCount;
170
+ const barW = bandW * 0.35;
171
+ const rangeW = bandW * 0.65;
172
+
173
+ return validData.map((d, i) => {
174
+ const ranges = (d.ranges ?? [tickDomainMax]).filter(Number.isFinite).slice(0, 3);
175
+ const sortedRanges = [...ranges].sort((a, b) => a - b);
176
+
177
+ const bandX = MARGIN.left + i * bandW;
178
+ const cy = MARGIN.top + scaleLinear(d.value, tickDomainMin, tickDomainMax, plotHeight, 0);
179
+ const targetY = MARGIN.top + scaleLinear(d.target, tickDomainMin, tickDomainMax, plotHeight, 0);
180
+
181
+ const rangeBands = sortedRanges.map((r, ri) => {
182
+ const prevR = ri === 0 ? tickDomainMin : sortedRanges[ri - 1];
183
+ return {
184
+ y: MARGIN.top + scaleLinear(r, tickDomainMin, tickDomainMax, plotHeight, 0),
185
+ height: Math.abs(
186
+ scaleLinear(r, tickDomainMin, tickDomainMax, plotHeight, 0) -
187
+ scaleLinear(prevR, tickDomainMin, tickDomainMax, plotHeight, 0)
188
+ ),
189
+ opacity: RANGE_OPACITIES[ri] ?? 0.44,
190
+ x: bandX + (bandW - rangeW) / 2,
191
+ width: rangeW
192
+ };
193
+ });
194
+
195
+ // FIX #3 : barre part de la baseline (0)
196
+ const zeroY = baselineY;
197
+ const barTop = Math.min(zeroY, cy);
198
+ const barBot = Math.max(zeroY, cy);
199
+
200
+ return {
201
+ datum: d,
202
+ index: i,
203
+ barX: bandX + (bandW - barW) / 2,
204
+ barY: barTop,
205
+ barW: barW,
206
+ barH: Math.max(barBot - barTop, 0.5),
207
+ targetY,
208
+ targetX: bandX + (bandW - rangeW) / 2,
209
+ targetH: rangeW,
210
+ labelY: MARGIN.top + plotHeight + 18,
211
+ labelX: bandX + bandW / 2,
212
+ rangeBands,
213
+ tooltipX: bandX + bandW / 2,
214
+ tooltipY: cy
215
+ };
216
+ });
217
+ });
218
+
219
+ const dataValueItems = $derived(
220
+ validData.map((d) => `${d.label}: value ${d.value}, target ${d.target}`)
221
+ );
222
+
223
+ function handlePointerMove(event: PointerEvent) {
224
+ const target = event.target;
225
+ if (!(target instanceof Element)) { hoveredIndex = null; return; }
226
+ const idx = Number(target.getAttribute("data-chart-index"));
227
+ hoveredIndex = Number.isInteger(idx) ? idx : null;
228
+ }
229
+
230
+ const classes = () => ["st-bulletChart", className].filter(Boolean).join(" ");
231
+ </script>
232
+
233
+ <div class={classes()}>
234
+ <div
235
+ class="st-bulletChart__visual"
236
+ role="img"
237
+ aria-label={label}
238
+ onpointermove={handlePointerMove}
239
+ onpointerleave={() => (hoveredIndex = null)}
240
+ >
241
+ <svg
242
+ viewBox="0 0 {width} {height}"
243
+ preserveAspectRatio="xMidYMid meet"
244
+ width="100%"
245
+ height="100%"
246
+ focusable="false"
247
+ aria-hidden="true"
248
+ >
249
+ <!-- FIX #3 : baseline à la position de 0 -->
250
+ {#if orientation === "horizontal"}
251
+ <line
252
+ class="st-bulletChart__baseline"
253
+ x1={baselineX}
254
+ x2={baselineX}
255
+ y1={MARGIN.top}
256
+ y2={height - MARGIN.bottom}
257
+ />
258
+ {:else}
259
+ <line
260
+ class="st-bulletChart__baseline"
261
+ x1={MARGIN.left}
262
+ x2={width - MARGIN.right}
263
+ y1={baselineY}
264
+ y2={baselineY}
265
+ />
266
+ {/if}
267
+
268
+ <!-- axis lines -->
269
+ <line
270
+ class="st-bulletChart__axis"
271
+ x1={MARGIN.left}
272
+ x2={orientation === "horizontal" ? width - MARGIN.right : MARGIN.left}
273
+ y1={orientation === "horizontal" ? MARGIN.top : MARGIN.top}
274
+ y2={orientation === "horizontal" ? height - MARGIN.bottom : height - MARGIN.bottom}
275
+ />
276
+ <line
277
+ class="st-bulletChart__axis"
278
+ x1={MARGIN.left}
279
+ x2={orientation === "horizontal" ? MARGIN.left : width - MARGIN.right}
280
+ y1={height - MARGIN.bottom}
281
+ y2={height - MARGIN.bottom}
282
+ />
283
+
284
+ <!-- tick lines + labels -->
285
+ {#each ticks as tick (tick)}
286
+ {#if orientation === "horizontal"}
287
+ {@const tx = MARGIN.left + scaleLinear(tick, tickDomainMin, tickDomainMax, 0, plotWidth)}
288
+ <line class="st-bulletChart__grid" x1={tx} x2={tx} y1={MARGIN.top} y2={height - MARGIN.bottom} />
289
+ <text class="st-bulletChart__tickLabel" x={tx} y={height - MARGIN.bottom + 14} text-anchor="middle">
290
+ {formatTick(tick)}
291
+ </text>
292
+ {:else}
293
+ {@const ty = MARGIN.top + scaleLinear(tick, tickDomainMin, tickDomainMax, plotHeight, 0)}
294
+ <line class="st-bulletChart__grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={ty} y2={ty} />
295
+ <text class="st-bulletChart__tickLabel" x={MARGIN.left - 6} y={ty} text-anchor="end" dominant-baseline="middle">
296
+ {formatTick(tick)}
297
+ </text>
298
+ {/if}
299
+ {/each}
300
+
301
+ <!-- FIX #7 : clé composite pour éviter les doublons -->
302
+ {#each bullets as b, i (`${i}-${b.datum.label}`)}
303
+ <!-- qualitative range bands -->
304
+ {#each b.rangeBands as rb, ri (ri)}
305
+ <rect
306
+ class="st-bulletChart__range"
307
+ x={rb.x ?? b.barX}
308
+ y={rb.y ?? b.barY}
309
+ width={orientation === "horizontal" ? rb.width : rb.width}
310
+ height={orientation === "horizontal" ? rb.height : Math.abs(rb.height ?? 0)}
311
+ style="opacity: {rb.opacity}"
312
+ />
313
+ {/each}
314
+
315
+ <!-- measure bar -->
316
+ {#if orientation === "horizontal"}
317
+ <rect
318
+ class="st-bulletChart__bar"
319
+ x={b.barX}
320
+ y={b.barY}
321
+ width={b.barW}
322
+ height={b.barH}
323
+ rx="1"
324
+ data-chart-index={i}
325
+ />
326
+ <!-- target marker -->
327
+ <line
328
+ class="st-bulletChart__target"
329
+ x1={b.targetX}
330
+ x2={b.targetX}
331
+ y1={b.targetY}
332
+ y2={b.targetY + b.targetH}
333
+ />
334
+ <!-- category label -->
335
+ <text
336
+ class="st-bulletChart__categoryLabel"
337
+ x={MARGIN.left - 8}
338
+ y={b.labelY}
339
+ text-anchor="end"
340
+ dominant-baseline="middle"
341
+ >
342
+ {b.datum.label}
343
+ </text>
344
+ {:else}
345
+ <rect
346
+ class="st-bulletChart__bar"
347
+ x={b.barX}
348
+ y={b.barY}
349
+ width={b.barW}
350
+ height={b.barH}
351
+ rx="1"
352
+ data-chart-index={i}
353
+ />
354
+ <line
355
+ class="st-bulletChart__target"
356
+ x1={b.targetX}
357
+ x2={b.targetX + b.targetH}
358
+ y1={b.targetY}
359
+ y2={b.targetY}
360
+ />
361
+ <text
362
+ class="st-bulletChart__categoryLabel"
363
+ x={b.labelX}
364
+ y={b.labelY}
365
+ text-anchor="middle"
366
+ >
367
+ {b.datum.label}
368
+ </text>
369
+ {/if}
370
+ {/each}
371
+ </svg>
372
+ </div>
373
+
374
+ <ChartDataList {label} items={dataValueItems} />
375
+
376
+ {#if hoveredIndex !== null && bullets[hoveredIndex]}
377
+ {@const b = bullets[hoveredIndex]}
378
+ <div
379
+ class="st-bulletChart__tooltip"
380
+ role="presentation"
381
+ style="left: {(b.tooltipX / width) * 100}%; top: {(b.tooltipY / height) * 100}%"
382
+ >
383
+ <span class="st-bulletChart__tooltipLabel">{b.datum.label}</span>
384
+ <span class="st-bulletChart__tooltipValue">value: {b.datum.value} / target: {b.datum.target}</span>
385
+ </div>
386
+ {/if}
387
+ </div>
388
+
389
+ <style>
390
+ .st-bulletChart {
391
+ color: var(--st-semantic-text-secondary);
392
+ display: block;
393
+ font-family: inherit;
394
+ position: relative;
395
+ width: 100%;
396
+ }
397
+
398
+ .st-bulletChart svg {
399
+ display: block;
400
+ overflow: visible;
401
+ }
402
+
403
+ .st-bulletChart__visual {
404
+ display: block;
405
+ }
406
+
407
+ .st-bulletChart__axis {
408
+ stroke: var(--st-semantic-border-subtle);
409
+ stroke-width: 1;
410
+ }
411
+
412
+ .st-bulletChart__baseline {
413
+ stroke: var(--st-semantic-border-subtle);
414
+ stroke-width: 1.5;
415
+ }
416
+
417
+ .st-bulletChart__grid {
418
+ stroke: var(--st-semantic-border-subtle);
419
+ stroke-dasharray: 2 3;
420
+ stroke-width: 1;
421
+ opacity: 0.6;
422
+ }
423
+
424
+ .st-bulletChart__range {
425
+ fill: var(--st-semantic-data-category1);
426
+ }
427
+
428
+ .st-bulletChart__bar {
429
+ cursor: pointer;
430
+ fill: var(--st-semantic-text-secondary);
431
+ transition: opacity 120ms ease;
432
+ }
433
+
434
+ .st-bulletChart__bar:hover {
435
+ opacity: 0.75;
436
+ }
437
+
438
+ @media (prefers-reduced-motion: reduce) {
439
+ .st-bulletChart__bar {
440
+ transition: none;
441
+ }
442
+ }
443
+
444
+ .st-bulletChart__target {
445
+ stroke: var(--st-semantic-text-primary, currentColor);
446
+ stroke-width: 2.5;
447
+ }
448
+
449
+ .st-bulletChart__tickLabel,
450
+ .st-bulletChart__categoryLabel {
451
+ fill: var(--st-semantic-text-secondary);
452
+ font-size: 0.6875rem;
453
+ }
454
+
455
+ .st-bulletChart__tooltip {
456
+ background: var(--st-semantic-surface-inverse);
457
+ border-radius: var(--st-radius-sm, 0.25rem);
458
+ color: var(--st-semantic-text-inverse);
459
+ display: inline-flex;
460
+ flex-direction: column;
461
+ font-size: 0.75rem;
462
+ gap: 0.125rem;
463
+ line-height: 1.2;
464
+ padding: 0.375rem 0.5rem;
465
+ pointer-events: none;
466
+ position: absolute;
467
+ transform: translate(-50%, calc(-100% - 8px));
468
+ white-space: nowrap;
469
+ z-index: 1;
470
+ }
471
+
472
+ .st-bulletChart__tooltipLabel {
473
+ font-weight: 600;
474
+ }
475
+
476
+ .st-bulletChart__tooltipValue {
477
+ opacity: 0.85;
478
+ }
479
+ </style>
@@ -0,0 +1,32 @@
1
+ /**
2
+ * BulletChart - Tableau bullet graph (mesure vs cible + bandes qualitatives).
3
+ * API canonique (référence Svelte, React/Vue doivent s'aligner)
4
+ *
5
+ * Props obligatoires :
6
+ * data BulletChartDatum[] - tableau {label, value, target, ranges?}
7
+ * label string - aria-label du graphique
8
+ *
9
+ * Props optionnelles :
10
+ * orientation "horizontal"|"vertical" (défaut "horizontal")
11
+ * width number (défaut 480)
12
+ * height number (défaut 240)
13
+ * class string
14
+ */
15
+ export type BulletChartDatum = {
16
+ label: string;
17
+ value: number;
18
+ target: number;
19
+ ranges?: number[];
20
+ };
21
+ type BulletChartProps = {
22
+ data: BulletChartDatum[];
23
+ label: string;
24
+ orientation?: "horizontal" | "vertical";
25
+ width?: number;
26
+ height?: number;
27
+ class?: string;
28
+ };
29
+ declare const BulletChart: import("svelte").Component<BulletChartProps, {}, "">;
30
+ type BulletChart = ReturnType<typeof BulletChart>;
31
+ export default BulletChart;
32
+ //# sourceMappingURL=BulletChart.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BulletChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/BulletChart.svelte.ts"],"names":[],"mappings":"AAGE;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB,CAAC;AAMF,KAAK,gBAAgB,GAAG;IACtB,IAAI,EAAE,gBAAgB,EAAE,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAqRJ,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}