@sentropic/design-system-svelte 0.18.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.
Files changed (43) hide show
  1. package/dist/BoxPlotChart.svelte +302 -0
  2. package/dist/BoxPlotChart.svelte.d.ts +40 -0
  3. package/dist/BoxPlotChart.svelte.d.ts.map +1 -0
  4. package/dist/BulletChart.svelte +479 -0
  5. package/dist/BulletChart.svelte.d.ts +32 -0
  6. package/dist/BulletChart.svelte.d.ts.map +1 -0
  7. package/dist/BumpChart.svelte +387 -0
  8. package/dist/BumpChart.svelte.d.ts +35 -0
  9. package/dist/BumpChart.svelte.d.ts.map +1 -0
  10. package/dist/CalendarHeatmapChart.svelte +345 -0
  11. package/dist/CalendarHeatmapChart.svelte.d.ts +28 -0
  12. package/dist/CalendarHeatmapChart.svelte.d.ts.map +1 -0
  13. package/dist/CandlestickChart.svelte +333 -0
  14. package/dist/CandlestickChart.svelte.d.ts +31 -0
  15. package/dist/CandlestickChart.svelte.d.ts.map +1 -0
  16. package/dist/HeatmapChart.svelte +337 -0
  17. package/dist/HeatmapChart.svelte.d.ts +35 -0
  18. package/dist/HeatmapChart.svelte.d.ts.map +1 -0
  19. package/dist/HistogramChart.svelte +294 -0
  20. package/dist/HistogramChart.svelte.d.ts +38 -0
  21. package/dist/HistogramChart.svelte.d.ts.map +1 -0
  22. package/dist/MarimekkoChart.svelte +319 -0
  23. package/dist/MarimekkoChart.svelte.d.ts +35 -0
  24. package/dist/MarimekkoChart.svelte.d.ts.map +1 -0
  25. package/dist/ParallelCoordinatesChart.svelte +315 -0
  26. package/dist/ParallelCoordinatesChart.svelte.d.ts +35 -0
  27. package/dist/ParallelCoordinatesChart.svelte.d.ts.map +1 -0
  28. package/dist/RadarChart.svelte +340 -0
  29. package/dist/RadarChart.svelte.d.ts +43 -0
  30. package/dist/RadarChart.svelte.d.ts.map +1 -0
  31. package/dist/SankeyChart.svelte +364 -0
  32. package/dist/SankeyChart.svelte.d.ts +45 -0
  33. package/dist/SankeyChart.svelte.d.ts.map +1 -0
  34. package/dist/SunburstChart.svelte +388 -0
  35. package/dist/SunburstChart.svelte.d.ts +39 -0
  36. package/dist/SunburstChart.svelte.d.ts.map +1 -0
  37. package/dist/chartContrast.d.ts +0 -4
  38. package/dist/chartContrast.d.ts.map +1 -1
  39. package/dist/chartContrast.js +4 -56
  40. package/dist/index.d.ts +24 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +12 -0
  43. package/package.json +1 -1
@@ -0,0 +1,345 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * CalendarHeatmapChart - grille jour×semaine (GitHub-style).
4
+ * API canonique (référence Svelte, React/Vue doivent s'aligner)
5
+ *
6
+ * Props obligatoires :
7
+ * data CalendarHeatmapChartDatum[] - tableau {date: "YYYY-MM-DD", value}
8
+ * label string
9
+ *
10
+ * Props optionnelles :
11
+ * width number (défaut 480)
12
+ * height number (défaut 140)
13
+ * class string
14
+ */
15
+ export type CalendarHeatmapChartDatum = {
16
+ date: string;
17
+ value: number;
18
+ };
19
+ </script>
20
+
21
+ <script lang="ts">
22
+ import ChartDataList from "./ChartDataList.svelte";
23
+
24
+ const TONES = [
25
+ "category1","category2","category3","category4",
26
+ "category5","category6","category7","category8"
27
+ ] as const;
28
+
29
+ type CalendarHeatmapChartProps = {
30
+ data: CalendarHeatmapChartDatum[];
31
+ label: string;
32
+ width?: number;
33
+ height?: number;
34
+ class?: string;
35
+ };
36
+
37
+ let {
38
+ data = [],
39
+ label,
40
+ width = 480,
41
+ height = 140,
42
+ class: className
43
+ }: CalendarHeatmapChartProps = $props();
44
+
45
+ const MARGIN = { top: 24, right: 8, bottom: 8, left: 24 };
46
+ const DAY_LABELS = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
47
+ const MONTH_ABBR = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
48
+
49
+ let hoveredDate: string | null = $state(null);
50
+
51
+ const plotWidth = $derived(Math.max(width - MARGIN.left - MARGIN.right, 1));
52
+ const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
53
+
54
+ /**
55
+ * FIX #2 : parse strict YYYY-MM-DD en UTC.
56
+ * Retourne null si la date n'est pas valide ou si le round-trip échoue.
57
+ */
58
+ function parseUTCDate(dateStr: string): { year: number; month: number; day: number; ts: number } | null {
59
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
60
+ if (!m) return null;
61
+ const year = Number(m[1]);
62
+ const month = Number(m[2]);
63
+ const day = Number(m[3]);
64
+ // Validation basique des bornes
65
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null;
66
+ const ts = Date.UTC(year, month - 1, day);
67
+ // Round-trip : la date UTC re-sérialisée doit valoir l'entrée
68
+ const check = new Date(ts).toISOString().slice(0, 10);
69
+ if (check !== dateStr) return null;
70
+ return { year, month, day, ts };
71
+ }
72
+
73
+ /** Nombre de jours entiers entre deux timestamps UTC (diff en ms / ms-par-jour) */
74
+ function daysDiff(tsA: number, tsB: number): number {
75
+ return Math.round((tsB - tsA) / 86400000);
76
+ }
77
+
78
+ // Group data by week and day-of-week
79
+ const grid = $derived.by(() => {
80
+ if (data.length === 0) return { cells: [], weeks: 0, monthLabels: [] };
81
+
82
+ // FIX #2 : valider strictement chaque date (parse UTC + round-trip)
83
+ const validData = data.filter((d) => parseUTCDate(d.date) !== null && Number.isFinite(d.value));
84
+ if (validData.length === 0) return { cells: [], weeks: 0, monthLabels: [] };
85
+
86
+ // Sort by date
87
+ const sorted = [...validData].sort((a, b) => a.date.localeCompare(b.date));
88
+
89
+ const firstParsed = parseUTCDate(sorted[0].date)!;
90
+ const lastParsed = parseUTCDate(sorted[sorted.length - 1].date)!;
91
+
92
+ // Compute value range
93
+ const vals = sorted.map((d) => d.value);
94
+ const minVal = Math.min(...vals);
95
+ const maxVal = Math.max(...vals);
96
+ const valueRange = maxVal > minVal ? maxVal - minVal : 1;
97
+
98
+ // Build a lookup map
99
+ const map = new Map<string, number>();
100
+ for (const d of sorted) {
101
+ map.set(d.date, d.value);
102
+ }
103
+
104
+ // FIX #2 : utiliser getUTCDay() pour le jour-de-semaine, JAMAIS getDay() local
105
+ const firstDate = new Date(firstParsed.ts);
106
+ const lastDate = new Date(lastParsed.ts);
107
+
108
+ // Determine grid start: Sunday of the week containing firstDate (UTC)
109
+ const startDOW = firstDate.getUTCDay(); // 0=Sun
110
+ const gridStartTs = firstParsed.ts - startDOW * 86400000;
111
+
112
+ // Determine grid end: Saturday of the week containing lastDate (UTC)
113
+ const endDOW = lastDate.getUTCDay();
114
+ const gridEndTs = lastParsed.ts + (6 - endDOW) * 86400000;
115
+
116
+ // FIX #2 : compter les semaines en jours calendaires entiers
117
+ const totalDays = daysDiff(gridStartTs, gridEndTs) + 1;
118
+ const weeks = Math.ceil(totalDays / 7);
119
+ const cellW = plotWidth / Math.max(weeks, 1);
120
+ const cellH = plotHeight / 7;
121
+
122
+ const cells: {
123
+ date: string;
124
+ value: number | null;
125
+ tone: typeof TONES[number] | null;
126
+ x: number;
127
+ y: number;
128
+ w: number;
129
+ h: number;
130
+ }[] = [];
131
+
132
+ const monthLabelMap = new Map<string, number>(); // month key -> x
133
+
134
+ for (let dayIndex = 0; dayIndex < totalDays; dayIndex++) {
135
+ const curTs = gridStartTs + dayIndex * 86400000;
136
+ const curDate = new Date(curTs);
137
+ // FIX #2 : utiliser les méthodes UTC partout
138
+ const dow = curDate.getUTCDay();
139
+ const week = Math.floor(dayIndex / 7);
140
+ // FIX #2 : sérialisation UTC
141
+ const dateStr = curDate.toISOString().slice(0, 10);
142
+ const val = map.get(dateStr) ?? null;
143
+ const x = MARGIN.left + week * cellW;
144
+ const y = MARGIN.top + dow * cellH;
145
+
146
+ let tone: typeof TONES[number] | null = null;
147
+ if (val !== null && Number.isFinite(val)) {
148
+ const idx = Math.min(
149
+ TONES.length - 1,
150
+ Math.floor(((val - minVal) / valueRange) * TONES.length)
151
+ );
152
+ tone = TONES[Math.max(0, idx)];
153
+ }
154
+
155
+ cells.push({ date: dateStr, value: val, tone, x, y, w: Math.max(cellW - 2, 1), h: Math.max(cellH - 2, 1) });
156
+
157
+ // Track month label positions (first week of each month)
158
+ // FIX #2 : utiliser getUTCFullYear/getUTCMonth
159
+ const mKey = `${curDate.getUTCFullYear()}-${curDate.getUTCMonth()}`;
160
+ if (!monthLabelMap.has(mKey)) {
161
+ monthLabelMap.set(mKey, x);
162
+ }
163
+ }
164
+
165
+ const monthLabels = Array.from(monthLabelMap.entries()).map(([key, x]) => {
166
+ const [, month] = key.split("-").map(Number);
167
+ return { label: MONTH_ABBR[month], x };
168
+ });
169
+
170
+ return { cells, weeks, monthLabels };
171
+ });
172
+
173
+ // FIX #2 : le SR ne liste que les dates valides (parse UTC strict + round-trip + valeur finie)
174
+ const dataValueItems = $derived(
175
+ data
176
+ .filter((d) => parseUTCDate(d.date) !== null && Number.isFinite(d.value))
177
+ .map((d) => `${d.date}: ${d.value}`)
178
+ );
179
+
180
+ const hoveredCell = $derived(
181
+ hoveredDate !== null ? grid.cells.find((c) => c.date === hoveredDate) ?? null : null
182
+ );
183
+
184
+ function handlePointerMove(event: PointerEvent) {
185
+ const target = event.target;
186
+ if (!(target instanceof Element)) { hoveredDate = null; return; }
187
+ hoveredDate = target.getAttribute("data-chart-date") ?? null;
188
+ }
189
+
190
+ const classes = () => ["st-calendarHeatmapChart", className].filter(Boolean).join(" ");
191
+ </script>
192
+
193
+ <div class={classes()}>
194
+ <div
195
+ class="st-calendarHeatmapChart__visual"
196
+ role="img"
197
+ aria-label={label}
198
+ onpointermove={handlePointerMove}
199
+ onpointerleave={() => (hoveredDate = null)}
200
+ >
201
+ <svg
202
+ viewBox="0 0 {width} {height}"
203
+ preserveAspectRatio="xMidYMid meet"
204
+ width="100%"
205
+ height="100%"
206
+ focusable="false"
207
+ aria-hidden="true"
208
+ >
209
+ <!-- day-of-week labels -->
210
+ {#each DAY_LABELS as day, di (day)}
211
+ {#if di % 2 === 1}
212
+ <text
213
+ class="st-calendarHeatmapChart__dayLabel"
214
+ x={MARGIN.left - 4}
215
+ y={MARGIN.top + di * (plotHeight / 7) + (plotHeight / 14)}
216
+ text-anchor="end"
217
+ dominant-baseline="middle"
218
+ >
219
+ {day}
220
+ </text>
221
+ {/if}
222
+ {/each}
223
+
224
+ <!-- month labels -->
225
+ {#each grid.monthLabels as ml (ml.label + ml.x)}
226
+ <text
227
+ class="st-calendarHeatmapChart__monthLabel"
228
+ x={ml.x}
229
+ y={MARGIN.top - 6}
230
+ dominant-baseline="auto"
231
+ >
232
+ {ml.label}
233
+ </text>
234
+ {/each}
235
+
236
+ <!-- cells -->
237
+ {#each grid.cells as cell (cell.date)}
238
+ <rect
239
+ class="st-calendarHeatmapChart__cell{cell.tone ? ` st-calendarHeatmapChart__cell--${cell.tone}` : ' st-calendarHeatmapChart__cell--empty'}"
240
+ class:st-calendarHeatmapChart__cell--dim={hoveredDate !== null && hoveredDate !== cell.date}
241
+ x={cell.x}
242
+ y={cell.y}
243
+ width={cell.w}
244
+ height={cell.h}
245
+ rx="2"
246
+ data-chart-date={cell.date}
247
+ />
248
+ {/each}
249
+ </svg>
250
+ </div>
251
+
252
+ <ChartDataList {label} items={dataValueItems} />
253
+
254
+ {#if hoveredCell !== null && hoveredCell.value !== null}
255
+ <div
256
+ class="st-calendarHeatmapChart__tooltip"
257
+ role="presentation"
258
+ style="left: {((hoveredCell.x + hoveredCell.w / 2) / width) * 100}%; top: {((hoveredCell.y + hoveredCell.h / 2) / height) * 100}%"
259
+ >
260
+ <span class="st-calendarHeatmapChart__tooltipLabel">{hoveredCell.date}</span>
261
+ <span class="st-calendarHeatmapChart__tooltipValue">{hoveredCell.value}</span>
262
+ </div>
263
+ {/if}
264
+ </div>
265
+
266
+ <style>
267
+ .st-calendarHeatmapChart {
268
+ color: var(--st-semantic-text-secondary);
269
+ display: block;
270
+ font-family: inherit;
271
+ position: relative;
272
+ width: 100%;
273
+ }
274
+
275
+ .st-calendarHeatmapChart svg {
276
+ display: block;
277
+ overflow: visible;
278
+ }
279
+
280
+ .st-calendarHeatmapChart__visual {
281
+ display: block;
282
+ }
283
+
284
+ .st-calendarHeatmapChart__dayLabel,
285
+ .st-calendarHeatmapChart__monthLabel {
286
+ fill: var(--st-semantic-text-secondary);
287
+ font-size: 0.5625rem;
288
+ }
289
+
290
+ .st-calendarHeatmapChart__cell {
291
+ cursor: pointer;
292
+ stroke: var(--st-semantic-surface-default, Canvas);
293
+ stroke-width: 1;
294
+ transition: opacity 120ms ease;
295
+ }
296
+
297
+ .st-calendarHeatmapChart__cell--empty {
298
+ fill: var(--st-semantic-border-subtle);
299
+ opacity: 0.25;
300
+ }
301
+
302
+ .st-calendarHeatmapChart__cell--dim {
303
+ opacity: 0.3;
304
+ }
305
+
306
+ @media (prefers-reduced-motion: reduce) {
307
+ .st-calendarHeatmapChart__cell {
308
+ transition: none;
309
+ }
310
+ }
311
+
312
+ .st-calendarHeatmapChart__cell--category1 { fill: var(--st-semantic-data-category1); }
313
+ .st-calendarHeatmapChart__cell--category2 { fill: var(--st-semantic-data-category2); }
314
+ .st-calendarHeatmapChart__cell--category3 { fill: var(--st-semantic-data-category3); }
315
+ .st-calendarHeatmapChart__cell--category4 { fill: var(--st-semantic-data-category4); }
316
+ .st-calendarHeatmapChart__cell--category5 { fill: var(--st-semantic-data-category5); }
317
+ .st-calendarHeatmapChart__cell--category6 { fill: var(--st-semantic-data-category6); }
318
+ .st-calendarHeatmapChart__cell--category7 { fill: var(--st-semantic-data-category7); }
319
+ .st-calendarHeatmapChart__cell--category8 { fill: var(--st-semantic-data-category8); }
320
+
321
+ .st-calendarHeatmapChart__tooltip {
322
+ background: var(--st-semantic-surface-inverse);
323
+ border-radius: var(--st-radius-sm, 0.25rem);
324
+ color: var(--st-semantic-text-inverse);
325
+ display: inline-flex;
326
+ flex-direction: column;
327
+ font-size: 0.75rem;
328
+ gap: 0.125rem;
329
+ line-height: 1.2;
330
+ padding: 0.375rem 0.5rem;
331
+ pointer-events: none;
332
+ position: absolute;
333
+ transform: translate(-50%, calc(-100% - 8px));
334
+ white-space: nowrap;
335
+ z-index: 1;
336
+ }
337
+
338
+ .st-calendarHeatmapChart__tooltipLabel {
339
+ font-weight: 600;
340
+ }
341
+
342
+ .st-calendarHeatmapChart__tooltipValue {
343
+ opacity: 0.85;
344
+ }
345
+ </style>
@@ -0,0 +1,28 @@
1
+ /**
2
+ * CalendarHeatmapChart - grille jour×semaine (GitHub-style).
3
+ * API canonique (référence Svelte, React/Vue doivent s'aligner)
4
+ *
5
+ * Props obligatoires :
6
+ * data CalendarHeatmapChartDatum[] - tableau {date: "YYYY-MM-DD", value}
7
+ * label string
8
+ *
9
+ * Props optionnelles :
10
+ * width number (défaut 480)
11
+ * height number (défaut 140)
12
+ * class string
13
+ */
14
+ export type CalendarHeatmapChartDatum = {
15
+ date: string;
16
+ value: number;
17
+ };
18
+ type CalendarHeatmapChartProps = {
19
+ data: CalendarHeatmapChartDatum[];
20
+ label: string;
21
+ width?: number;
22
+ height?: number;
23
+ class?: string;
24
+ };
25
+ declare const CalendarHeatmapChart: import("svelte").Component<CalendarHeatmapChartProps, {}, "">;
26
+ type CalendarHeatmapChart = ReturnType<typeof CalendarHeatmapChart>;
27
+ export default CalendarHeatmapChart;
28
+ //# sourceMappingURL=CalendarHeatmapChart.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CalendarHeatmapChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/CalendarHeatmapChart.svelte.ts"],"names":[],"mappings":"AAGE;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,yBAAyB,GAAG;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAMF,KAAK,yBAAyB,GAAG;IAC/B,IAAI,EAAE,yBAAyB,EAAE,CAAC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAgNJ,QAAA,MAAM,oBAAoB,+DAAwC,CAAC;AACnE,KAAK,oBAAoB,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;AACpE,eAAe,oBAAoB,CAAC"}
@@ -0,0 +1,333 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * CandlestickChart - OHLC (open/high/low/close), bougies vertes/rouges.
4
+ * API canonique (référence Svelte, React/Vue doivent s'aligner)
5
+ *
6
+ * Props obligatoires :
7
+ * data CandlestickChartDatum[] - tableau {label, open, high, low, close}
8
+ * label string
9
+ *
10
+ * Props optionnelles :
11
+ * width number (défaut 480)
12
+ * height number (défaut 240)
13
+ * class string
14
+ */
15
+ export type CandlestickChartDatum = {
16
+ label: string;
17
+ open: number;
18
+ high: number;
19
+ low: number;
20
+ close: number;
21
+ };
22
+ </script>
23
+
24
+ <script lang="ts">
25
+ import ChartDataList from "./ChartDataList.svelte";
26
+
27
+ type CandlestickChartProps = {
28
+ data: CandlestickChartDatum[];
29
+ label: string;
30
+ width?: number;
31
+ height?: number;
32
+ class?: string;
33
+ };
34
+
35
+ let {
36
+ data = [],
37
+ label,
38
+ width = 480,
39
+ height = 240,
40
+ class: className
41
+ }: CandlestickChartProps = $props();
42
+
43
+ const MARGIN = { top: 12, right: 16, bottom: 32, left: 52 };
44
+
45
+ function scaleLinear(v: number, d0: number, d1: number, r0: number, r1: number) {
46
+ if (d1 === d0) return r0;
47
+ return r0 + ((v - d0) * (r1 - r0)) / (d1 - d0);
48
+ }
49
+
50
+ function niceTicks(min: number, max: number, target = 5): number[] {
51
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) {
52
+ const base = Number.isFinite(max) ? max : 0;
53
+ return [base];
54
+ }
55
+ const range = max - min;
56
+ const rough = range / Math.max(target - 1, 1);
57
+ const pow = Math.pow(10, Math.floor(Math.log10(rough)));
58
+ const norm = rough / pow;
59
+ let step: number;
60
+ if (norm < 1.5) step = 1 * pow;
61
+ else if (norm < 3) step = 2 * pow;
62
+ else if (norm < 7) step = 5 * pow;
63
+ else step = 10 * pow;
64
+ const start = Math.floor(min / step) * step;
65
+ const end = Math.ceil(max / step) * step;
66
+ const ticks: number[] = [];
67
+ for (let v = start; v <= end + step / 2; v += step) {
68
+ ticks.push(Number(v.toFixed(10)));
69
+ }
70
+ return ticks;
71
+ }
72
+
73
+ function formatTick(v: number): string {
74
+ if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}k`;
75
+ if (Number.isInteger(v)) return String(v);
76
+ return v.toFixed(1);
77
+ }
78
+
79
+ let hoveredIndex: number | null = $state(null);
80
+
81
+ const plotWidth = $derived(Math.max(width - MARGIN.left - MARGIN.right, 1));
82
+ const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
83
+
84
+ // FIX #1 : filtrer les bougies invalides AVANT le domaine
85
+ const validData = $derived(
86
+ data.filter((d) =>
87
+ Number.isFinite(d.open) &&
88
+ Number.isFinite(d.high) &&
89
+ Number.isFinite(d.low) &&
90
+ Number.isFinite(d.close)
91
+ )
92
+ );
93
+
94
+ const domainBounds = $derived.by(() => {
95
+ const allVals: number[] = [];
96
+ for (const d of validData) {
97
+ // Domaine inclut open/high/low/close (pas seulement high/low)
98
+ allVals.push(d.open, d.high, d.low, d.close);
99
+ }
100
+ if (allVals.length === 0) return { rawMin: 0, rawMax: 1 };
101
+ const rawMin = Math.min(...allVals);
102
+ const rawMax = Math.max(...allVals);
103
+ // Domaine plat → fallback range 1 pour éviter division par 0
104
+ return { rawMin, rawMax: rawMax === rawMin ? rawMin + 1 : rawMax };
105
+ });
106
+
107
+ const ticks = $derived(niceTicks(domainBounds.rawMin, domainBounds.rawMax, 5));
108
+ const domainMin = $derived(ticks[0]);
109
+ const domainMax = $derived(ticks[ticks.length - 1]);
110
+
111
+ const candles = $derived.by(() => {
112
+ if (validData.length === 0) return [];
113
+ const band = plotWidth / validData.length;
114
+ const bodyW = band * 0.55;
115
+
116
+ return validData.map((d, i) => {
117
+ // FIX #1 : clamp high/low pour garantir high≥max(O,C) et low≤min(O,C)
118
+ const clampedHigh = Math.max(d.high, d.open, d.close);
119
+ const clampedLow = Math.min(d.low, d.open, d.close);
120
+
121
+ const bullish = d.close >= d.open;
122
+ const centerX = MARGIN.left + band * i + band / 2;
123
+
124
+ const bodyTop = MARGIN.top + scaleLinear(Math.max(d.open, d.close), domainMin, domainMax, plotHeight, 0);
125
+ const bodyBot = MARGIN.top + scaleLinear(Math.min(d.open, d.close), domainMin, domainMax, plotHeight, 0);
126
+ const highY = MARGIN.top + scaleLinear(clampedHigh, domainMin, domainMax, plotHeight, 0);
127
+ const lowY = MARGIN.top + scaleLinear(clampedLow, domainMin, domainMax, plotHeight, 0);
128
+
129
+ return {
130
+ datum: d,
131
+ index: i,
132
+ bullish,
133
+ centerX,
134
+ bodyX: centerX - bodyW / 2,
135
+ bodyY: bodyTop,
136
+ bodyW,
137
+ bodyH: Math.max(bodyBot - bodyTop, 0.5),
138
+ wickHighY: highY,
139
+ wickLowY: lowY,
140
+ tooltipY: bodyTop
141
+ };
142
+ });
143
+ });
144
+
145
+ const dataValueItems = $derived(
146
+ validData.map((d) => `${d.label}: O ${d.open} H ${d.high} L ${d.low} C ${d.close}`)
147
+ );
148
+
149
+ function handlePointerMove(event: PointerEvent) {
150
+ const target = event.target;
151
+ if (!(target instanceof Element)) { hoveredIndex = null; return; }
152
+ const idx = Number(target.getAttribute("data-chart-index"));
153
+ hoveredIndex = Number.isInteger(idx) ? idx : null;
154
+ }
155
+
156
+ const classes = () => ["st-candlestickChart", className].filter(Boolean).join(" ");
157
+ </script>
158
+
159
+ <div class={classes()}>
160
+ <div
161
+ class="st-candlestickChart__visual"
162
+ role="img"
163
+ aria-label={label}
164
+ onpointermove={handlePointerMove}
165
+ onpointerleave={() => (hoveredIndex = null)}
166
+ >
167
+ <svg
168
+ viewBox="0 0 {width} {height}"
169
+ preserveAspectRatio="xMidYMid meet"
170
+ width="100%"
171
+ height="100%"
172
+ focusable="false"
173
+ aria-hidden="true"
174
+ >
175
+ <!-- gridlines + tick labels -->
176
+ {#each ticks as tick (tick)}
177
+ {@const ty = MARGIN.top + scaleLinear(tick, domainMin, domainMax, plotHeight, 0)}
178
+ <line class="st-candlestickChart__grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={ty} y2={ty} />
179
+ <text class="st-candlestickChart__tickLabel" x={MARGIN.left - 6} y={ty} text-anchor="end" dominant-baseline="middle">
180
+ {formatTick(tick)}
181
+ </text>
182
+ {/each}
183
+
184
+ <!-- axes -->
185
+ <line class="st-candlestickChart__axis" x1={MARGIN.left} x2={MARGIN.left} y1={MARGIN.top} y2={height - MARGIN.bottom} />
186
+ <line class="st-candlestickChart__axis" x1={MARGIN.left} x2={width - MARGIN.right} y1={height - MARGIN.bottom} y2={height - MARGIN.bottom} />
187
+
188
+ <!-- FIX #7 : clé composite pour éviter les doublons -->
189
+ {#each candles as c, i (`${i}-${c.datum.label}`)}
190
+ <!-- wick -->
191
+ <line
192
+ class="st-candlestickChart__wick st-candlestickChart__wick--{c.bullish ? 'up' : 'down'}"
193
+ x1={c.centerX}
194
+ x2={c.centerX}
195
+ y1={c.wickHighY}
196
+ y2={c.wickLowY}
197
+ data-chart-index={i}
198
+ />
199
+ <!-- body -->
200
+ <rect
201
+ class="st-candlestickChart__body st-candlestickChart__body--{c.bullish ? 'up' : 'down'}"
202
+ class:st-candlestickChart__body--dim={hoveredIndex !== null && hoveredIndex !== i}
203
+ x={c.bodyX}
204
+ y={c.bodyY}
205
+ width={c.bodyW}
206
+ height={c.bodyH}
207
+ rx="1"
208
+ data-chart-index={i}
209
+ />
210
+ <!-- category label -->
211
+ <text
212
+ class="st-candlestickChart__categoryLabel"
213
+ x={c.centerX}
214
+ y={height - MARGIN.bottom + 16}
215
+ text-anchor="middle"
216
+ >
217
+ {c.datum.label}
218
+ </text>
219
+ {/each}
220
+ </svg>
221
+ </div>
222
+
223
+ <ChartDataList {label} items={dataValueItems} />
224
+
225
+ {#if hoveredIndex !== null && candles[hoveredIndex]}
226
+ {@const c = candles[hoveredIndex]}
227
+ <div
228
+ class="st-candlestickChart__tooltip"
229
+ role="presentation"
230
+ style="left: {(c.centerX / width) * 100}%; top: {(c.tooltipY / height) * 100}%"
231
+ >
232
+ <span class="st-candlestickChart__tooltipLabel">{c.datum.label}</span>
233
+ <span class="st-candlestickChart__tooltipValue">O {c.datum.open} H {c.datum.high} L {c.datum.low} C {c.datum.close}</span>
234
+ </div>
235
+ {/if}
236
+ </div>
237
+
238
+ <style>
239
+ .st-candlestickChart {
240
+ color: var(--st-semantic-text-secondary);
241
+ display: block;
242
+ font-family: inherit;
243
+ position: relative;
244
+ width: 100%;
245
+ }
246
+
247
+ .st-candlestickChart svg {
248
+ display: block;
249
+ overflow: visible;
250
+ }
251
+
252
+ .st-candlestickChart__visual {
253
+ display: block;
254
+ }
255
+
256
+ .st-candlestickChart__axis {
257
+ stroke: var(--st-semantic-border-subtle);
258
+ stroke-width: 1;
259
+ }
260
+
261
+ .st-candlestickChart__grid {
262
+ stroke: var(--st-semantic-border-subtle);
263
+ stroke-dasharray: 2 3;
264
+ stroke-width: 1;
265
+ opacity: 0.7;
266
+ }
267
+
268
+ .st-candlestickChart__wick {
269
+ stroke-width: 1.5;
270
+ }
271
+
272
+ .st-candlestickChart__wick--up {
273
+ stroke: var(--st-semantic-feedback-success);
274
+ }
275
+
276
+ .st-candlestickChart__wick--down {
277
+ stroke: var(--st-semantic-feedback-error);
278
+ }
279
+
280
+ .st-candlestickChart__body {
281
+ cursor: pointer;
282
+ transition: opacity 120ms ease;
283
+ }
284
+
285
+ .st-candlestickChart__body--dim {
286
+ opacity: 0.4;
287
+ }
288
+
289
+ .st-candlestickChart__body--up {
290
+ fill: var(--st-semantic-feedback-success);
291
+ }
292
+
293
+ .st-candlestickChart__body--down {
294
+ fill: var(--st-semantic-feedback-error);
295
+ }
296
+
297
+ @media (prefers-reduced-motion: reduce) {
298
+ .st-candlestickChart__body {
299
+ transition: none;
300
+ }
301
+ }
302
+
303
+ .st-candlestickChart__tickLabel,
304
+ .st-candlestickChart__categoryLabel {
305
+ fill: var(--st-semantic-text-secondary);
306
+ font-size: 0.6875rem;
307
+ }
308
+
309
+ .st-candlestickChart__tooltip {
310
+ background: var(--st-semantic-surface-inverse);
311
+ border-radius: var(--st-radius-sm, 0.25rem);
312
+ color: var(--st-semantic-text-inverse);
313
+ display: inline-flex;
314
+ flex-direction: column;
315
+ font-size: 0.75rem;
316
+ gap: 0.125rem;
317
+ line-height: 1.2;
318
+ padding: 0.375rem 0.5rem;
319
+ pointer-events: none;
320
+ position: absolute;
321
+ transform: translate(-50%, calc(-100% - 8px));
322
+ white-space: nowrap;
323
+ z-index: 1;
324
+ }
325
+
326
+ .st-candlestickChart__tooltipLabel {
327
+ font-weight: 600;
328
+ }
329
+
330
+ .st-candlestickChart__tooltipValue {
331
+ opacity: 0.85;
332
+ }
333
+ </style>