@webmcp-auto-ui/ui 2.5.32 → 2.5.33
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/package.json +15 -2
- package/src/agent/DiagnosticModal.svelte +126 -50
- package/src/agent/EphemeralBubble.svelte +13 -3
- package/src/agent/MCPserversList.svelte +147 -0
- package/src/agent/McpConnector.svelte +10 -1
- package/src/agent/RecipeBrowser.svelte +384 -0
- package/src/agent/RemoteMCPserversDemo.svelte +5 -121
- package/src/agent/ToolBrowser.svelte +133 -0
- package/src/agent/WebMCPserversList.svelte +2 -0
- package/src/agent/useAgentLoop.svelte.ts +396 -0
- package/src/base/chat-inline.svelte +64 -0
- package/src/base/dialog-content.svelte +3 -1
- package/src/components/HeaderControls.svelte +78 -0
- package/src/index.ts +13 -35
- package/src/stores/canvas.svelte.ts +0 -6
- package/src/widgets/SafeImage.svelte +67 -0
- package/src/widgets/WidgetRenderer.svelte +153 -78
- package/src/widgets/notebook/executors/index.ts +0 -1
- package/src/widgets/notebook/executors/sql.ts +32 -182
- package/src/widgets/notebook/import-modal-api.ts +237 -0
- package/src/widgets/notebook/import-modal.svelte +738 -0
- package/src/widgets/notebook/left-pane.ts +1 -1
- package/src/widgets/notebook/notebook.svelte +75 -0
- package/src/widgets/notebook/notebook.ts +38 -73
- package/src/widgets/notebook/prose.ts +6 -3
- package/src/widgets/notebook/shared.ts +68 -49
- package/src/widgets/rich/cards.svelte +74 -0
- package/src/widgets/rich/carousel.svelte +126 -0
- package/src/widgets/rich/chart-rich.svelte +221 -0
- package/src/widgets/rich/chat-input.svelte +52 -0
- package/src/widgets/rich/data-table.svelte +132 -0
- package/src/widgets/rich/gallery.svelte +115 -0
- package/src/widgets/rich/grid-data.svelte +85 -0
- package/src/widgets/rich/hemicycle.svelte +95 -0
- package/src/widgets/rich/js-sandbox.svelte +67 -0
- package/src/widgets/rich/json-viewer.svelte +82 -0
- package/src/widgets/rich/log.svelte +62 -0
- package/src/widgets/rich/profile.svelte +91 -0
- package/src/widgets/rich/sankey.svelte +73 -0
- package/src/widgets/rich/stat-card.svelte +60 -0
- package/src/widgets/rich/timeline.svelte +95 -0
- package/src/widgets/rich/trombinoscope.svelte +87 -0
- package/src/widgets/simple/actions.svelte +36 -0
- package/src/widgets/simple/alert.svelte +52 -0
- package/src/widgets/simple/chart.svelte +38 -0
- package/src/widgets/simple/code.svelte +30 -0
- package/src/widgets/simple/kv.svelte +31 -0
- package/src/widgets/simple/list.svelte +35 -0
- package/src/widgets/simple/stat.svelte +36 -0
- package/src/widgets/simple/tags.svelte +34 -0
- package/src/widgets/simple/text.svelte +130 -0
- package/src/widgets/helpers/safe-image.ts +0 -78
- package/src/widgets/notebook/import-modals.ts +0 -560
- package/src/widgets/notebook/recipe-browser.ts +0 -350
- package/src/widgets/rich/cards.ts +0 -181
- package/src/widgets/rich/carousel.ts +0 -319
- package/src/widgets/rich/chart-rich.ts +0 -386
- package/src/widgets/rich/d3.ts +0 -503
- package/src/widgets/rich/data-table.ts +0 -342
- package/src/widgets/rich/gallery.ts +0 -350
- package/src/widgets/rich/grid-data.ts +0 -173
- package/src/widgets/rich/hemicycle.ts +0 -313
- package/src/widgets/rich/js-sandbox.ts +0 -122
- package/src/widgets/rich/json-viewer.ts +0 -202
- package/src/widgets/rich/log.ts +0 -143
- package/src/widgets/rich/map.ts +0 -218
- package/src/widgets/rich/profile.ts +0 -256
- package/src/widgets/rich/sankey.ts +0 -257
- package/src/widgets/rich/stat-card.ts +0 -125
- package/src/widgets/rich/timeline.ts +0 -179
- package/src/widgets/rich/trombinoscope.ts +0 -246
- package/src/widgets/simple/actions.ts +0 -89
- package/src/widgets/simple/alert.ts +0 -100
- package/src/widgets/simple/chart.ts +0 -189
- package/src/widgets/simple/code.ts +0 -79
- package/src/widgets/simple/kv.ts +0 -68
- package/src/widgets/simple/list.ts +0 -89
- package/src/widgets/simple/stat.ts +0 -58
- package/src/widgets/simple/tags.ts +0 -125
- package/src/widgets/simple/text.ts +0 -198
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Carousel (vanilla) — slides with prev/next buttons, dots, auto-advance and swipe.
|
|
3
|
-
* Mirrors Carousel.svelte. Replaces $effect/setInterval with a closure-managed timer.
|
|
4
|
-
*
|
|
5
|
-
* Contract:
|
|
6
|
-
* render(container, data) -> cleanup()
|
|
7
|
-
* Payload shape:
|
|
8
|
-
* { spec: Partial<CarouselSpec>, data?: unknown, onslidechange?: (slide, index) => void }
|
|
9
|
-
* Event:
|
|
10
|
-
* 'widget:interact' { action: 'slidechange', payload: { slide, index } }
|
|
11
|
-
*
|
|
12
|
-
* Cleanup: clears the auto-advance interval, removes all listeners and empties the container.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { createSafeImage } from '../helpers/safe-image.js';
|
|
16
|
-
|
|
17
|
-
export interface CarouselSlide {
|
|
18
|
-
src?: string;
|
|
19
|
-
content?: string;
|
|
20
|
-
title?: string;
|
|
21
|
-
subtitle?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface CarouselSpec {
|
|
25
|
-
title?: string;
|
|
26
|
-
slides?: CarouselSlide[];
|
|
27
|
-
autoPlay?: boolean;
|
|
28
|
-
interval?: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface CarouselPayload {
|
|
32
|
-
spec?: Partial<CarouselSpec>;
|
|
33
|
-
data?: unknown;
|
|
34
|
-
onslidechange?: (slide: CarouselSlide, index: number) => void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function resolveSlides(spec: Partial<CarouselSpec>, data: unknown): CarouselSlide[] {
|
|
38
|
-
if (Array.isArray(spec.slides) && spec.slides.length) return spec.slides as CarouselSlide[];
|
|
39
|
-
if (Array.isArray(data)) return data as CarouselSlide[];
|
|
40
|
-
return [];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function render(container: HTMLElement, data: any): () => void {
|
|
44
|
-
const payload: CarouselPayload = (data && typeof data === 'object' ? data : {}) as CarouselPayload;
|
|
45
|
-
const spec: Partial<CarouselSpec> = payload.spec ?? {};
|
|
46
|
-
const inner = payload.data;
|
|
47
|
-
const onslidechange = typeof payload.onslidechange === 'function' ? payload.onslidechange : undefined;
|
|
48
|
-
const slides = resolveSlides(spec, inner);
|
|
49
|
-
const autoPlay = spec.autoPlay !== false;
|
|
50
|
-
const intervalMs = spec.interval ?? 5000;
|
|
51
|
-
|
|
52
|
-
// --- Root ---
|
|
53
|
-
const root = document.createElement('div');
|
|
54
|
-
root.className = 'bg-surface border border-border rounded-lg p-3 md:p-4 font-sans';
|
|
55
|
-
|
|
56
|
-
if (spec.title) {
|
|
57
|
-
const h3 = document.createElement('h3');
|
|
58
|
-
h3.className = 'text-sm font-semibold text-text1 mb-3';
|
|
59
|
-
h3.textContent = spec.title;
|
|
60
|
-
root.appendChild(h3);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// --- Tracked listeners for robust cleanup ---
|
|
64
|
-
const listeners: Array<{ el: EventTarget; type: string; handler: EventListener; opts?: AddEventListenerOptions }> = [];
|
|
65
|
-
const addL = (el: EventTarget, type: string, handler: EventListener, opts?: AddEventListenerOptions) => {
|
|
66
|
-
el.addEventListener(type, handler, opts);
|
|
67
|
-
listeners.push({ el, type, handler, opts });
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// --- Auto-advance timer (closure-managed, replaces $effect) ---
|
|
71
|
-
let timer: ReturnType<typeof setInterval> | null = null;
|
|
72
|
-
|
|
73
|
-
const clearTimer = () => {
|
|
74
|
-
if (timer !== null) {
|
|
75
|
-
clearInterval(timer);
|
|
76
|
-
timer = null;
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
// Empty state — no slides, nothing else to wire
|
|
81
|
-
if (slides.length === 0) {
|
|
82
|
-
const p = document.createElement('p');
|
|
83
|
-
p.className = 'text-text2 text-sm';
|
|
84
|
-
p.textContent = 'Aucun contenu';
|
|
85
|
-
root.appendChild(p);
|
|
86
|
-
container.appendChild(root);
|
|
87
|
-
return () => {
|
|
88
|
-
container.innerHTML = '';
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// --- Current index via closure ---
|
|
93
|
-
let currentIdx = 0;
|
|
94
|
-
|
|
95
|
-
// --- Viewport ---
|
|
96
|
-
const viewport = document.createElement('div');
|
|
97
|
-
viewport.className = 'relative overflow-hidden rounded-lg';
|
|
98
|
-
viewport.setAttribute('role', 'region');
|
|
99
|
-
viewport.setAttribute('aria-roledescription', 'carousel');
|
|
100
|
-
viewport.setAttribute('aria-label', spec.title ?? 'Carousel');
|
|
101
|
-
viewport.setAttribute('tabindex', '0');
|
|
102
|
-
|
|
103
|
-
// --- Track (flex row translated on change) ---
|
|
104
|
-
const track = document.createElement('div');
|
|
105
|
-
track.className = 'flex transition-transform duration-300 ease-out';
|
|
106
|
-
track.style.transform = 'translateX(0%)';
|
|
107
|
-
|
|
108
|
-
const slideEls: HTMLElement[] = [];
|
|
109
|
-
slides.forEach((slide, i) => {
|
|
110
|
-
const slideEl = document.createElement('div');
|
|
111
|
-
slideEl.className = 'w-full flex-shrink-0';
|
|
112
|
-
slideEl.setAttribute('role', 'group');
|
|
113
|
-
slideEl.setAttribute('aria-roledescription', 'slide');
|
|
114
|
-
slideEl.setAttribute('aria-label', `${i + 1} / ${slides.length}`);
|
|
115
|
-
slideEl.setAttribute('aria-current', i === 0 ? 'true' : 'false');
|
|
116
|
-
|
|
117
|
-
if (slide.src) {
|
|
118
|
-
const img = createSafeImage({
|
|
119
|
-
src: slide.src,
|
|
120
|
-
alt: slide.title ?? '',
|
|
121
|
-
className: 'w-full h-48 sm:h-64 object-cover',
|
|
122
|
-
loading: 'lazy',
|
|
123
|
-
});
|
|
124
|
-
slideEl.appendChild(img);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (slide.title || slide.subtitle || slide.content) {
|
|
128
|
-
const body = document.createElement('div');
|
|
129
|
-
body.className = 'p-3';
|
|
130
|
-
|
|
131
|
-
if (slide.title) {
|
|
132
|
-
const t = document.createElement('div');
|
|
133
|
-
t.className = 'font-semibold text-sm text-text1';
|
|
134
|
-
t.textContent = slide.title;
|
|
135
|
-
body.appendChild(t);
|
|
136
|
-
}
|
|
137
|
-
if (slide.subtitle) {
|
|
138
|
-
const s = document.createElement('div');
|
|
139
|
-
s.className = 'text-xs text-text2 mt-0.5';
|
|
140
|
-
s.textContent = slide.subtitle;
|
|
141
|
-
body.appendChild(s);
|
|
142
|
-
}
|
|
143
|
-
if (slide.content) {
|
|
144
|
-
const c = document.createElement('div');
|
|
145
|
-
c.className = 'text-xs text-text2 mt-1.5';
|
|
146
|
-
c.textContent = slide.content;
|
|
147
|
-
body.appendChild(c);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
slideEl.appendChild(body);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
track.appendChild(slideEl);
|
|
154
|
-
slideEls.push(slideEl);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
viewport.appendChild(track);
|
|
158
|
-
|
|
159
|
-
// --- Dots container (built up front, updated on change) ---
|
|
160
|
-
const dots: HTMLButtonElement[] = [];
|
|
161
|
-
let dotsWrap: HTMLDivElement | null = null;
|
|
162
|
-
if (slides.length > 1) {
|
|
163
|
-
dotsWrap = document.createElement('div');
|
|
164
|
-
dotsWrap.className = 'flex justify-center gap-1.5 mt-2';
|
|
165
|
-
slides.forEach((_, i) => {
|
|
166
|
-
const dot = document.createElement('button');
|
|
167
|
-
dot.type = 'button';
|
|
168
|
-
dot.className = `w-2 h-2 rounded-full transition-colors ${i === 0 ? 'bg-accent' : 'bg-border2'}`;
|
|
169
|
-
dot.setAttribute('aria-label', `Aller à la diapositive ${i + 1}`);
|
|
170
|
-
dot.setAttribute('aria-current', i === 0 ? 'true' : 'false');
|
|
171
|
-
dots.push(dot);
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// --- Render state (DOM updates) ---
|
|
176
|
-
const renderState = () => {
|
|
177
|
-
track.style.transform = `translateX(-${currentIdx * 100}%)`;
|
|
178
|
-
slideEls.forEach((el, i) => {
|
|
179
|
-
el.setAttribute('aria-current', i === currentIdx ? 'true' : 'false');
|
|
180
|
-
});
|
|
181
|
-
dots.forEach((dot, i) => {
|
|
182
|
-
const active = i === currentIdx;
|
|
183
|
-
dot.className = `w-2 h-2 rounded-full transition-colors ${active ? 'bg-accent' : 'bg-border2'}`;
|
|
184
|
-
dot.setAttribute('aria-current', active ? 'true' : 'false');
|
|
185
|
-
});
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
// --- Navigation ---
|
|
189
|
-
const resetAuto = () => {
|
|
190
|
-
clearTimer();
|
|
191
|
-
if (autoPlay && slides.length > 1) {
|
|
192
|
-
timer = setInterval(() => {
|
|
193
|
-
// internal tick — not user-initiated
|
|
194
|
-
goTo(currentIdx < slides.length - 1 ? currentIdx + 1 : 0, false);
|
|
195
|
-
}, intervalMs);
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
const goTo = (i: number, userInitiated: boolean) => {
|
|
200
|
-
const next = Math.max(0, Math.min(i, slides.length - 1));
|
|
201
|
-
if (next === currentIdx && !userInitiated) {
|
|
202
|
-
// still reset timer to avoid drift
|
|
203
|
-
resetAuto();
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
currentIdx = next;
|
|
207
|
-
renderState();
|
|
208
|
-
if (userInitiated) {
|
|
209
|
-
const slide = slides[currentIdx];
|
|
210
|
-
onslidechange?.(slide, currentIdx);
|
|
211
|
-
container.dispatchEvent(
|
|
212
|
-
new CustomEvent('widget:interact', {
|
|
213
|
-
detail: { action: 'slidechange', payload: { slide, index: currentIdx } },
|
|
214
|
-
bubbles: true,
|
|
215
|
-
}),
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
resetAuto();
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
const prev = (userInitiated: boolean) => {
|
|
222
|
-
goTo(currentIdx > 0 ? currentIdx - 1 : slides.length - 1, userInitiated);
|
|
223
|
-
};
|
|
224
|
-
const next = (userInitiated: boolean) => {
|
|
225
|
-
goTo(currentIdx < slides.length - 1 ? currentIdx + 1 : 0, userInitiated);
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
// --- Prev/Next buttons ---
|
|
229
|
-
if (slides.length > 1) {
|
|
230
|
-
const prevBtn = document.createElement('button');
|
|
231
|
-
prevBtn.type = 'button';
|
|
232
|
-
prevBtn.className =
|
|
233
|
-
'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';
|
|
234
|
-
prevBtn.setAttribute('aria-label', 'Diapositive précédente');
|
|
235
|
-
prevBtn.innerHTML = '‹';
|
|
236
|
-
addL(prevBtn, 'click', () => prev(true));
|
|
237
|
-
|
|
238
|
-
const nextBtn = document.createElement('button');
|
|
239
|
-
nextBtn.type = 'button';
|
|
240
|
-
nextBtn.className =
|
|
241
|
-
'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';
|
|
242
|
-
nextBtn.setAttribute('aria-label', 'Diapositive suivante');
|
|
243
|
-
nextBtn.innerHTML = '›';
|
|
244
|
-
addL(nextBtn, 'click', () => next(true));
|
|
245
|
-
|
|
246
|
-
viewport.appendChild(prevBtn);
|
|
247
|
-
viewport.appendChild(nextBtn);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// --- Touch/swipe ---
|
|
251
|
-
let touchStartX = 0;
|
|
252
|
-
addL(
|
|
253
|
-
viewport,
|
|
254
|
-
'touchstart',
|
|
255
|
-
(e) => {
|
|
256
|
-
const te = e as TouchEvent;
|
|
257
|
-
touchStartX = te.touches[0]?.clientX ?? 0;
|
|
258
|
-
},
|
|
259
|
-
{ passive: true },
|
|
260
|
-
);
|
|
261
|
-
addL(
|
|
262
|
-
viewport,
|
|
263
|
-
'touchend',
|
|
264
|
-
(e) => {
|
|
265
|
-
const te = e as TouchEvent;
|
|
266
|
-
const endX = te.changedTouches[0]?.clientX ?? 0;
|
|
267
|
-
const diff = touchStartX - endX;
|
|
268
|
-
if (Math.abs(diff) > 50) {
|
|
269
|
-
if (diff > 0) next(true);
|
|
270
|
-
else prev(true);
|
|
271
|
-
}
|
|
272
|
-
},
|
|
273
|
-
{ passive: true },
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
// --- Keyboard arrows ---
|
|
277
|
-
addL(viewport, 'keydown', (e) => {
|
|
278
|
-
const ke = e as KeyboardEvent;
|
|
279
|
-
if (ke.key === 'ArrowLeft') {
|
|
280
|
-
ke.preventDefault();
|
|
281
|
-
prev(true);
|
|
282
|
-
} else if (ke.key === 'ArrowRight') {
|
|
283
|
-
ke.preventDefault();
|
|
284
|
-
next(true);
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// --- Pause auto-advance on hover / focus for accessibility ---
|
|
289
|
-
addL(viewport, 'mouseenter', () => clearTimer());
|
|
290
|
-
addL(viewport, 'mouseleave', () => resetAuto());
|
|
291
|
-
addL(viewport, 'focusin', () => clearTimer());
|
|
292
|
-
addL(viewport, 'focusout', () => resetAuto());
|
|
293
|
-
|
|
294
|
-
// --- Wire dots ---
|
|
295
|
-
if (dotsWrap) {
|
|
296
|
-
dots.forEach((dot, i) => {
|
|
297
|
-
addL(dot, 'click', () => goTo(i, true));
|
|
298
|
-
dotsWrap!.appendChild(dot);
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// --- Mount ---
|
|
303
|
-
root.appendChild(viewport);
|
|
304
|
-
if (dotsWrap) root.appendChild(dotsWrap);
|
|
305
|
-
container.appendChild(root);
|
|
306
|
-
|
|
307
|
-
// --- Start auto-advance ---
|
|
308
|
-
resetAuto();
|
|
309
|
-
|
|
310
|
-
// --- Cleanup ---
|
|
311
|
-
return () => {
|
|
312
|
-
clearTimer();
|
|
313
|
-
for (const { el, type, handler, opts } of listeners) {
|
|
314
|
-
el.removeEventListener(type, handler, opts);
|
|
315
|
-
}
|
|
316
|
-
listeners.length = 0;
|
|
317
|
-
container.innerHTML = '';
|
|
318
|
-
};
|
|
319
|
-
}
|
|
@@ -1,386 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chart — vanilla renderer
|
|
3
|
-
* Ports Chart.svelte (pie/donut/bar/line/area) to imperative DOM + SVG.
|
|
4
|
-
* Contract: render(container, spec) -> cleanup()
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
8
|
-
|
|
9
|
-
export interface ChartDataset {
|
|
10
|
-
label?: string;
|
|
11
|
-
values: number[];
|
|
12
|
-
color?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface ChartSpec {
|
|
16
|
-
title?: string;
|
|
17
|
-
type?: 'bar' | 'line' | 'area' | 'pie' | 'donut';
|
|
18
|
-
labels?: string[];
|
|
19
|
-
data?: ChartDataset[];
|
|
20
|
-
legend?: boolean;
|
|
21
|
-
xAxis?: { label?: string };
|
|
22
|
-
yAxis?: { label?: string };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const PAL = [
|
|
26
|
-
'#6366f1', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6',
|
|
27
|
-
'#06b6d4', '#f97316', '#ec4899', '#14b8a6', '#a855f7',
|
|
28
|
-
'#eab308', '#3b82f6', '#22c55e', '#e11d48', '#0ea5e9',
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
const fmt = new Intl.NumberFormat('fr-FR');
|
|
32
|
-
|
|
33
|
-
function col(ds: ChartDataset, i: number): string {
|
|
34
|
-
return ds.color ?? PAL[i % PAL.length];
|
|
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) {
|
|
42
|
-
const ix1 = cx + ir * Math.cos(end), iy1 = cy + ir * Math.sin(end);
|
|
43
|
-
const ix2 = cx + ir * Math.cos(start), iy2 = cy + ir * Math.sin(start);
|
|
44
|
-
return `M${x1},${y1}A${r},${r},0,${large},1,${x2},${y2}L${ix1},${iy1}A${ir},${ir},0,${large},0,${ix2},${iy2}Z`;
|
|
45
|
-
}
|
|
46
|
-
return `M${cx},${cy}L${x1},${y1}A${r},${r},0,${large},1,${x2},${y2}Z`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface Slice {
|
|
50
|
-
label: string;
|
|
51
|
-
value: number;
|
|
52
|
-
color: string;
|
|
53
|
-
startAngle: number;
|
|
54
|
-
endAngle: number;
|
|
55
|
-
pct: number;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function computeSlices(datasets: ChartDataset[], xLabels: string[]): Slice[] {
|
|
59
|
-
if (!datasets.length) return [];
|
|
60
|
-
const ds = datasets[0];
|
|
61
|
-
const tot = (ds.values ?? []).reduce((a, b) => a + b, 0) || 1;
|
|
62
|
-
let angle = -Math.PI / 2;
|
|
63
|
-
return (ds.values ?? []).map((v, i) => {
|
|
64
|
-
const pct = v / tot;
|
|
65
|
-
const start = angle;
|
|
66
|
-
angle += pct * 2 * Math.PI;
|
|
67
|
-
return {
|
|
68
|
-
label: xLabels[i] ?? String(i),
|
|
69
|
-
value: v,
|
|
70
|
-
color: col(ds, i),
|
|
71
|
-
startAngle: start,
|
|
72
|
-
endAngle: angle,
|
|
73
|
-
pct,
|
|
74
|
-
};
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function h(tag: string, attrs?: Record<string, string>, children?: (Node | string)[]): HTMLElement {
|
|
79
|
-
const el = document.createElement(tag);
|
|
80
|
-
if (attrs) for (const k in attrs) el.setAttribute(k, attrs[k]);
|
|
81
|
-
if (children) for (const c of children) el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
82
|
-
return el;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function s(tag: string, attrs?: Record<string, string>, children?: (Node | string)[]): SVGElement {
|
|
86
|
-
const el = document.createElementNS(SVG_NS, tag);
|
|
87
|
-
if (attrs) for (const k in attrs) el.setAttribute(k, attrs[k]);
|
|
88
|
-
if (children) for (const c of children) el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
89
|
-
return el;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function dispatchInteract(container: HTMLElement, action: string, payload: any) {
|
|
93
|
-
container.dispatchEvent(new CustomEvent('widget:interact', {
|
|
94
|
-
detail: { action, payload },
|
|
95
|
-
bubbles: true,
|
|
96
|
-
}));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Serialize the chart SVG to a PNG Blob at the given scale.
|
|
101
|
-
* Falls back to an empty 1x1 PNG if no SVG is present (e.g. bar chart pure-DOM).
|
|
102
|
-
*/
|
|
103
|
-
function makeExportPng(container: HTMLElement) {
|
|
104
|
-
return async (scale = 2): Promise<Blob> => {
|
|
105
|
-
const svg = container.querySelector('svg');
|
|
106
|
-
if (!svg) {
|
|
107
|
-
// no SVG (bar chart). Rasterize via html2canvas-like minimal fallback: blank blob.
|
|
108
|
-
const c = document.createElement('canvas');
|
|
109
|
-
c.width = 1; c.height = 1;
|
|
110
|
-
return await new Promise<Blob>((resolve) => c.toBlob(b => resolve(b!), 'image/png')!);
|
|
111
|
-
}
|
|
112
|
-
const clone = svg.cloneNode(true) as SVGElement;
|
|
113
|
-
const bbox = svg.getBoundingClientRect();
|
|
114
|
-
const w = Math.max(1, Math.round(bbox.width));
|
|
115
|
-
const hh = Math.max(1, Math.round(bbox.height));
|
|
116
|
-
clone.setAttribute('xmlns', SVG_NS);
|
|
117
|
-
clone.setAttribute('width', String(w));
|
|
118
|
-
clone.setAttribute('height', String(hh));
|
|
119
|
-
const xml = new XMLSerializer().serializeToString(clone);
|
|
120
|
-
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
|
|
121
|
-
const url = URL.createObjectURL(svgBlob);
|
|
122
|
-
try {
|
|
123
|
-
const img = new Image();
|
|
124
|
-
img.crossOrigin = 'anonymous';
|
|
125
|
-
await new Promise<void>((resolve, reject) => {
|
|
126
|
-
img.onload = () => resolve();
|
|
127
|
-
img.onerror = () => reject(new Error('svg img load failed'));
|
|
128
|
-
img.src = url;
|
|
129
|
-
});
|
|
130
|
-
const canvas = document.createElement('canvas');
|
|
131
|
-
canvas.width = w * scale;
|
|
132
|
-
canvas.height = hh * scale;
|
|
133
|
-
const ctx = canvas.getContext('2d')!;
|
|
134
|
-
ctx.scale(scale, scale);
|
|
135
|
-
ctx.drawImage(img, 0, 0, w, hh);
|
|
136
|
-
return await new Promise<Blob>((resolve, reject) => {
|
|
137
|
-
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
|
138
|
-
});
|
|
139
|
-
} finally {
|
|
140
|
-
URL.revokeObjectURL(url);
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function render(container: HTMLElement, data: any): () => void {
|
|
146
|
-
const spec: Partial<ChartSpec> = (data ?? {}) as Partial<ChartSpec>;
|
|
147
|
-
const datasets: ChartDataset[] = Array.isArray(spec.data) ? spec.data : [];
|
|
148
|
-
const labels: string[] = Array.isArray(spec.labels) ? spec.labels : [];
|
|
149
|
-
const type = spec.type ?? 'bar';
|
|
150
|
-
const isPie = type === 'pie' || type === 'donut';
|
|
151
|
-
const allVals = datasets.flatMap(d => d.values ?? []);
|
|
152
|
-
const maxVal = Math.max(...allVals.filter(v => typeof v === 'number'), 1);
|
|
153
|
-
const xLabels: string[] = labels.length > 0
|
|
154
|
-
? labels
|
|
155
|
-
: (datasets[0]?.values ?? []).map((_: number, i: number) => String(i + 1));
|
|
156
|
-
const showLegend = spec.legend !== false && datasets.length > 1;
|
|
157
|
-
const isCategoricalBar = type === 'bar' && datasets.length === 1;
|
|
158
|
-
const isCategoricalLine = (type === 'line' || type === 'area') && datasets.length === 1;
|
|
159
|
-
const barCol = (ds: ChartDataset, di: number, xi: number) =>
|
|
160
|
-
isCategoricalBar ? PAL[xi % PAL.length] : col(ds, di);
|
|
161
|
-
|
|
162
|
-
// Cleanup tracking.
|
|
163
|
-
const listeners: Array<{ el: EventTarget; type: string; fn: EventListener }> = [];
|
|
164
|
-
const on = (el: EventTarget, t: string, fn: EventListener) => {
|
|
165
|
-
el.addEventListener(t, fn);
|
|
166
|
-
listeners.push({ el, type: t, fn });
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
container.innerHTML = '';
|
|
170
|
-
const root = h('div', { class: 'bg-surface border border-border rounded-lg p-3 md:p-4 font-sans' });
|
|
171
|
-
|
|
172
|
-
if (spec.title) {
|
|
173
|
-
root.appendChild(h('h3', { class: 'text-sm font-semibold text-text1 mb-3' }, [spec.title]));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (!datasets.length) {
|
|
177
|
-
root.appendChild(h('p', { class: 'text-text2 text-sm' }, ['No data']));
|
|
178
|
-
container.appendChild(root);
|
|
179
|
-
(container as any).__exportPng = makeExportPng(container);
|
|
180
|
-
return () => {
|
|
181
|
-
for (const l of listeners) l.el.removeEventListener(l.type, l.fn);
|
|
182
|
-
listeners.length = 0;
|
|
183
|
-
container.innerHTML = '';
|
|
184
|
-
delete (container as any).__exportPng;
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Tooltip element (shared across hoverables in this render).
|
|
189
|
-
const tooltipEl = h('div', { class: 'mt-2 text-xs text-text2 font-mono', style: 'display:none;' });
|
|
190
|
-
|
|
191
|
-
if (isPie) {
|
|
192
|
-
const slices = computeSlices(datasets, xLabels);
|
|
193
|
-
const wrapper = h('div', { class: 'relative flex flex-col sm:flex-row items-center gap-4 sm:gap-6' });
|
|
194
|
-
const svg = s('svg', {
|
|
195
|
-
viewBox: '-1 -1 2 2',
|
|
196
|
-
class: 'w-40 h-40 flex-shrink-0',
|
|
197
|
-
xmlns: SVG_NS,
|
|
198
|
-
});
|
|
199
|
-
slices.forEach((sl, i) => {
|
|
200
|
-
const path = s('path', {
|
|
201
|
-
d: arc(0, 0, 0.9, sl.startAngle, sl.endAngle, type === 'donut' ? 0.5 : 0),
|
|
202
|
-
fill: sl.color,
|
|
203
|
-
opacity: '0.85',
|
|
204
|
-
class: 'cursor-pointer hover:opacity-100 transition-opacity',
|
|
205
|
-
role: 'img',
|
|
206
|
-
'aria-label': `${sl.label}: ${fmt.format(sl.value)} (${Math.round(sl.pct * 100)}%)`,
|
|
207
|
-
});
|
|
208
|
-
const enter = () => {
|
|
209
|
-
tooltipEl.textContent = `${sl.label}: ${fmt.format(sl.value)} (${Math.round(sl.pct * 100)}%)`;
|
|
210
|
-
tooltipEl.style.display = '';
|
|
211
|
-
dispatchInteract(container, 'chart:hover', { index: i, label: sl.label, value: sl.value, pct: sl.pct });
|
|
212
|
-
};
|
|
213
|
-
const leave = () => {
|
|
214
|
-
tooltipEl.textContent = '';
|
|
215
|
-
tooltipEl.style.display = 'none';
|
|
216
|
-
};
|
|
217
|
-
const click = () => dispatchInteract(container, 'chart:click', { index: i, label: sl.label, value: sl.value, pct: sl.pct });
|
|
218
|
-
on(path, 'mouseenter', enter);
|
|
219
|
-
on(path, 'mouseleave', leave);
|
|
220
|
-
on(path, 'click', click);
|
|
221
|
-
svg.appendChild(path);
|
|
222
|
-
});
|
|
223
|
-
wrapper.appendChild(svg);
|
|
224
|
-
|
|
225
|
-
const legend = h('div', { class: 'flex flex-col gap-1.5 text-xs' });
|
|
226
|
-
for (const sl of slices) {
|
|
227
|
-
const row = h('div', { class: 'flex items-center gap-1.5' });
|
|
228
|
-
row.appendChild(h('div', { class: 'w-2.5 h-2.5 rounded-full flex-shrink-0', style: `background:${sl.color};` }));
|
|
229
|
-
row.appendChild(h('span', { class: 'text-text2' }, [sl.label]));
|
|
230
|
-
row.appendChild(h('span', { class: 'text-text2 ml-1' }, [`${Math.round(sl.pct * 100)}%`]));
|
|
231
|
-
legend.appendChild(row);
|
|
232
|
-
}
|
|
233
|
-
wrapper.appendChild(legend);
|
|
234
|
-
root.appendChild(wrapper);
|
|
235
|
-
root.appendChild(tooltipEl);
|
|
236
|
-
} else {
|
|
237
|
-
const chartWrap = h('div', { class: 'relative' });
|
|
238
|
-
|
|
239
|
-
if (type === 'bar') {
|
|
240
|
-
const barsRow = h('div', { class: 'flex items-end gap-1 h-24 sm:h-32 mb-1' });
|
|
241
|
-
xLabels.forEach((lbl, xi) => {
|
|
242
|
-
const cell = h('div', { class: 'flex-1 flex gap-0.5 items-end h-full' });
|
|
243
|
-
datasets.forEach((ds, di) => {
|
|
244
|
-
const v = ds.values[xi] ?? 0;
|
|
245
|
-
const pct = Math.round((v / maxVal) * 100);
|
|
246
|
-
const bar = h('div', {
|
|
247
|
-
class: 'flex-1 rounded-t transition-all hover:opacity-80 cursor-default',
|
|
248
|
-
style: `height:${pct}%;background:${barCol(ds, di, xi)};`,
|
|
249
|
-
title: `${ds.label ?? ''} ${lbl}: ${fmt.format(v)}`,
|
|
250
|
-
role: 'img',
|
|
251
|
-
'aria-label': `${ds.label ?? ''} ${lbl}: ${fmt.format(v)}`,
|
|
252
|
-
});
|
|
253
|
-
const enter = () => {
|
|
254
|
-
tooltipEl.textContent = `${ds.label ?? ''} ${lbl}: ${fmt.format(v)}`;
|
|
255
|
-
tooltipEl.style.display = '';
|
|
256
|
-
dispatchInteract(container, 'chart:hover', { series: di, index: xi, label: lbl, value: v });
|
|
257
|
-
};
|
|
258
|
-
const leave = () => {
|
|
259
|
-
tooltipEl.textContent = '';
|
|
260
|
-
tooltipEl.style.display = 'none';
|
|
261
|
-
};
|
|
262
|
-
const click = () => dispatchInteract(container, 'chart:click', { series: di, index: xi, label: lbl, value: v });
|
|
263
|
-
on(bar, 'mouseenter', enter);
|
|
264
|
-
on(bar, 'mouseleave', leave);
|
|
265
|
-
on(bar, 'click', click);
|
|
266
|
-
cell.appendChild(bar);
|
|
267
|
-
});
|
|
268
|
-
barsRow.appendChild(cell);
|
|
269
|
-
});
|
|
270
|
-
chartWrap.appendChild(barsRow);
|
|
271
|
-
|
|
272
|
-
const xRow = h('div', { class: 'flex gap-1 mb-2' });
|
|
273
|
-
for (const lbl of xLabels) {
|
|
274
|
-
xRow.appendChild(h('div', { class: 'flex-1 text-center text-[9px] font-mono text-text2 truncate' }, [lbl]));
|
|
275
|
-
}
|
|
276
|
-
chartWrap.appendChild(xRow);
|
|
277
|
-
} else {
|
|
278
|
-
// line or area
|
|
279
|
-
const W = 400, H = 120, pad = 10;
|
|
280
|
-
const svg = s('svg', {
|
|
281
|
-
viewBox: `0 0 ${W} ${H}`,
|
|
282
|
-
class: 'w-full',
|
|
283
|
-
xmlns: SVG_NS,
|
|
284
|
-
});
|
|
285
|
-
datasets.forEach((ds, di) => {
|
|
286
|
-
const n = ds.values.length;
|
|
287
|
-
const step = (W - pad * 2) / (n - 1 || 1);
|
|
288
|
-
const pts = ds.values.map((v, i) => `${pad + i * step},${H - pad - (v / maxVal) * (H - pad * 2)}`);
|
|
289
|
-
if (type === 'area') {
|
|
290
|
-
const lastX = pad + (n - 1) * step;
|
|
291
|
-
svg.appendChild(s('polygon', {
|
|
292
|
-
points: `${pad},${H - pad} ${pts.join(' ')} ${lastX},${H - pad}`,
|
|
293
|
-
fill: col(ds, di),
|
|
294
|
-
opacity: '0.15',
|
|
295
|
-
}));
|
|
296
|
-
}
|
|
297
|
-
svg.appendChild(s('polyline', {
|
|
298
|
-
points: pts.join(' '),
|
|
299
|
-
fill: 'none',
|
|
300
|
-
stroke: col(ds, di),
|
|
301
|
-
'stroke-width': '2',
|
|
302
|
-
'stroke-linejoin': 'round',
|
|
303
|
-
}));
|
|
304
|
-
ds.values.forEach((v, i) => {
|
|
305
|
-
const cx = pad + i * step;
|
|
306
|
-
const cy = H - pad - (v / maxVal) * (H - pad * 2);
|
|
307
|
-
const circle = s('circle', {
|
|
308
|
-
cx: String(cx),
|
|
309
|
-
cy: String(cy),
|
|
310
|
-
r: '4',
|
|
311
|
-
fill: isCategoricalLine ? PAL[i % PAL.length] : col(ds, di),
|
|
312
|
-
stroke: 'var(--color-surface, white)',
|
|
313
|
-
'stroke-width': '1.5',
|
|
314
|
-
role: 'img',
|
|
315
|
-
'aria-label': `${ds.label ?? ''} ${xLabels[i] ?? i}: ${fmt.format(v)}`,
|
|
316
|
-
});
|
|
317
|
-
circle.style.cursor = 'pointer';
|
|
318
|
-
const enter = () => {
|
|
319
|
-
tooltipEl.textContent = `${ds.label ?? ''} ${xLabels[i] ?? i}: ${fmt.format(v)}`;
|
|
320
|
-
tooltipEl.style.display = '';
|
|
321
|
-
dispatchInteract(container, 'chart:hover', { series: di, index: i, label: xLabels[i], value: v });
|
|
322
|
-
};
|
|
323
|
-
const leave = () => {
|
|
324
|
-
tooltipEl.textContent = '';
|
|
325
|
-
tooltipEl.style.display = 'none';
|
|
326
|
-
};
|
|
327
|
-
const click = () => dispatchInteract(container, 'chart:click', { series: di, index: i, label: xLabels[i], value: v });
|
|
328
|
-
on(circle, 'mouseenter', enter);
|
|
329
|
-
on(circle, 'mouseleave', leave);
|
|
330
|
-
on(circle, 'click', click);
|
|
331
|
-
svg.appendChild(circle);
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
chartWrap.appendChild(svg);
|
|
335
|
-
|
|
336
|
-
const xRow = h('div', { class: 'flex gap-1' });
|
|
337
|
-
for (const lbl of xLabels) {
|
|
338
|
-
xRow.appendChild(h('div', { class: 'flex-1 text-center text-[9px] font-mono text-text2 truncate' }, [lbl]));
|
|
339
|
-
}
|
|
340
|
-
chartWrap.appendChild(xRow);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
root.appendChild(chartWrap);
|
|
344
|
-
root.appendChild(tooltipEl);
|
|
345
|
-
|
|
346
|
-
// Legend
|
|
347
|
-
if (isCategoricalLine && xLabels.length > 1) {
|
|
348
|
-
const leg = h('div', { class: 'flex gap-3 flex-wrap mt-2' });
|
|
349
|
-
xLabels.forEach((lbl, xi) => {
|
|
350
|
-
const row = h('div', { class: 'flex items-center gap-1 text-xs' });
|
|
351
|
-
row.appendChild(h('div', { class: 'w-2.5 h-2.5 rounded-full flex-shrink-0', style: `background:${PAL[xi % PAL.length]};` }));
|
|
352
|
-
row.appendChild(h('span', { class: 'text-text2' }, [lbl]));
|
|
353
|
-
leg.appendChild(row);
|
|
354
|
-
});
|
|
355
|
-
root.appendChild(leg);
|
|
356
|
-
} else if (isCategoricalBar && xLabels.length > 1) {
|
|
357
|
-
const leg = h('div', { class: 'flex gap-3 flex-wrap mt-2' });
|
|
358
|
-
xLabels.forEach((lbl, xi) => {
|
|
359
|
-
const row = h('div', { class: 'flex items-center gap-1 text-xs' });
|
|
360
|
-
row.appendChild(h('div', { class: 'w-2.5 h-2.5 rounded-sm flex-shrink-0', style: `background:${PAL[xi % PAL.length]};` }));
|
|
361
|
-
row.appendChild(h('span', { class: 'text-text2' }, [lbl]));
|
|
362
|
-
leg.appendChild(row);
|
|
363
|
-
});
|
|
364
|
-
root.appendChild(leg);
|
|
365
|
-
} else if (showLegend) {
|
|
366
|
-
const leg = h('div', { class: 'flex gap-3 flex-wrap mt-2' });
|
|
367
|
-
datasets.forEach((ds, i) => {
|
|
368
|
-
const row = h('div', { class: 'flex items-center gap-1 text-xs' });
|
|
369
|
-
row.appendChild(h('div', { class: 'w-2.5 h-2.5 rounded-sm flex-shrink-0', style: `background:${col(ds, i)};` }));
|
|
370
|
-
row.appendChild(h('span', { class: 'text-text2' }, [ds.label ?? `Series ${i + 1}`]));
|
|
371
|
-
leg.appendChild(row);
|
|
372
|
-
});
|
|
373
|
-
root.appendChild(leg);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
container.appendChild(root);
|
|
378
|
-
(container as any).__exportPng = makeExportPng(container);
|
|
379
|
-
|
|
380
|
-
return () => {
|
|
381
|
-
for (const l of listeners) l.el.removeEventListener(l.type, l.fn);
|
|
382
|
-
listeners.length = 0;
|
|
383
|
-
container.innerHTML = '';
|
|
384
|
-
delete (container as any).__exportPng;
|
|
385
|
-
};
|
|
386
|
-
}
|