@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.
- package/dist/BoxPlotChart.svelte +302 -0
- package/dist/BoxPlotChart.svelte.d.ts +40 -0
- package/dist/BoxPlotChart.svelte.d.ts.map +1 -0
- package/dist/BulletChart.svelte +479 -0
- package/dist/BulletChart.svelte.d.ts +32 -0
- package/dist/BulletChart.svelte.d.ts.map +1 -0
- package/dist/BumpChart.svelte +387 -0
- package/dist/BumpChart.svelte.d.ts +35 -0
- package/dist/BumpChart.svelte.d.ts.map +1 -0
- package/dist/CalendarHeatmapChart.svelte +345 -0
- package/dist/CalendarHeatmapChart.svelte.d.ts +28 -0
- package/dist/CalendarHeatmapChart.svelte.d.ts.map +1 -0
- package/dist/CandlestickChart.svelte +333 -0
- package/dist/CandlestickChart.svelte.d.ts +31 -0
- package/dist/CandlestickChart.svelte.d.ts.map +1 -0
- package/dist/HeatmapChart.svelte +337 -0
- package/dist/HeatmapChart.svelte.d.ts +35 -0
- package/dist/HeatmapChart.svelte.d.ts.map +1 -0
- package/dist/HistogramChart.svelte +294 -0
- package/dist/HistogramChart.svelte.d.ts +38 -0
- package/dist/HistogramChart.svelte.d.ts.map +1 -0
- package/dist/MarimekkoChart.svelte +319 -0
- package/dist/MarimekkoChart.svelte.d.ts +35 -0
- package/dist/MarimekkoChart.svelte.d.ts.map +1 -0
- package/dist/ParallelCoordinatesChart.svelte +315 -0
- package/dist/ParallelCoordinatesChart.svelte.d.ts +35 -0
- package/dist/ParallelCoordinatesChart.svelte.d.ts.map +1 -0
- package/dist/RadarChart.svelte +340 -0
- package/dist/RadarChart.svelte.d.ts +43 -0
- package/dist/RadarChart.svelte.d.ts.map +1 -0
- package/dist/SankeyChart.svelte +364 -0
- package/dist/SankeyChart.svelte.d.ts +45 -0
- package/dist/SankeyChart.svelte.d.ts.map +1 -0
- package/dist/SunburstChart.svelte +388 -0
- package/dist/SunburstChart.svelte.d.ts +39 -0
- package/dist/SunburstChart.svelte.d.ts.map +1 -0
- package/dist/chartContrast.d.ts +0 -4
- package/dist/chartContrast.d.ts.map +1 -1
- package/dist/chartContrast.js +4 -56
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- 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>
|