@webmcp-auto-ui/ui 2.5.27 → 2.5.29

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 (76) hide show
  1. package/package.json +18 -5
  2. package/src/agent/LLMSelector.svelte +11 -3
  3. package/src/agent/ModelCacheManager.svelte +359 -0
  4. package/src/index.ts +42 -30
  5. package/src/theme/scale.ts +128 -0
  6. package/src/widgets/WidgetRenderer.svelte +144 -107
  7. package/src/widgets/export-widget.ts +28 -1
  8. package/src/widgets/helpers/safe-image.ts +78 -0
  9. package/src/widgets/notebook/.gitkeep +0 -0
  10. package/src/widgets/notebook/chart-renderer.ts +63 -0
  11. package/src/widgets/notebook/executors/.gitkeep +1 -0
  12. package/src/widgets/notebook/executors/index.ts +4 -0
  13. package/src/widgets/notebook/executors/js-worker.ts +269 -0
  14. package/src/widgets/notebook/executors/sql.ts +206 -0
  15. package/src/widgets/notebook/import-modals.ts +560 -0
  16. package/src/widgets/notebook/left-pane.ts +256 -0
  17. package/src/widgets/notebook/notebook.ts +930 -0
  18. package/src/widgets/notebook/prose.ts +615 -0
  19. package/src/widgets/notebook/recipe-browser.ts +350 -0
  20. package/src/widgets/notebook/recipes/notebook.md +124 -0
  21. package/src/widgets/notebook/resource-extractor.ts +162 -0
  22. package/src/widgets/notebook/share-handlers.ts +222 -0
  23. package/src/widgets/notebook/shared.ts +1633 -0
  24. package/src/widgets/rich/cards.ts +181 -0
  25. package/src/widgets/rich/carousel.ts +319 -0
  26. package/src/widgets/rich/chart-rich.ts +386 -0
  27. package/src/widgets/rich/d3.ts +503 -0
  28. package/src/widgets/rich/data-table.ts +342 -0
  29. package/src/widgets/rich/gallery.ts +350 -0
  30. package/src/widgets/rich/grid-data.ts +173 -0
  31. package/src/widgets/rich/hemicycle.ts +313 -0
  32. package/src/widgets/rich/js-sandbox.ts +122 -0
  33. package/src/widgets/rich/json-viewer.ts +202 -0
  34. package/src/widgets/rich/log.ts +143 -0
  35. package/src/widgets/rich/map.ts +218 -0
  36. package/src/widgets/rich/profile.ts +256 -0
  37. package/src/widgets/rich/sankey.ts +257 -0
  38. package/src/widgets/rich/stat-card.ts +125 -0
  39. package/src/widgets/rich/timeline.ts +179 -0
  40. package/src/widgets/rich/trombinoscope.ts +246 -0
  41. package/src/widgets/simple/actions.ts +89 -0
  42. package/src/widgets/simple/alert.ts +100 -0
  43. package/src/widgets/simple/chart.ts +189 -0
  44. package/src/widgets/simple/code.ts +79 -0
  45. package/src/widgets/simple/kv.ts +68 -0
  46. package/src/widgets/simple/list.ts +89 -0
  47. package/src/widgets/simple/stat.ts +58 -0
  48. package/src/widgets/simple/tags.ts +125 -0
  49. package/src/widgets/simple/text.ts +198 -0
  50. package/src/widgets/SafeImage.svelte +0 -76
  51. package/src/widgets/rich/Cards.svelte +0 -39
  52. package/src/widgets/rich/Carousel.svelte +0 -88
  53. package/src/widgets/rich/Chart.svelte +0 -142
  54. package/src/widgets/rich/D3Widget.svelte +0 -378
  55. package/src/widgets/rich/DataTable.svelte +0 -62
  56. package/src/widgets/rich/Gallery.svelte +0 -94
  57. package/src/widgets/rich/GridData.svelte +0 -44
  58. package/src/widgets/rich/Hemicycle.svelte +0 -78
  59. package/src/widgets/rich/JsSandbox.svelte +0 -51
  60. package/src/widgets/rich/JsonViewer.svelte +0 -42
  61. package/src/widgets/rich/LogViewer.svelte +0 -24
  62. package/src/widgets/rich/MapView.svelte +0 -140
  63. package/src/widgets/rich/ProfileCard.svelte +0 -59
  64. package/src/widgets/rich/Sankey.svelte +0 -56
  65. package/src/widgets/rich/StatCard.svelte +0 -35
  66. package/src/widgets/rich/Timeline.svelte +0 -43
  67. package/src/widgets/rich/Trombinoscope.svelte +0 -48
  68. package/src/widgets/simple/ActionsBlock.svelte +0 -15
  69. package/src/widgets/simple/AlertBlock.svelte +0 -11
  70. package/src/widgets/simple/ChartBlock.svelte +0 -21
  71. package/src/widgets/simple/CodeBlock.svelte +0 -11
  72. package/src/widgets/simple/KVBlock.svelte +0 -16
  73. package/src/widgets/simple/ListBlock.svelte +0 -17
  74. package/src/widgets/simple/StatBlock.svelte +0 -14
  75. package/src/widgets/simple/TagsBlock.svelte +0 -15
  76. package/src/widgets/simple/TextBlock.svelte +0 -122
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Vanilla renderer for the "text" / TextBlock widget.
3
+ *
4
+ * Mirrors the Svelte TextBlock.svelte output: a <div class="tb-md ..."> whose
5
+ * innerHTML is produced by a minimal, XSS-safe markdown -> HTML function.
6
+ *
7
+ * XSS note: renderMarkdown() escapes &, <, >, " BEFORE applying inline rules,
8
+ * so user-controlled text can only reach the DOM as already-escaped entities or
9
+ * as the structural tags we explicitly emit (h1-6, p, strong, em, code, pre, ul,
10
+ * ol, li, a, hr). Using innerHTML here is therefore safe — the function is the
11
+ * single source of trust for sanitization.
12
+ */
13
+
14
+ export interface TextBlockData {
15
+ content?: string;
16
+ }
17
+
18
+ /** Minimal markdown → HTML renderer (no deps, naturally XSS-safe: only produces known tags) */
19
+ function renderMarkdown(src: string): string {
20
+ if (!src) return '';
21
+
22
+ // Escape HTML entities first (XSS protection)
23
+ const esc = (s: string) =>
24
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
25
+
26
+ const lines = src.split('\n');
27
+ const out: string[] = [];
28
+ let inCode = false;
29
+ let codeLines: string[] = [];
30
+ let inUl = false;
31
+ let inOl = false;
32
+
33
+ const closeList = () => {
34
+ if (inUl) { out.push('</ul>'); inUl = false; }
35
+ if (inOl) { out.push('</ol>'); inOl = false; }
36
+ };
37
+
38
+ /** Inline formatting: bold, italic, code, links */
39
+ const inline = (s: string): string => {
40
+ return esc(s)
41
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
42
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
43
+ .replace(/\*([^*]+)\*/g, '<em>$1</em>')
44
+ // Whitelist URL protocols to block javascript:/data:/vbscript: XSS via markdown links.
45
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, href) =>
46
+ /^(https?:|\/|#|mailto:)/i.test(href)
47
+ ? `<a href="${href}" target="_blank" rel="noopener">${label}</a>`
48
+ : label,
49
+ );
50
+ };
51
+
52
+ for (const line of lines) {
53
+ // Fenced code block toggle
54
+ if (line.trimStart().startsWith('```')) {
55
+ if (!inCode) {
56
+ closeList();
57
+ inCode = true;
58
+ codeLines = [];
59
+ } else {
60
+ out.push(`<pre><code>${esc(codeLines.join('\n'))}</code></pre>`);
61
+ inCode = false;
62
+ }
63
+ continue;
64
+ }
65
+ if (inCode) { codeLines.push(line); continue; }
66
+
67
+ const trimmed = line.trim();
68
+
69
+ // Empty line → close lists, push break
70
+ if (!trimmed) { closeList(); out.push(''); continue; }
71
+
72
+ // Headers
73
+ const hMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
74
+ if (hMatch) {
75
+ closeList();
76
+ const level = hMatch[1].length;
77
+ out.push(`<h${level}>${inline(hMatch[2])}</h${level}>`);
78
+ continue;
79
+ }
80
+
81
+ // Unordered list
82
+ if (/^[-*+]\s+/.test(trimmed)) {
83
+ if (inOl) { out.push('</ol>'); inOl = false; }
84
+ if (!inUl) { out.push('<ul>'); inUl = true; }
85
+ out.push(`<li>${inline(trimmed.replace(/^[-*+]\s+/, ''))}</li>`);
86
+ continue;
87
+ }
88
+
89
+ // Ordered list
90
+ const olMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
91
+ if (olMatch) {
92
+ if (inUl) { out.push('</ul>'); inUl = false; }
93
+ if (!inOl) { out.push('<ol>'); inOl = true; }
94
+ out.push(`<li>${inline(olMatch[2])}</li>`);
95
+ continue;
96
+ }
97
+
98
+ // Horizontal rule
99
+ if (/^[-*_]{3,}$/.test(trimmed)) {
100
+ closeList();
101
+ out.push('<hr>');
102
+ continue;
103
+ }
104
+
105
+ // Regular paragraph line
106
+ closeList();
107
+ out.push(`<p>${inline(trimmed)}</p>`);
108
+ }
109
+
110
+ // Close any open blocks
111
+ if (inCode) out.push(`<pre><code>${esc(codeLines.join('\n'))}</code></pre>`);
112
+ closeList();
113
+
114
+ return out.join('\n');
115
+ }
116
+
117
+ /** Scoped styles — emitted once per document, matching TextBlock.svelte :global rules. */
118
+ const STYLE_ID = 'autoui-text-widget-styles';
119
+ const STYLE_CSS = `
120
+ .tb-md { color: var(--color-text2); }
121
+ .tb-md h1 { font-size: 1.5em; font-weight: 700; color: var(--color-text1); margin: 0.8em 0 0.4em; }
122
+ .tb-md h2 { font-size: 1.25em; font-weight: 600; color: var(--color-text1); margin: 0.7em 0 0.35em; }
123
+ .tb-md h3 { font-size: 1.1em; font-weight: 600; color: var(--color-text1); margin: 0.6em 0 0.3em; }
124
+ .tb-md h4, .tb-md h5, .tb-md h6 { font-size: 1em; font-weight: 600; color: var(--color-text1); margin: 0.5em 0 0.25em; }
125
+ .tb-md p { margin: 0.4em 0; }
126
+ .tb-md strong { font-weight: 600; color: var(--color-text1); }
127
+ .tb-md em { font-style: italic; }
128
+ .tb-md a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 2px; }
129
+ .tb-md a:hover { opacity: 0.8; }
130
+ .tb-md ul, .tb-md ol { margin: 0.4em 0; padding-left: 1.5em; }
131
+ .tb-md ul { list-style: disc; }
132
+ .tb-md ol { list-style: decimal; }
133
+ .tb-md li { margin: 0.15em 0; }
134
+ .tb-md code { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 0.9em; background: var(--color-surface2); padding: 0.15em 0.35em; border-radius: 4px; }
135
+ .tb-md pre { background: var(--color-surface2); border-radius: 6px; padding: 0.75em 1em; margin: 0.5em 0; overflow-x: auto; }
136
+ .tb-md pre code { background: none; padding: 0; font-size: 0.85em; }
137
+ .tb-md hr { border: none; border-top: 1px solid var(--color-surface2); margin: 0.8em 0; }
138
+ `;
139
+
140
+ function ensureStyles(doc: Document): void {
141
+ if (doc.getElementById(STYLE_ID)) return;
142
+ const style = doc.createElement('style');
143
+ style.id = STYLE_ID;
144
+ style.textContent = STYLE_CSS;
145
+ doc.head.appendChild(style);
146
+ }
147
+
148
+ /**
149
+ * Mount a text / markdown widget into `container`.
150
+ *
151
+ * Returns an `unmount()` function that clears the container and removes any
152
+ * attached listeners.
153
+ */
154
+ export function render(container: HTMLElement, data: any): () => void {
155
+ const payload = (data ?? {}) as Partial<TextBlockData>;
156
+ const content = typeof payload.content === 'string' ? payload.content : '';
157
+
158
+ ensureStyles(container.ownerDocument ?? document);
159
+
160
+ const root = container.ownerDocument.createElement('div');
161
+ root.className = 'tb-md p-4 md:p-5 text-sm leading-relaxed';
162
+
163
+ if (!content.trim()) {
164
+ // Empty-state placeholder — keeps layout, signals absence to a11y tree.
165
+ const placeholder = container.ownerDocument.createElement('p');
166
+ placeholder.textContent = '';
167
+ placeholder.setAttribute('aria-label', 'empty text block');
168
+ placeholder.style.opacity = '0.5';
169
+ placeholder.style.fontStyle = 'italic';
170
+ root.appendChild(placeholder);
171
+ } else {
172
+ // Safe: renderMarkdown() escapes user text before emitting tags.
173
+ root.innerHTML = renderMarkdown(content);
174
+ }
175
+
176
+ // Delegated click handler — lets links emit widget:interact for host apps
177
+ // (e.g. analytics / canvas telemetry) without blocking default navigation.
178
+ const onClick = (ev: MouseEvent) => {
179
+ const target = ev.target as HTMLElement | null;
180
+ if (!target) return;
181
+ const anchor = target.closest('a');
182
+ if (!anchor || !root.contains(anchor)) return;
183
+ container.dispatchEvent(
184
+ new CustomEvent('widget:interact', {
185
+ detail: { action: 'link', payload: { href: (anchor as HTMLAnchorElement).href } },
186
+ bubbles: true,
187
+ }),
188
+ );
189
+ };
190
+ root.addEventListener('click', onClick);
191
+
192
+ container.appendChild(root);
193
+
194
+ return () => {
195
+ root.removeEventListener('click', onClick);
196
+ container.innerHTML = '';
197
+ };
198
+ }
@@ -1,76 +0,0 @@
1
- <script lang="ts">
2
- /**
3
- * SafeImage — Robust image component with URL validation, error fallback, and loading state.
4
- * Replaces raw <img> tags in widgets to handle hallucinated/broken URLs gracefully.
5
- */
6
- interface Props {
7
- src: string | undefined | null;
8
- alt?: string;
9
- class?: string;
10
- style?: string;
11
- loading?: 'lazy' | 'eager';
12
- /** Fallback text shown when image fails or URL is invalid (defaults to alt or 'Image') */
13
- fallbackText?: string;
14
- /** If true, render nothing when URL is invalid/missing instead of showing placeholder */
15
- hideOnError?: boolean;
16
- }
17
-
18
- let { src, alt = '', class: className = '', style = '', loading = 'lazy', fallbackText, hideOnError = false }: Props = $props();
19
-
20
- const VALID_PREFIXES = ['http://', 'https://', 'data:', '/'];
21
-
22
- const isValidUrl = $derived(
23
- typeof src === 'string' && src.length > 0 && VALID_PREFIXES.some(p => src!.startsWith(p))
24
- );
25
-
26
- let hasError = $state(false);
27
- let isLoaded = $state(false);
28
-
29
- // Reset state when src changes
30
- $effect(() => {
31
- if (src) {
32
- hasError = false;
33
- isLoaded = false;
34
- }
35
- });
36
-
37
- function onError() {
38
- hasError = true;
39
- }
40
-
41
- function onLoad() {
42
- isLoaded = true;
43
- }
44
-
45
- const showPlaceholder = $derived(!isValidUrl || hasError);
46
- const label = $derived(fallbackText ?? alt ?? 'Image');
47
- </script>
48
-
49
- {#if showPlaceholder}
50
- {#if !hideOnError}
51
- <div
52
- class="flex items-center justify-center bg-surface2 text-text2 text-xs {className}"
53
- {style}
54
- role="img"
55
- aria-label={label}
56
- >
57
- <svg class="w-5 h-5 opacity-40 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
58
- <rect x="3" y="3" width="18" height="18" rx="2" />
59
- <circle cx="8.5" cy="8.5" r="1.5" />
60
- <path d="M21 15l-5-5L5 21" />
61
- </svg>
62
- <span class="truncate max-w-[80%]">{label}</span>
63
- </div>
64
- {/if}
65
- {:else}
66
- <img
67
- {src}
68
- {alt}
69
- class={className}
70
- {style}
71
- {loading}
72
- onerror={onError}
73
- onload={onLoad}
74
- referrerpolicy="no-referrer"
75
- />
76
- {/if}
@@ -1,39 +0,0 @@
1
- <script lang="ts">
2
- import SafeImage from '../SafeImage.svelte';
3
- export interface CardItem { title: string; description?: string; subtitle?: string; image?: string; tags?: string[]; href?: string; }
4
- export interface CardsSpec { title?: string; cards?: CardItem[]; minCardWidth?: string; gap?: string; emptyMessage?: string; }
5
- interface Props { spec: Partial<CardsSpec>; data?: unknown; oncardclick?: (c: CardItem) => void; }
6
- let { spec, data, oncardclick }: Props = $props();
7
- const cards=$derived.by<CardItem[]>(()=>{
8
- if(Array.isArray(spec.cards)&&spec.cards.length) return spec.cards;
9
- if(Array.isArray(data)) return (data as Record<string,unknown>[]).map(d=>({title:String(d.title??d.name??d.label??JSON.stringify(d)),description:d.description as string|undefined,subtitle:d.subtitle as string|undefined}));
10
- return [];
11
- });
12
- </script>
13
- <div class="bg-surface border border-border rounded-lg p-3 md:p-4 font-sans">
14
- {#if spec.title}<h3 class="text-sm font-semibold text-text1 mb-3">{spec.title}</h3>{/if}
15
- {#if cards.length===0}<p class="text-text2 text-sm">{spec.emptyMessage??'No items'}</p>
16
- {:else}
17
- <div class="grid" style="grid-template-columns: repeat(auto-fill, minmax({spec.minCardWidth??'180px'}, 1fr)); gap: {spec.gap??'1rem'};">
18
- {#each cards as card}
19
- <!-- svelte-ignore a11y_click_events_have_key_events -->
20
- <!-- svelte-ignore a11y_no_static_element_interactions -->
21
- <!-- svelte-ignore a11y_interactive_supports_focus -->
22
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
23
- <div class="bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all {oncardclick?'cursor-pointer':''}" role={oncardclick?"button":undefined} tabindex={oncardclick?0:undefined} title={oncardclick?"Double-cliquez pour interagir":undefined} ondblclick={()=>oncardclick?.(card)}>
24
- {#if card.image}<SafeImage src={card.image} alt={card.title} class="w-full h-32 object-cover" hideOnError />{/if}
25
- <div class="p-3">
26
- <div class="font-semibold text-sm text-text1 leading-tight">{card.title}</div>
27
- {#if card.subtitle}<div class="text-xs text-text2 mt-0.5">{card.subtitle}</div>{/if}
28
- {#if card.description}<div class="text-xs text-text2 mt-1.5 leading-relaxed">{card.description}</div>{/if}
29
- {#if card.tags?.length}
30
- <div class="flex gap-1 flex-wrap mt-2">
31
- {#each card.tags as tag}<span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface2 text-text2">{tag}</span>{/each}
32
- </div>
33
- {/if}
34
- </div>
35
- </div>
36
- {/each}
37
- </div>
38
- {/if}
39
- </div>
@@ -1,88 +0,0 @@
1
- <script lang="ts">
2
- import SafeImage from '../SafeImage.svelte';
3
- export interface CarouselSlide { src?: string; content?: string; title?: string; subtitle?: string; }
4
- export interface CarouselSpec { title?: string; slides?: CarouselSlide[]; autoPlay?: boolean; interval?: number; }
5
- interface Props { spec: Partial<CarouselSpec>; data?: unknown; onslidechange?: (slide: CarouselSlide, index: number) => void; }
6
- let { spec, data, onslidechange }: Props = $props();
7
-
8
- const slides = $derived.by<CarouselSlide[]>(() => {
9
- if (Array.isArray(spec.slides) && spec.slides.length) return spec.slides;
10
- if (Array.isArray(data)) return data as CarouselSlide[];
11
- return [];
12
- });
13
-
14
- let current = $state(0);
15
- let timer: ReturnType<typeof setInterval> | null = null;
16
-
17
- function goTo(i: number, userInitiated = false) {
18
- current = Math.max(0, Math.min(i, slides.length - 1));
19
- if (userInitiated) onslidechange?.(slides[current], current);
20
- resetAuto();
21
- }
22
- function prev() { goTo(current > 0 ? current - 1 : slides.length - 1, true); }
23
- function next() { goTo(current < slides.length - 1 ? current + 1 : 0); }
24
-
25
- function resetAuto() {
26
- if (timer) clearInterval(timer);
27
- if (spec.autoPlay !== false && slides.length > 1) {
28
- timer = setInterval(next, spec.interval ?? 5000);
29
- }
30
- }
31
-
32
- $effect(() => {
33
- if (spec.autoPlay !== false && slides.length > 1) {
34
- resetAuto();
35
- return () => { if (timer) clearInterval(timer); };
36
- }
37
- });
38
-
39
- // Touch/swipe support
40
- let touchStartX = 0;
41
- function onTouchStart(e: TouchEvent) { touchStartX = e.touches[0].clientX; }
42
- function onTouchEnd(e: TouchEvent) {
43
- const diff = touchStartX - e.changedTouches[0].clientX;
44
- if (Math.abs(diff) > 50) { diff > 0 ? next() : prev(); }
45
- }
46
- </script>
47
-
48
- <div class="bg-surface border border-border rounded-lg p-3 md:p-4 font-sans">
49
- {#if spec.title}<h3 class="text-sm font-semibold text-text1 mb-3">{spec.title}</h3>{/if}
50
- {#if slides.length === 0}
51
- <p class="text-text2 text-sm">Aucun contenu</p>
52
- {:else}
53
- <!-- svelte-ignore a11y_no_static_element_interactions -->
54
- <div class="relative overflow-hidden rounded-lg"
55
- ontouchstart={onTouchStart} ontouchend={onTouchEnd}>
56
- <div class="flex transition-transform duration-300 ease-out" style="transform: translateX(-{current * 100}%);">
57
- {#each slides as slide}
58
- <div class="w-full flex-shrink-0">
59
- {#if slide.src}
60
- <SafeImage src={slide.src} alt={slide.title ?? ''} class="w-full h-48 sm:h-64 object-cover" loading="lazy" />
61
- {/if}
62
- {#if slide.title || slide.subtitle || slide.content}
63
- <div class="p-3">
64
- {#if slide.title}<div class="font-semibold text-sm text-text1">{slide.title}</div>{/if}
65
- {#if slide.subtitle}<div class="text-xs text-text2 mt-0.5">{slide.subtitle}</div>{/if}
66
- {#if slide.content}<div class="text-xs text-text2 mt-1.5">{slide.content}</div>{/if}
67
- </div>
68
- {/if}
69
- </div>
70
- {/each}
71
- </div>
72
-
73
- {#if slides.length > 1}
74
- <button class="absolute left-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/40 text-white flex items-center justify-center hover:bg-black/60 text-sm" onclick={prev}>&lsaquo;</button>
75
- <button class="absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/40 text-white flex items-center justify-center hover:bg-black/60 text-sm" onclick={() => goTo(current < slides.length - 1 ? current + 1 : 0, true)}>&rsaquo;</button>
76
- {/if}
77
- </div>
78
-
79
- {#if slides.length > 1}
80
- <div class="flex justify-center gap-1.5 mt-2">
81
- {#each slides as _, i}
82
- <button class="w-2 h-2 rounded-full transition-colors {i === current ? 'bg-accent' : 'bg-border2'}"
83
- onclick={() => goTo(i, true)}></button>
84
- {/each}
85
- </div>
86
- {/if}
87
- {/if}
88
- </div>
@@ -1,142 +0,0 @@
1
- <script lang="ts">
2
- export interface ChartDataset { label?: string; values: number[]; color?: string; }
3
- export interface ChartSpec { title?: string; type?: 'bar'|'line'|'area'|'pie'|'donut'; labels?: string[]; data?: ChartDataset[]; legend?: boolean; xAxis?: {label?:string}; yAxis?: {label?:string}; }
4
- interface Props { spec: Partial<ChartSpec>; }
5
- let { spec }: Props = $props();
6
- const PAL=[
7
- '#6366f1','#f59e0b','#10b981','#ef4444','#8b5cf6',
8
- '#06b6d4','#f97316','#ec4899','#14b8a6','#a855f7',
9
- '#eab308','#3b82f6','#22c55e','#e11d48','#0ea5e9',
10
- ];
11
- const fmt=new Intl.NumberFormat('fr-FR');
12
- const datasets=$derived<ChartDataset[]>(Array.isArray(spec.data)?spec.data:[]);
13
- const labels=$derived<string[]>(Array.isArray(spec.labels)?spec.labels:[]);
14
- const type=$derived(spec.type??'bar');
15
- const isPie=$derived(type==='pie'||type==='donut');
16
- const allVals=$derived(datasets.flatMap(d=>d.values??[]));
17
- const maxVal=$derived(Math.max(...allVals.filter(v=>typeof v==='number'),1));
18
- const xLabels=$derived(labels.length>0?labels:(datasets[0]?.values??[]).map((_:number,i:number)=>String(i+1)));
19
- const showLegend=$derived(spec.legend!==false&&datasets.length>1);
20
- function col(ds:ChartDataset,i:number){return ds.color??PAL[i%PAL.length];}
21
- /** For single-series bar charts, color each bar distinctly by x-index */
22
- const isCategoricalBar=$derived(type==='bar'&&datasets.length===1);
23
- const isCategoricalLine=$derived((type==='line'||type==='area')&&datasets.length===1);
24
- function barCol(ds:ChartDataset,di:number,xi:number){return isCategoricalBar?PAL[xi%PAL.length]:col(ds,di);}
25
- // Pie
26
- const pieTotal=$derived.by<number>(()=>{const ds=datasets[0];if(!ds)return 1;return ds.values.reduce((a,b)=>a+b,0)||1;});
27
- interface Slice{label:string;value:number;color:string;startAngle:number;endAngle:number;pct:number}
28
- const slices=$derived.by<Slice[]>(()=>{
29
- if(!isPie||!datasets.length)return[];
30
- const ds=datasets[0];const tot=pieTotal;
31
- let angle=-Math.PI/2;
32
- return (ds.values??[]).map((v,i)=>{
33
- const pct=v/tot;const start=angle;angle+=pct*2*Math.PI;
34
- return{label:xLabels[i]??String(i),value:v,color:col(ds,i),startAngle:start,endAngle:angle,pct};
35
- });
36
- });
37
- function arc(cx:number,cy:number,r:number,start:number,end:number,ir=0):string{
38
- const x1=cx+r*Math.cos(start),y1=cy+r*Math.sin(start);
39
- const x2=cx+r*Math.cos(end),y2=cy+r*Math.sin(end);
40
- const large=end-start>Math.PI?1:0;
41
- if(ir>0){const ix1=cx+ir*Math.cos(end),iy1=cy+ir*Math.sin(end),ix2=cx+ir*Math.cos(start),iy2=cy+ir*Math.sin(start);return`M${x1},${y1}A${r},${r},0,${large},1,${x2},${y2}L${ix1},${iy1}A${ir},${ir},0,${large},0,${ix2},${iy2}Z`;}
42
- return`M${cx},${cy}L${x1},${y1}A${r},${r},0,${large},1,${x2},${y2}Z`;
43
- }
44
- let tooltip=$state<string|null>(null);
45
- </script>
46
- <div class="bg-surface border border-border rounded-lg p-3 md:p-4 font-sans">
47
- {#if spec.title}<h3 class="text-sm font-semibold text-text1 mb-3">{spec.title}</h3>{/if}
48
- {#if !datasets.length}<p class="text-text2 text-sm">No data</p>
49
- {:else if isPie}
50
- <div class="relative flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
51
- <svg viewBox="-1 -1 2 2" class="w-40 h-40 flex-shrink-0" xmlns="http://www.w3.org/2000/svg">
52
- {#each slices as s,i}
53
- <!-- svelte-ignore a11y_click_events_have_key_events -->
54
- <!-- svelte-ignore a11y_no_static_element_interactions -->
55
- <path d={arc(0,0,0.9,s.startAngle,s.endAngle,type==='donut'?0.5:0)} fill={s.color} opacity="0.85"
56
- onmouseenter={()=>tooltip=`${s.label}: ${fmt.format(s.value)} (${Math.round(s.pct*100)}%)`}
57
- onmouseleave={()=>tooltip=null}
58
- class="cursor-pointer hover:opacity-100 transition-opacity"/>
59
- {/each}
60
- </svg>
61
- <div class="flex flex-col gap-1.5 text-xs">
62
- {#each slices as s}
63
- <div class="flex items-center gap-1.5">
64
- <div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:{s.color};"></div>
65
- <span class="text-text2">{s.label}</span>
66
- <span class="text-text2 ml-1">{Math.round(s.pct*100)}%</span>
67
- </div>
68
- {/each}
69
- </div>
70
- </div>
71
- {#if tooltip}<div class="mt-2 text-xs text-text2 font-mono">{tooltip}</div>{/if}
72
- {:else}
73
- <div class="relative">
74
- {#if type==='bar'}
75
- <div class="flex items-end gap-1 h-24 sm:h-32 mb-1">
76
- {#each xLabels as lbl, xi}
77
- <div class="flex-1 flex gap-0.5 items-end h-full">
78
- {#each datasets as ds, di}
79
- {@const v=ds.values[xi]??0}
80
- {@const pct=Math.round(v/maxVal*100)}
81
- <div class="flex-1 rounded-t transition-all hover:opacity-80 cursor-default"
82
- style="height:{pct}%;background:{barCol(ds,di,xi)};"
83
- title="{ds.label??''} {lbl}: {fmt.format(v)}">
84
- </div>
85
- {/each}
86
- </div>
87
- {/each}
88
- </div>
89
- <div class="flex gap-1 mb-2">
90
- {#each xLabels as lbl}<div class="flex-1 text-center text-[9px] font-mono text-text2 truncate">{lbl}</div>{/each}
91
- </div>
92
- {:else}
93
- {@const W=400}{@const H=120}{@const pad=10}
94
- <svg viewBox="0 0 {W} {H}" class="w-full" xmlns="http://www.w3.org/2000/svg">
95
- {#each datasets as ds, di}
96
- {@const pts=ds.values.map((v,i)=>`${pad+i*((W-pad*2)/(ds.values.length-1||1))},${H-pad-(v/maxVal)*(H-pad*2)}`)}
97
- {#if type==='area'}
98
- <polygon points="{pad},{H-pad} {pts.join(' ')} {pad+((ds.values.length-1)*((W-pad*2)/(ds.values.length-1||1)))},{H-pad}" fill={col(ds,di)} opacity="0.15"/>
99
- {/if}
100
- <polyline points={pts.join(' ')} fill="none" stroke={col(ds,di)} stroke-width="2" stroke-linejoin="round"/>
101
- {#each ds.values as v, i}
102
- {@const cx = pad + i * ((W - pad * 2) / (ds.values.length - 1 || 1))}
103
- {@const cy = H - pad - (v / maxVal) * (H - pad * 2)}
104
- <circle cx={cx} cy={cy} r="4" fill={isCategoricalLine ? PAL[i % PAL.length] : col(ds, di)} stroke="var(--color-surface, white)" stroke-width="1.5" />
105
- {/each}
106
- {/each}
107
- </svg>
108
- <div class="flex gap-1">
109
- {#each xLabels as lbl}<div class="flex-1 text-center text-[9px] font-mono text-text2 truncate">{lbl}</div>{/each}
110
- </div>
111
- {/if}
112
- </div>
113
- {#if isCategoricalLine && xLabels.length>1}
114
- <div class="flex gap-3 flex-wrap mt-2">
115
- {#each xLabels as lbl, xi}
116
- <div class="flex items-center gap-1 text-xs">
117
- <div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:{PAL[xi%PAL.length]};"></div>
118
- <span class="text-text2">{lbl}</span>
119
- </div>
120
- {/each}
121
- </div>
122
- {:else if isCategoricalBar && xLabels.length>1}
123
- <div class="flex gap-3 flex-wrap mt-2">
124
- {#each xLabels as lbl,xi}
125
- <div class="flex items-center gap-1 text-xs">
126
- <div class="w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:{PAL[xi%PAL.length]};"></div>
127
- <span class="text-text2">{lbl}</span>
128
- </div>
129
- {/each}
130
- </div>
131
- {:else if showLegend}
132
- <div class="flex gap-3 flex-wrap mt-2">
133
- {#each datasets as ds,i}
134
- <div class="flex items-center gap-1 text-xs">
135
- <div class="w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:{col(ds,i)};"></div>
136
- <span class="text-text2">{ds.label??`Series ${i+1}`}</span>
137
- </div>
138
- {/each}
139
- </div>
140
- {/if}
141
- {/if}
142
- </div>