@sentropic/design-system-svelte 0.34.34 → 0.34.36

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,439 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * RibbonChart - rang empilé à rubans dans le temps (façon Power BI « ribbon
4
+ * chart »). Par période, les catégories sont empilées (hauteur = value) et
5
+ * TRIÉES par valeur (la plus grande en bas) ; des RUBANS lissés relient le
6
+ * segment d'une même `category` d'une période à la suivante, matérialisant le
7
+ * flux de rang. Distinct d'un StackedBarChart classique : l'ordre d'empilement
8
+ * est un classement par valeur et les rubans inter-périodes encodent la
9
+ * continuité d'une catégorie.
10
+ * API canonique (référence Svelte, React/Vue doivent s'aligner).
11
+ *
12
+ * Modèle : catégories et périodes dérivées des `data`. La couleur suit `tone`
13
+ * si fourni, sinon une teinte stable par `category` (cycle sur category1..8).
14
+ * Légende des catégories sous le graphe via ChartDataList.
15
+ *
16
+ * Props obligatoires :
17
+ * data RibbonChartDatum[] - tableau {category, period, value, tone?}
18
+ *
19
+ * Props optionnelles :
20
+ * label string
21
+ * width number (défaut 520)
22
+ * height number (défaut 300)
23
+ * size number (alias de width)
24
+ * class string
25
+ */
26
+ export type RibbonChartTone =
27
+ | "category1" | "category2" | "category3" | "category4"
28
+ | "category5" | "category6" | "category7" | "category8";
29
+
30
+ export type RibbonChartDatum = {
31
+ category: string;
32
+ period: string | number;
33
+ value: number;
34
+ tone?: RibbonChartTone;
35
+ };
36
+ </script>
37
+
38
+ <script lang="ts">
39
+ import ChartDataList from "./ChartDataList.svelte";
40
+
41
+ type RibbonChartProps = {
42
+ data: RibbonChartDatum[];
43
+ label?: string;
44
+ width?: number;
45
+ height?: number;
46
+ size?: number;
47
+ class?: string;
48
+ };
49
+
50
+ let {
51
+ data = [],
52
+ label,
53
+ width,
54
+ height = 300,
55
+ size,
56
+ class: className
57
+ }: RibbonChartProps = $props();
58
+
59
+ const resolvedWidth = $derived(width ?? size ?? 520);
60
+
61
+ const MARGIN = { top: 16, right: 16, bottom: 32, left: 16 };
62
+ const RIBBON_SMOOTH = 0.4;
63
+
64
+ const TONES = [
65
+ "category1","category2","category3","category4","category5","category6","category7","category8"
66
+ ] as const;
67
+
68
+ let hoveredKey: string | null = $state(null);
69
+
70
+ const plotWidth = $derived(Math.max(resolvedWidth - MARGIN.left - MARGIN.right, 1));
71
+ const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
72
+
73
+ // Normalise : filtre les data sans catégorie et de valeur non finie/< 0.
74
+ const validData = $derived(
75
+ data.filter(
76
+ (d) =>
77
+ typeof d.category === "string" &&
78
+ d.category.length > 0 &&
79
+ Number.isFinite(d.value) &&
80
+ d.value >= 0
81
+ )
82
+ );
83
+
84
+ // Périodes distinctes (ordre d'apparition), colonnes empilées de gauche à droite.
85
+ const periodOrder = $derived.by(() => {
86
+ const seen: (string | number)[] = [];
87
+ for (const d of validData) {
88
+ if (!seen.includes(d.period)) seen.push(d.period);
89
+ }
90
+ return seen;
91
+ });
92
+
93
+ // Catégories distinctes (ordre d'apparition) → index categoryN (1..8, cyclé)
94
+ // si aucun `tone` explicite. Un `tone` sur une donnée l'emporte sur la dérivation.
95
+ const categoryOrder = $derived.by(() => {
96
+ const seen: string[] = [];
97
+ for (const d of validData) {
98
+ if (!seen.includes(d.category)) seen.push(d.category);
99
+ }
100
+ return seen;
101
+ });
102
+ const explicitToneByCategory = $derived.by(() => {
103
+ const map = new Map<string, RibbonChartTone>();
104
+ for (const d of validData) {
105
+ if (d.tone) map.set(d.category, d.tone);
106
+ }
107
+ return map;
108
+ });
109
+ const toneOf = (category: string): RibbonChartTone => {
110
+ const explicit = explicitToneByCategory.get(category);
111
+ if (explicit) return explicit;
112
+ const idx = categoryOrder.indexOf(category);
113
+ return `category${((idx < 0 ? 0 : idx) % 8) + 1}` as RibbonChartTone;
114
+ };
115
+
116
+ // Empilement par période : segments TRIÉS par valeur décroissante (rang), la
117
+ // plus grande catégorie au pied. Pour chaque segment on garde y haut/bas afin
118
+ // de tracer les rubans inter-périodes par catégorie.
119
+ type Segment = {
120
+ key: string;
121
+ category: string;
122
+ value: number;
123
+ tone: RibbonChartTone;
124
+ x: number;
125
+ width: number;
126
+ yTop: number;
127
+ yBottom: number;
128
+ cx: number;
129
+ cy: number;
130
+ };
131
+
132
+ const columns = $derived.by(() => {
133
+ if (validData.length === 0 || periodOrder.length === 0) return [] as {
134
+ period: string | number;
135
+ index: number;
136
+ cx: number;
137
+ segments: Segment[];
138
+ }[];
139
+ const band = plotWidth / periodOrder.length;
140
+ const barWidth = Math.min(band * 0.5, 72);
141
+ // Domaine vertical = plus grande somme de période (toutes empilées).
142
+ const totals = periodOrder.map((p) =>
143
+ validData.filter((d) => d.period === p).reduce((s, d) => s + Math.max(d.value, 0), 0)
144
+ );
145
+ const domainMax = Math.max(1, ...totals);
146
+ return periodOrder.map((period, pi) => {
147
+ const x = MARGIN.left + band * pi + (band - barWidth) / 2;
148
+ const rows = validData
149
+ .filter((d) => d.period === period)
150
+ .map((d) => ({ category: d.category, value: Math.max(d.value, 0) }))
151
+ // Tri par rang : valeur décroissante (la plus grande au pied).
152
+ .sort((a, b) => b.value - a.value);
153
+ let acc = 0;
154
+ const segments = rows.map((row, ri) => {
155
+ const h = (row.value / domainMax) * plotHeight;
156
+ const yBottom = MARGIN.top + plotHeight - acc;
157
+ const yTop = yBottom - h;
158
+ acc += h;
159
+ return {
160
+ key: `${pi}-${ri}-${row.category}`,
161
+ category: row.category,
162
+ value: row.value,
163
+ tone: toneOf(row.category),
164
+ x,
165
+ width: barWidth,
166
+ yTop,
167
+ yBottom,
168
+ cx: x + barWidth / 2,
169
+ cy: yTop + (yBottom - yTop) / 2
170
+ } satisfies Segment;
171
+ });
172
+ return { period, index: pi, cx: MARGIN.left + band * (pi + 0.5), segments };
173
+ });
174
+ });
175
+
176
+ // Rubans : pour chaque catégorie présente dans deux périodes consécutives, une
177
+ // bande lissée reliant le segment gauche (bord droit) au segment droit (bord
178
+ // gauche). Quadrilatère à bords supérieur/inférieur en cubiques.
179
+ type Ribbon = { key: string; category: string; tone: RibbonChartTone; d: string };
180
+
181
+ const ribbons = $derived.by(() => {
182
+ const out: Ribbon[] = [];
183
+ for (let ci = 0; ci < columns.length - 1; ci++) {
184
+ const left = columns[ci];
185
+ const right = columns[ci + 1];
186
+ for (const ls of left.segments) {
187
+ const rs = right.segments.find((s) => s.category === ls.category);
188
+ if (!rs) continue;
189
+ const x0 = ls.x + ls.width;
190
+ const x1 = rs.x;
191
+ const mid = x0 + (x1 - x0) * RIBBON_SMOOTH;
192
+ const mid2 = x1 - (x1 - x0) * RIBBON_SMOOTH;
193
+ // Bord supérieur (gauche→droite) puis bord inférieur (droite→gauche).
194
+ const d =
195
+ `M${x0.toFixed(2)},${ls.yTop.toFixed(2)} ` +
196
+ `C${mid.toFixed(2)},${ls.yTop.toFixed(2)} ${mid2.toFixed(2)},${rs.yTop.toFixed(2)} ${x1.toFixed(2)},${rs.yTop.toFixed(2)} ` +
197
+ `L${x1.toFixed(2)},${rs.yBottom.toFixed(2)} ` +
198
+ `C${mid2.toFixed(2)},${rs.yBottom.toFixed(2)} ${mid.toFixed(2)},${ls.yBottom.toFixed(2)} ${x0.toFixed(2)},${ls.yBottom.toFixed(2)} Z`;
199
+ out.push({ key: `${ci}-${ls.category}`, category: ls.category, tone: ls.tone, d });
200
+ }
201
+ }
202
+ return out;
203
+ });
204
+
205
+ const legendItems = $derived(categoryOrder.map((category) => ({ category, tone: toneOf(category) })));
206
+ const hasLegend = $derived(categoryOrder.length > 0);
207
+
208
+ const dataValueItems = $derived(
209
+ categoryOrder.map(
210
+ (category) =>
211
+ `${category}: ${periodOrder
212
+ .map((p) => {
213
+ const found = validData.find((d) => d.category === category && d.period === p);
214
+ return `${p} = ${found ? found.value : 0}`;
215
+ })
216
+ .join(", ")}`
217
+ )
218
+ );
219
+
220
+ function handlePointerMove(event: PointerEvent) {
221
+ const target = event.target;
222
+ if (!(target instanceof Element)) {
223
+ hoveredKey = null;
224
+ return;
225
+ }
226
+ const key = target.getAttribute("data-chart-key");
227
+ hoveredKey = key ?? null;
228
+ }
229
+
230
+ const hoveredSegment = $derived.by(() => {
231
+ if (hoveredKey === null) return null;
232
+ for (const col of columns) {
233
+ for (const seg of col.segments) {
234
+ if (seg.key === hoveredKey) return seg;
235
+ }
236
+ }
237
+ return null;
238
+ });
239
+
240
+ const classes = () => ["st-ribbonChart", className].filter(Boolean).join(" ");
241
+ </script>
242
+
243
+ <div class={classes()}>
244
+ <div
245
+ class="st-ribbonChart__visual"
246
+ role="img"
247
+ aria-label={label}
248
+ onpointermove={handlePointerMove}
249
+ onpointerleave={() => (hoveredKey = null)}
250
+ >
251
+ <svg
252
+ viewBox="0 0 {resolvedWidth} {height}"
253
+ preserveAspectRatio="xMidYMid meet"
254
+ width="100%"
255
+ height="100%"
256
+ focusable="false"
257
+ aria-hidden="true"
258
+ >
259
+ <!-- rubans inter-périodes (sous les segments) -->
260
+ {#each ribbons as ribbon (ribbon.key)}
261
+ <path
262
+ class="st-ribbonChart__ribbon st-ribbonChart__ribbon--{ribbon.tone}"
263
+ class:st-ribbonChart__ribbon--dim={hoveredSegment !== null && hoveredSegment.category !== ribbon.category}
264
+ d={ribbon.d}
265
+ />
266
+ {/each}
267
+
268
+ <!-- segments empilés par période -->
269
+ {#each columns as col (col.index)}
270
+ <text class="st-ribbonChart__periodLabel" x={col.cx} y={height - MARGIN.bottom + 16} text-anchor="middle">
271
+ {col.period}
272
+ </text>
273
+ {#each col.segments as seg (seg.key)}
274
+ <rect
275
+ class="st-ribbonChart__seg st-ribbonChart__seg--{seg.tone}"
276
+ class:st-ribbonChart__seg--dim={hoveredSegment !== null && hoveredSegment.category !== seg.category}
277
+ x={seg.x}
278
+ y={seg.yTop}
279
+ width={seg.width}
280
+ height={Math.max(seg.yBottom - seg.yTop, 0)}
281
+ rx="2"
282
+ data-chart-key={seg.key}
283
+ />
284
+ {/each}
285
+ {/each}
286
+ </svg>
287
+ </div>
288
+
289
+ {#if hasLegend}
290
+ <ul class="st-ribbonChart__legend" aria-label={`Catégories de ${label ?? "ribbon"}`}>
291
+ {#each legendItems as item (item.category)}
292
+ <li class="st-ribbonChart__legendItem">
293
+ <span class="st-ribbonChart__legendSwatch st-ribbonChart__legendSwatch--{item.tone}" aria-hidden="true"></span>
294
+ {item.category}
295
+ </li>
296
+ {/each}
297
+ </ul>
298
+ {/if}
299
+
300
+ <ChartDataList label={label ?? "ribbon"} items={dataValueItems} />
301
+
302
+ {#if hoveredSegment}
303
+ {@const seg = hoveredSegment}
304
+ <div
305
+ class="st-ribbonChart__tooltip"
306
+ role="presentation"
307
+ style="left: {(seg.cx / resolvedWidth) * 100}%; top: {(seg.cy / height) * 100}%"
308
+ >
309
+ <span class="st-ribbonChart__tooltipLabel">{seg.category}</span>
310
+ <span class="st-ribbonChart__tooltipValue">{seg.value}</span>
311
+ </div>
312
+ {/if}
313
+ </div>
314
+
315
+ <style>
316
+ .st-ribbonChart {
317
+ color: var(--st-semantic-text-secondary);
318
+ display: block;
319
+ font-family: inherit;
320
+ position: relative;
321
+ width: 100%;
322
+ }
323
+
324
+ .st-ribbonChart svg {
325
+ display: block;
326
+ overflow: visible;
327
+ }
328
+
329
+ .st-ribbonChart__visual {
330
+ display: block;
331
+ }
332
+
333
+ .st-ribbonChart__periodLabel {
334
+ fill: var(--st-semantic-text-secondary);
335
+ font-size: 0.6875rem;
336
+ }
337
+
338
+ .st-ribbonChart__ribbon {
339
+ opacity: 0.34;
340
+ transition: opacity 120ms ease;
341
+ }
342
+
343
+ .st-ribbonChart__ribbon--dim {
344
+ opacity: 0.1;
345
+ }
346
+
347
+ .st-ribbonChart__seg {
348
+ cursor: pointer;
349
+ stroke: var(--st-semantic-surface-default, Canvas);
350
+ stroke-width: 1;
351
+ transition: opacity 120ms ease;
352
+ }
353
+
354
+ .st-ribbonChart__seg--dim {
355
+ opacity: 0.4;
356
+ }
357
+
358
+ .st-ribbonChart__seg--category1,
359
+ .st-ribbonChart__ribbon--category1 { fill: var(--st-semantic-data-category1); }
360
+ .st-ribbonChart__seg--category2,
361
+ .st-ribbonChart__ribbon--category2 { fill: var(--st-semantic-data-category2); }
362
+ .st-ribbonChart__seg--category3,
363
+ .st-ribbonChart__ribbon--category3 { fill: var(--st-semantic-data-category3); }
364
+ .st-ribbonChart__seg--category4,
365
+ .st-ribbonChart__ribbon--category4 { fill: var(--st-semantic-data-category4); }
366
+ .st-ribbonChart__seg--category5,
367
+ .st-ribbonChart__ribbon--category5 { fill: var(--st-semantic-data-category5); }
368
+ .st-ribbonChart__seg--category6,
369
+ .st-ribbonChart__ribbon--category6 { fill: var(--st-semantic-data-category6); }
370
+ .st-ribbonChart__seg--category7,
371
+ .st-ribbonChart__ribbon--category7 { fill: var(--st-semantic-data-category7); }
372
+ .st-ribbonChart__seg--category8,
373
+ .st-ribbonChart__ribbon--category8 { fill: var(--st-semantic-data-category8); }
374
+
375
+ .st-ribbonChart__legend {
376
+ display: flex;
377
+ flex-wrap: wrap;
378
+ gap: var(--st-spacing-3, 0.75rem);
379
+ list-style: none;
380
+ margin: var(--st-spacing-2, 0.5rem) 0 0 0;
381
+ padding: 0;
382
+ }
383
+
384
+ .st-ribbonChart__legendItem {
385
+ align-items: center;
386
+ color: var(--st-semantic-text-secondary);
387
+ display: inline-flex;
388
+ font-size: 0.75rem;
389
+ gap: var(--st-spacing-1, 0.25rem);
390
+ line-height: 1;
391
+ }
392
+
393
+ .st-ribbonChart__legendSwatch {
394
+ border-radius: var(--st-radius-sm, 0.25rem);
395
+ display: inline-block;
396
+ height: 0.625rem;
397
+ width: 0.625rem;
398
+ }
399
+ .st-ribbonChart__legendSwatch--category1 { background: var(--st-semantic-data-category1); }
400
+ .st-ribbonChart__legendSwatch--category2 { background: var(--st-semantic-data-category2); }
401
+ .st-ribbonChart__legendSwatch--category3 { background: var(--st-semantic-data-category3); }
402
+ .st-ribbonChart__legendSwatch--category4 { background: var(--st-semantic-data-category4); }
403
+ .st-ribbonChart__legendSwatch--category5 { background: var(--st-semantic-data-category5); }
404
+ .st-ribbonChart__legendSwatch--category6 { background: var(--st-semantic-data-category6); }
405
+ .st-ribbonChart__legendSwatch--category7 { background: var(--st-semantic-data-category7); }
406
+ .st-ribbonChart__legendSwatch--category8 { background: var(--st-semantic-data-category8); }
407
+
408
+ .st-ribbonChart__tooltip {
409
+ background: var(--st-semantic-surface-inverse);
410
+ border-radius: var(--st-radius-sm, 0.25rem);
411
+ color: var(--st-semantic-text-inverse);
412
+ display: inline-flex;
413
+ flex-direction: column;
414
+ font-size: 0.75rem;
415
+ gap: 0.125rem;
416
+ line-height: 1.2;
417
+ padding: 0.375rem 0.5rem;
418
+ pointer-events: none;
419
+ position: absolute;
420
+ transform: translate(-50%, calc(-100% - 8px));
421
+ white-space: nowrap;
422
+ z-index: 1;
423
+ }
424
+
425
+ .st-ribbonChart__tooltipLabel {
426
+ font-weight: 600;
427
+ }
428
+
429
+ .st-ribbonChart__tooltipValue {
430
+ opacity: 0.85;
431
+ }
432
+
433
+ @media (prefers-reduced-motion: reduce) {
434
+ .st-ribbonChart__seg,
435
+ .st-ribbonChart__ribbon {
436
+ transition: none;
437
+ }
438
+ }
439
+ </style>
@@ -0,0 +1,43 @@
1
+ /**
2
+ * RibbonChart - rang empilé à rubans dans le temps (façon Power BI « ribbon
3
+ * chart »). Par période, les catégories sont empilées (hauteur = value) et
4
+ * TRIÉES par valeur (la plus grande en bas) ; des RUBANS lissés relient le
5
+ * segment d'une même `category` d'une période à la suivante, matérialisant le
6
+ * flux de rang. Distinct d'un StackedBarChart classique : l'ordre d'empilement
7
+ * est un classement par valeur et les rubans inter-périodes encodent la
8
+ * continuité d'une catégorie.
9
+ * API canonique (référence Svelte, React/Vue doivent s'aligner).
10
+ *
11
+ * Modèle : catégories et périodes dérivées des `data`. La couleur suit `tone`
12
+ * si fourni, sinon une teinte stable par `category` (cycle sur category1..8).
13
+ * Légende des catégories sous le graphe via ChartDataList.
14
+ *
15
+ * Props obligatoires :
16
+ * data RibbonChartDatum[] - tableau {category, period, value, tone?}
17
+ *
18
+ * Props optionnelles :
19
+ * label string
20
+ * width number (défaut 520)
21
+ * height number (défaut 300)
22
+ * size number (alias de width)
23
+ * class string
24
+ */
25
+ export type RibbonChartTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
26
+ export type RibbonChartDatum = {
27
+ category: string;
28
+ period: string | number;
29
+ value: number;
30
+ tone?: RibbonChartTone;
31
+ };
32
+ type RibbonChartProps = {
33
+ data: RibbonChartDatum[];
34
+ label?: string;
35
+ width?: number;
36
+ height?: number;
37
+ size?: number;
38
+ class?: string;
39
+ };
40
+ declare const RibbonChart: import("svelte").Component<RibbonChartProps, {}, "">;
41
+ type RibbonChart = ReturnType<typeof RibbonChart>;
42
+ export default RibbonChart;
43
+ //# sourceMappingURL=RibbonChart.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RibbonChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/RibbonChart.svelte.ts"],"names":[],"mappings":"AAGE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,MAAM,eAAe,GACvB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,eAAe,CAAC;CACxB,CAAC;AAMF,KAAK,gBAAgB,GAAG;IACtB,IAAI,EAAE,gBAAgB,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAwPJ,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}