@sentropic/design-system-svelte 0.34.39 → 0.34.42

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,356 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * RenkoChart — briques Renko à partir d'une série de prix (façon Highcharts
4
+ * Stock « renko »). On empile des briques d'une taille fixe (`boxSize`) en
5
+ * ignorant le temps régulier : une nouvelle brique n'apparaît qu'au
6
+ * franchissement de `boxSize`. Brique haussière = +boxSize au-dessus de la
7
+ * dernière, brique baissière = -boxSize en-dessous ; une INVERSION exige un
8
+ * franchissement de 2×boxSize en sens inverse. Vert (hausse) / rouge (baisse)
9
+ * via les tons sémantiques success / error. Axe Y prix gradué (niceTicks), PAS
10
+ * d'axe temps régulier (les colonnes sont équidistantes). a11y : `role="img"`
11
+ * + `data-chart-key` + liste accessible des briques.
12
+ * API canonique (référence Svelte, React/Vue/Angular doivent s'aligner).
13
+ *
14
+ * Props obligatoires :
15
+ * data RenkoChartDatum[] - série de prix {date, close}
16
+ *
17
+ * Props optionnelles :
18
+ * boxSize number (taille d'une brique ; défaut auto ~ (max-min)/20)
19
+ * label string
20
+ * width number (défaut 640)
21
+ * height number (défaut 320)
22
+ * size number (non utilisé pour le rendu ; réservé parité d'API)
23
+ * class string
24
+ */
25
+ export type RenkoChartDirection = "up" | "down";
26
+
27
+ export type RenkoChartDatum = {
28
+ /** Position temporelle (timestamp ou index) — ignorée pour l'empilement. */
29
+ date: number;
30
+ /** Prix de clôture : pilote la formation des briques. */
31
+ close: number;
32
+ };
33
+ </script>
34
+
35
+ <script lang="ts">
36
+ import ChartDataList from "./ChartDataList.svelte";
37
+
38
+ type RenkoChartProps = {
39
+ data: RenkoChartDatum[];
40
+ boxSize?: number;
41
+ label?: string;
42
+ width?: number;
43
+ height?: number;
44
+ size?: number;
45
+ class?: string;
46
+ };
47
+
48
+ let {
49
+ data = [],
50
+ boxSize,
51
+ label,
52
+ width = 640,
53
+ height = 320,
54
+ size,
55
+ class: className
56
+ }: RenkoChartProps = $props();
57
+
58
+ const MARGIN = { top: 16, right: 18, bottom: 36, left: 52 };
59
+
60
+ function niceTicks(min: number, max: number, target = 5): number[] {
61
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) {
62
+ return [Number.isFinite(max) ? max : 0];
63
+ }
64
+ const range = max - min;
65
+ const rough = range / Math.max(target - 1, 1);
66
+ const pow = Math.pow(10, Math.floor(Math.log10(rough)));
67
+ const norm = rough / pow;
68
+ let step: number;
69
+ if (norm < 1.5) step = pow;
70
+ else if (norm < 3) step = 2 * pow;
71
+ else if (norm < 7) step = 5 * pow;
72
+ else step = 10 * pow;
73
+ const start = Math.floor(min / step) * step;
74
+ const end = Math.ceil(max / step) * step;
75
+ const ticks: number[] = [];
76
+ for (let v = start; v <= end + step / 2; v += step) ticks.push(Number(v.toFixed(10)));
77
+ return ticks;
78
+ }
79
+
80
+ function scaleLinear(v: number, d0: number, d1: number, r0: number, r1: number) {
81
+ if (d1 === d0) return r0;
82
+ return r0 + ((v - d0) * (r1 - r0)) / (d1 - d0);
83
+ }
84
+
85
+ function fmt(v: number): string {
86
+ if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}k`;
87
+ return Number.isInteger(v) ? String(v) : v.toFixed(1);
88
+ }
89
+
90
+ let hoveredKey: string | null = $state(null);
91
+
92
+ // Points valides : date et close finis.
93
+ const validData = $derived(
94
+ data.filter((d) => d && Number.isFinite(d.date) && Number.isFinite(d.close))
95
+ );
96
+
97
+ // Taille de brique effective : `boxSize` fini > 0, sinon auto ~ (max-min)/20.
98
+ const effectiveBox = $derived.by(() => {
99
+ if (Number.isFinite(boxSize) && (boxSize as number) > 0) return boxSize as number;
100
+ const closes = validData.map((d) => d.close);
101
+ if (closes.length === 0) return 1;
102
+ const min = Math.min(...closes);
103
+ const max = Math.max(...closes);
104
+ const span = max - min;
105
+ return span > 0 ? span / 20 : 1;
106
+ });
107
+
108
+ // Construit les briques Renko. Chaque brique couvre [bottom, top] (hauteur
109
+ // boxSize) ; on en émet une à chaque franchissement de `box`. L'inversion
110
+ // exige 2×box (la première brique d'un nouveau sens repart d'un cran décalé).
111
+ const bricks = $derived.by(() => {
112
+ const box = effectiveBox;
113
+ const out: { bottom: number; top: number; direction: RenkoChartDirection }[] = [];
114
+ if (validData.length === 0 || box <= 0) return out;
115
+
116
+ // Niveau de référence : la base de la dernière brique posée.
117
+ let base = validData[0].close;
118
+ let direction: RenkoChartDirection | null = null;
119
+
120
+ for (let i = 1; i < validData.length; i++) {
121
+ const price = validData[i].close;
122
+ // Briques haussières tant que le prix monte d'au moins un `box`.
123
+ while (price >= base + box) {
124
+ out.push({ bottom: base, top: base + box, direction: "up" });
125
+ base += box;
126
+ direction = "up";
127
+ }
128
+ // Briques baissières tant que le prix descend d'au moins un `box`.
129
+ while (price <= base - box) {
130
+ out.push({ bottom: base - box, top: base, direction: "down" });
131
+ base -= box;
132
+ direction = "down";
133
+ }
134
+ void direction;
135
+ }
136
+ return out;
137
+ });
138
+
139
+ const priceRange = $derived.by(() => {
140
+ if (bricks.length === 0) {
141
+ const closes = validData.map((d) => d.close);
142
+ const min = closes.length ? Math.min(...closes) : 0;
143
+ const max = closes.length ? Math.max(...closes) : 0;
144
+ return { min, max };
145
+ }
146
+ let min = Infinity;
147
+ let max = -Infinity;
148
+ for (const b of bricks) {
149
+ if (b.bottom < min) min = b.bottom;
150
+ if (b.top > max) max = b.top;
151
+ }
152
+ return { min, max };
153
+ });
154
+
155
+ const scales = $derived.by(() => {
156
+ const { min, max } = priceRange;
157
+ const yTicks = niceTicks(min, max);
158
+ const plotW = Math.max(width - MARGIN.left - MARGIN.right, 1);
159
+ const plotH = Math.max(height - MARGIN.top - MARGIN.bottom, 1);
160
+ return {
161
+ yTicks,
162
+ yMin: yTicks[0],
163
+ yMax: yTicks[yTicks.length - 1],
164
+ plotW,
165
+ plotH
166
+ };
167
+ });
168
+
169
+ // Colonnes de briques côte à côte (équidistantes) : pas d'axe temps régulier.
170
+ const columns = $derived.by(() => {
171
+ const { yMin, yMax, plotW, plotH } = scales;
172
+ const n = bricks.length;
173
+ if (n === 0) return [];
174
+ const colW = plotW / n;
175
+ const brickW = colW * 0.86;
176
+ return bricks.map((b, i) => {
177
+ const cx = MARGIN.left + colW * i + colW / 2;
178
+ const top = MARGIN.top + scaleLinear(b.top, yMin, yMax, plotH, 0);
179
+ const bottom = MARGIN.top + scaleLinear(b.bottom, yMin, yMax, plotH, 0);
180
+ return {
181
+ key: `${i}`,
182
+ brick: b,
183
+ x: cx - brickW / 2,
184
+ y: Math.min(top, bottom),
185
+ width: brickW,
186
+ height: Math.max(Math.abs(bottom - top), 0.5),
187
+ cx,
188
+ cy: (top + bottom) / 2,
189
+ direction: b.direction
190
+ };
191
+ });
192
+ });
193
+
194
+ const dataValueItems = $derived(
195
+ columns.map((c) => `${c.direction === "up" ? "▲" : "▼"} ${fmt(c.brick.bottom)} → ${fmt(c.brick.top)}`)
196
+ );
197
+
198
+ function handlePointerMove(event: PointerEvent) {
199
+ const target = event.target;
200
+ if (!(target instanceof Element)) {
201
+ hoveredKey = null;
202
+ return;
203
+ }
204
+ hoveredKey = target.getAttribute("data-chart-key");
205
+ }
206
+
207
+ const hoveredColumn = $derived.by(() => {
208
+ if (hoveredKey === null) return null;
209
+ return columns.find((c) => c.key === hoveredKey) ?? null;
210
+ });
211
+
212
+ const classes = () => ["st-renkoChart", className].filter(Boolean).join(" ");
213
+ </script>
214
+
215
+ <div class={classes()}>
216
+ <div
217
+ class="st-renkoChart__visual"
218
+ role="img"
219
+ aria-label={label}
220
+ onpointermove={handlePointerMove}
221
+ onpointerleave={() => (hoveredKey = null)}
222
+ >
223
+ <svg
224
+ viewBox="0 0 {width} {height}"
225
+ preserveAspectRatio="xMidYMid meet"
226
+ width="100%"
227
+ height="100%"
228
+ focusable="false"
229
+ aria-hidden="true"
230
+ >
231
+ <!-- gridlines + ticks Y (prix) -->
232
+ {#each scales.yTicks as t (t)}
233
+ {@const y = MARGIN.top + scaleLinear(t, scales.yMin, scales.yMax, scales.plotH, 0)}
234
+ <line class="st-renkoChart__grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={y} y2={y} />
235
+ <text class="st-renkoChart__tick" x={MARGIN.left - 6} y={y} text-anchor="end" dominant-baseline="middle">{fmt(t)}</text>
236
+ {/each}
237
+
238
+ <!-- axes -->
239
+ <line class="st-renkoChart__axis" x1={MARGIN.left} x2={MARGIN.left} y1={MARGIN.top} y2={height - MARGIN.bottom} />
240
+ <line class="st-renkoChart__axis" x1={MARGIN.left} x2={width - MARGIN.right} y1={height - MARGIN.bottom} y2={height - MARGIN.bottom} />
241
+
242
+ <!-- colonnes de briques -->
243
+ {#each columns as c (c.key)}
244
+ <rect
245
+ class="st-renkoChart__brick st-renkoChart__brick--{c.direction}"
246
+ class:st-renkoChart__brick--dim={hoveredKey !== null && hoveredKey !== c.key}
247
+ x={c.x}
248
+ y={c.y}
249
+ width={c.width}
250
+ height={c.height}
251
+ data-chart-key={c.key}
252
+ />
253
+ {/each}
254
+ </svg>
255
+ </div>
256
+
257
+ <ChartDataList label={label ?? "renko"} items={dataValueItems} />
258
+
259
+ {#if hoveredColumn}
260
+ {@const c = hoveredColumn}
261
+ <div
262
+ class="st-renkoChart__tooltip"
263
+ role="presentation"
264
+ style="left: {(c.cx / width) * 100}%; top: {(c.cy / height) * 100}%"
265
+ >
266
+ <span class="st-renkoChart__tooltipLabel">{c.direction === "up" ? "▲" : "▼"}</span>
267
+ <span class="st-renkoChart__tooltipValue">{fmt(c.brick.bottom)} → {fmt(c.brick.top)}</span>
268
+ </div>
269
+ {/if}
270
+ </div>
271
+
272
+ <style>
273
+ .st-renkoChart {
274
+ color: var(--st-semantic-text-secondary);
275
+ display: block;
276
+ font-family: inherit;
277
+ position: relative;
278
+ width: 100%;
279
+ }
280
+
281
+ .st-renkoChart svg {
282
+ display: block;
283
+ overflow: visible;
284
+ }
285
+
286
+ .st-renkoChart__visual {
287
+ display: block;
288
+ }
289
+
290
+ .st-renkoChart__grid {
291
+ opacity: 0.5;
292
+ stroke: var(--st-semantic-border-subtle);
293
+ stroke-dasharray: 2 3;
294
+ stroke-width: 1;
295
+ }
296
+
297
+ .st-renkoChart__axis {
298
+ stroke: var(--st-semantic-border-subtle);
299
+ stroke-width: 1;
300
+ }
301
+
302
+ .st-renkoChart__tick {
303
+ fill: var(--st-semantic-text-secondary);
304
+ font-size: 0.6875rem;
305
+ }
306
+
307
+ .st-renkoChart__brick {
308
+ cursor: pointer;
309
+ stroke: var(--st-semantic-surface-default, Canvas);
310
+ stroke-width: 0.5;
311
+ transition: opacity 120ms ease;
312
+ }
313
+
314
+ .st-renkoChart__brick--dim {
315
+ opacity: 0.35;
316
+ }
317
+
318
+ .st-renkoChart__brick--up {
319
+ fill: var(--st-semantic-feedback-success);
320
+ }
321
+
322
+ .st-renkoChart__brick--down {
323
+ fill: var(--st-semantic-feedback-error);
324
+ }
325
+
326
+ .st-renkoChart__tooltip {
327
+ background: var(--st-semantic-surface-inverse);
328
+ border-radius: var(--st-radius-sm, 0.25rem);
329
+ color: var(--st-semantic-text-inverse);
330
+ display: inline-flex;
331
+ flex-direction: column;
332
+ font-size: 0.75rem;
333
+ gap: 0.125rem;
334
+ line-height: 1.2;
335
+ padding: 0.375rem 0.5rem;
336
+ pointer-events: none;
337
+ position: absolute;
338
+ transform: translate(-50%, calc(-100% - 8px));
339
+ white-space: nowrap;
340
+ z-index: 1;
341
+ }
342
+
343
+ .st-renkoChart__tooltipLabel {
344
+ font-weight: 600;
345
+ }
346
+
347
+ .st-renkoChart__tooltipValue {
348
+ opacity: 0.85;
349
+ }
350
+
351
+ @media (prefers-reduced-motion: reduce) {
352
+ .st-renkoChart__brick {
353
+ transition: none;
354
+ }
355
+ }
356
+ </style>
@@ -0,0 +1,43 @@
1
+ /**
2
+ * RenkoChart — briques Renko à partir d'une série de prix (façon Highcharts
3
+ * Stock « renko »). On empile des briques d'une taille fixe (`boxSize`) en
4
+ * ignorant le temps régulier : une nouvelle brique n'apparaît qu'au
5
+ * franchissement de `boxSize`. Brique haussière = +boxSize au-dessus de la
6
+ * dernière, brique baissière = -boxSize en-dessous ; une INVERSION exige un
7
+ * franchissement de 2×boxSize en sens inverse. Vert (hausse) / rouge (baisse)
8
+ * via les tons sémantiques success / error. Axe Y prix gradué (niceTicks), PAS
9
+ * d'axe temps régulier (les colonnes sont équidistantes). a11y : `role="img"`
10
+ * + `data-chart-key` + liste accessible des briques.
11
+ * API canonique (référence Svelte, React/Vue/Angular doivent s'aligner).
12
+ *
13
+ * Props obligatoires :
14
+ * data RenkoChartDatum[] - série de prix {date, close}
15
+ *
16
+ * Props optionnelles :
17
+ * boxSize number (taille d'une brique ; défaut auto ~ (max-min)/20)
18
+ * label string
19
+ * width number (défaut 640)
20
+ * height number (défaut 320)
21
+ * size number (non utilisé pour le rendu ; réservé parité d'API)
22
+ * class string
23
+ */
24
+ export type RenkoChartDirection = "up" | "down";
25
+ export type RenkoChartDatum = {
26
+ /** Position temporelle (timestamp ou index) — ignorée pour l'empilement. */
27
+ date: number;
28
+ /** Prix de clôture : pilote la formation des briques. */
29
+ close: number;
30
+ };
31
+ type RenkoChartProps = {
32
+ data: RenkoChartDatum[];
33
+ boxSize?: number;
34
+ label?: string;
35
+ width?: number;
36
+ height?: number;
37
+ size?: number;
38
+ class?: string;
39
+ };
40
+ declare const RenkoChart: import("svelte").Component<RenkoChartProps, {}, "">;
41
+ type RenkoChart = ReturnType<typeof RenkoChart>;
42
+ export default RenkoChart;
43
+ //# sourceMappingURL=RenkoChart.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RenkoChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/RenkoChart.svelte.ts"],"names":[],"mappings":"AAGE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,MAAM,mBAAmB,GAAG,IAAI,GAAG,MAAM,CAAC;AAEhD,MAAM,MAAM,eAAe,GAAG;IAC5B,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAMF,KAAK,eAAe,GAAG;IACrB,IAAI,EAAE,eAAe,EAAE,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,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;AAkNJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=RenkoChart.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RenkoChart.test.d.ts","sourceRoot":"","sources":["../src/lib/RenkoChart.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,66 @@
1
+ import { render } from "@testing-library/svelte";
2
+ import { describe, expect, it } from "vitest";
3
+ import RenkoChart from "./RenkoChart.svelte";
4
+ // Série de prix : montée régulière puis repli — produit des briques up puis down.
5
+ const series = [
6
+ { date: 0, close: 100 },
7
+ { date: 1, close: 110 },
8
+ { date: 2, close: 120 },
9
+ { date: 3, close: 130 },
10
+ { date: 4, close: 120 },
11
+ { date: 5, close: 110 }
12
+ ];
13
+ const bricks = (container) => Array.from(container.querySelectorAll(".st-renkoChart__brick"));
14
+ const listItems = (container) => Array.from(container.querySelectorAll(".st-chartDataList li")).map((n) => n.textContent?.trim());
15
+ const structuralClass = (el) => el.className.split(/\s+/)[0];
16
+ describe("RenkoChart", () => {
17
+ it("renders an img role and bricks from the price series", () => {
18
+ const { container } = render(RenkoChart, { props: { data: series, boxSize: 10, label: "Renko" } });
19
+ expect(container.querySelector('[role="img"]')).toBeTruthy();
20
+ expect(bricks(container).length).toBeGreaterThan(0);
21
+ });
22
+ it("forms one up-brick per box crossing on a rising series", () => {
23
+ // 100 → 130 avec boxSize 10 : 3 briques haussières.
24
+ const { container } = render(RenkoChart, {
25
+ props: { data: [{ date: 0, close: 100 }, { date: 1, close: 130 }], boxSize: 10, label: "R" }
26
+ });
27
+ const up = container.querySelectorAll(".st-renkoChart__brick--up");
28
+ expect(up.length).toBe(3);
29
+ });
30
+ it("colours descending bricks with the down tone", () => {
31
+ const { container } = render(RenkoChart, { props: { data: series, boxSize: 10, label: "R" } });
32
+ expect(container.querySelectorAll(".st-renkoChart__brick--down").length).toBeGreaterThan(0);
33
+ });
34
+ it("renders a graduated price (Y) axis with nice ticks", () => {
35
+ const { container } = render(RenkoChart, { props: { data: series, boxSize: 10, label: "R" } });
36
+ expect(container.querySelectorAll(".st-renkoChart__axis").length).toBe(2);
37
+ expect(container.querySelectorAll(".st-renkoChart__tick").length).toBeGreaterThan(0);
38
+ });
39
+ it("lists every brick in the accessible data list", () => {
40
+ const { container } = render(RenkoChart, {
41
+ props: { data: [{ date: 0, close: 100 }, { date: 1, close: 110 }], boxSize: 10, label: "R" }
42
+ });
43
+ expect(listItems(container)[0]).toBe("▲ 100 → 110");
44
+ });
45
+ it("drops non-finite points before building bricks", () => {
46
+ const { container } = render(RenkoChart, {
47
+ props: {
48
+ data: [
49
+ { date: Number.NaN, close: 100 },
50
+ { date: 0, close: Number.NaN },
51
+ { date: 1, close: 100 },
52
+ { date: 2, close: 120 }
53
+ ],
54
+ boxSize: 10,
55
+ label: "R"
56
+ }
57
+ });
58
+ expect(bricks(container).length).toBe(2);
59
+ });
60
+ it("merges a custom class onto the root", () => {
61
+ const { container } = render(RenkoChart, { props: { data: series, class: "mine" } });
62
+ const root = container.querySelector(".st-renkoChart");
63
+ expect(structuralClass(root)).toBe("st-renkoChart");
64
+ expect(root.classList.contains("mine")).toBe(true);
65
+ });
66
+ });