@webmcp-auto-ui/ui 2.5.37 → 2.5.38
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 +3 -1
- package/src/base/chat-inline.svelte +81 -9
- package/src/widgets/WidgetRenderer.svelte +6 -5
- package/src/widgets/notebook/left-pane.ts +20 -0
- package/src/widgets/notebook/notebook.ts +432 -44
- package/src/widgets/notebook/resource-extractor.ts +16 -1
- package/src/widgets/notebook/share-handlers.ts +20 -1
- package/src/widgets/notebook/shared.ts +185 -25
- package/src/widgets/rich/cards.svelte +3 -1
- package/src/widgets/rich/chart-rich.svelte +73 -7
- package/src/widgets/rich/data-table.svelte +28 -7
- package/src/widgets/rich/map.svelte +392 -0
- package/src/widgets/rich/stat-card.svelte +119 -20
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
<svelte:options customElement={{ tag: 'auto-map', shadow: 'none' }} />
|
|
2
|
+
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import { onMount, onDestroy } from 'svelte';
|
|
5
|
+
|
|
6
|
+
export interface MapMarker {
|
|
7
|
+
lat: number;
|
|
8
|
+
lon: number;
|
|
9
|
+
label?: string;
|
|
10
|
+
color?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TileLayer {
|
|
14
|
+
name?: string;
|
|
15
|
+
url: string;
|
|
16
|
+
opacity?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MapData {
|
|
20
|
+
markers?: MapMarker[];
|
|
21
|
+
geojson?: GeoJSON.FeatureCollection | GeoJSON.Feature | null;
|
|
22
|
+
center?: [number, number] | { lat: number; lon?: number; lng?: number };
|
|
23
|
+
zoom?: number;
|
|
24
|
+
height?: string;
|
|
25
|
+
cluster?: boolean;
|
|
26
|
+
tileLayers?: TileLayer[];
|
|
27
|
+
title?: unknown;
|
|
28
|
+
popup?: unknown;
|
|
29
|
+
radius?: unknown;
|
|
30
|
+
color_field?: unknown;
|
|
31
|
+
color_scale?: unknown;
|
|
32
|
+
}
|
|
33
|
+
interface Props { data?: MapData | null; }
|
|
34
|
+
|
|
35
|
+
let { data = {} }: Props = $props();
|
|
36
|
+
|
|
37
|
+
const d: MapData = data ?? {};
|
|
38
|
+
const rawMarkers = d.markers ?? [];
|
|
39
|
+
const rawGeojson = d.geojson ?? null;
|
|
40
|
+
const rawCenter = d.center;
|
|
41
|
+
const rawZoom = d.zoom;
|
|
42
|
+
const rawHeight = d.height ?? '400px';
|
|
43
|
+
const rawCluster = d.cluster ?? false;
|
|
44
|
+
const rawTileLayers = d.tileLayers ?? [];
|
|
45
|
+
|
|
46
|
+
const IGNORED_AT_INIT: Array<[string, unknown]> = [
|
|
47
|
+
['title', d.title],
|
|
48
|
+
['popup', d.popup],
|
|
49
|
+
['radius', d.radius],
|
|
50
|
+
['color_field', d.color_field],
|
|
51
|
+
['color_scale', d.color_scale],
|
|
52
|
+
];
|
|
53
|
+
for (const [key, val] of IGNORED_AT_INIT) {
|
|
54
|
+
if (val !== undefined) {
|
|
55
|
+
// eslint-disable-next-line no-console
|
|
56
|
+
console.warn('[auto-map] unknown prop ignored:', key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeMarker(raw: unknown): MapMarker | null {
|
|
61
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
62
|
+
const o = raw as Record<string, unknown>;
|
|
63
|
+
const lat = o.lat;
|
|
64
|
+
const lon = o.lon ?? o.lng;
|
|
65
|
+
if (typeof lat !== 'number' || !Number.isFinite(lat)) return null;
|
|
66
|
+
if (typeof lon !== 'number' || !Number.isFinite(lon)) return null;
|
|
67
|
+
const label = (typeof o.label === 'string' ? o.label : undefined) ??
|
|
68
|
+
(typeof o.popup === 'string' ? o.popup : undefined);
|
|
69
|
+
const color = typeof o.color === 'string' ? o.color : undefined;
|
|
70
|
+
return { lat, lon, label, color };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeCenter(raw: unknown): [number, number] | undefined {
|
|
74
|
+
if (raw == null) return undefined;
|
|
75
|
+
if (Array.isArray(raw) && raw.length >= 2 &&
|
|
76
|
+
typeof raw[0] === 'number' && Number.isFinite(raw[0]) &&
|
|
77
|
+
typeof raw[1] === 'number' && Number.isFinite(raw[1])) {
|
|
78
|
+
return [raw[0], raw[1]];
|
|
79
|
+
}
|
|
80
|
+
if (typeof raw === 'object') {
|
|
81
|
+
const o = raw as Record<string, unknown>;
|
|
82
|
+
const lat = o.lat;
|
|
83
|
+
const lon = o.lon ?? o.lng;
|
|
84
|
+
if (typeof lat === 'number' && typeof lon === 'number' &&
|
|
85
|
+
Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
86
|
+
return [lon, lat];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const markers: MapMarker[] = Array.isArray(rawMarkers)
|
|
93
|
+
? (rawMarkers as unknown[]).map(normalizeMarker).filter((m): m is MapMarker => m !== null)
|
|
94
|
+
: [];
|
|
95
|
+
const geojson = rawGeojson ?? null;
|
|
96
|
+
const center = normalizeCenter(rawCenter);
|
|
97
|
+
const zoom = rawZoom;
|
|
98
|
+
const height = rawHeight;
|
|
99
|
+
const cluster = rawCluster === true;
|
|
100
|
+
const tileLayers: TileLayer[] = Array.isArray(rawTileLayers)
|
|
101
|
+
? (rawTileLayers as unknown[]).flatMap((t) => {
|
|
102
|
+
if (!t || typeof t !== 'object') return [];
|
|
103
|
+
const o = t as Record<string, unknown>;
|
|
104
|
+
if (typeof o.url !== 'string') return [];
|
|
105
|
+
return [{
|
|
106
|
+
url: o.url,
|
|
107
|
+
name: typeof o.name === 'string' ? o.name : undefined,
|
|
108
|
+
opacity: typeof o.opacity === 'number' ? o.opacity : undefined,
|
|
109
|
+
}];
|
|
110
|
+
})
|
|
111
|
+
: [];
|
|
112
|
+
|
|
113
|
+
/** Light, free MapLibre style (Carto Positron, no API key). */
|
|
114
|
+
const STYLE_URL = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
|
|
115
|
+
|
|
116
|
+
let containerEl: HTMLDivElement | undefined = $state();
|
|
117
|
+
let map: any = null;
|
|
118
|
+
let mountedMarkers: any[] = [];
|
|
119
|
+
let destroyed = false;
|
|
120
|
+
|
|
121
|
+
function isFiniteNum(v: unknown): v is number {
|
|
122
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const safeMarkers = markers;
|
|
126
|
+
const hasData = safeMarkers.length > 0 || !!geojson || tileLayers.length > 0;
|
|
127
|
+
|
|
128
|
+
function buildPointsCollection(ms: MapMarker[]): GeoJSON.FeatureCollection {
|
|
129
|
+
return {
|
|
130
|
+
type: 'FeatureCollection',
|
|
131
|
+
features: ms.map((m) => ({
|
|
132
|
+
type: 'Feature',
|
|
133
|
+
geometry: { type: 'Point', coordinates: [m.lon, m.lat] },
|
|
134
|
+
properties: { label: m.label ?? '', color: m.color ?? '' },
|
|
135
|
+
})),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
onMount(async () => {
|
|
140
|
+
if (typeof window === 'undefined' || !containerEl || !hasData) return;
|
|
141
|
+
// Dynamic imports avoid SSR issues and let Vite split out the heavy bundle.
|
|
142
|
+
const [{ default: maplibregl }, turf] = await Promise.all([
|
|
143
|
+
import('maplibre-gl'),
|
|
144
|
+
import('@turf/turf'),
|
|
145
|
+
]);
|
|
146
|
+
// Inject CSS once at module load time (string import lands in <head> via bundler)
|
|
147
|
+
await import('maplibre-gl/dist/maplibre-gl.css');
|
|
148
|
+
|
|
149
|
+
if (destroyed || !containerEl) return;
|
|
150
|
+
|
|
151
|
+
// Build a unified FeatureCollection for bbox/centroid math
|
|
152
|
+
const features: GeoJSON.Feature[] = [];
|
|
153
|
+
if (safeMarkers.length) {
|
|
154
|
+
features.push(...buildPointsCollection(safeMarkers).features);
|
|
155
|
+
}
|
|
156
|
+
if (geojson) {
|
|
157
|
+
if ((geojson as GeoJSON.FeatureCollection).type === 'FeatureCollection') {
|
|
158
|
+
features.push(...((geojson as GeoJSON.FeatureCollection).features ?? []));
|
|
159
|
+
} else {
|
|
160
|
+
features.push(geojson as GeoJSON.Feature);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features };
|
|
164
|
+
|
|
165
|
+
// Resolve initial center/zoom
|
|
166
|
+
let initialCenter: [number, number] = [0, 20];
|
|
167
|
+
let initialZoom = 1;
|
|
168
|
+
let bbox: [number, number, number, number] | null = null;
|
|
169
|
+
|
|
170
|
+
if (center && Array.isArray(center) && isFiniteNum(center[0]) && isFiniteNum(center[1])) {
|
|
171
|
+
initialCenter = [center[0], center[1]];
|
|
172
|
+
} else if (features.length > 0) {
|
|
173
|
+
try {
|
|
174
|
+
bbox = (turf as any).bbox(fc) as [number, number, number, number];
|
|
175
|
+
const [minX, minY, maxX, maxY] = bbox;
|
|
176
|
+
if ([minX, minY, maxX, maxY].every(isFiniteNum)) {
|
|
177
|
+
initialCenter = [(minX + maxX) / 2, (minY + maxY) / 2];
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// ignore — fall back to default center
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (isFiniteNum(zoom)) {
|
|
184
|
+
initialZoom = zoom as number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
map = new maplibregl.Map({
|
|
188
|
+
container: containerEl,
|
|
189
|
+
style: STYLE_URL,
|
|
190
|
+
center: initialCenter,
|
|
191
|
+
zoom: initialZoom,
|
|
192
|
+
attributionControl: { compact: true },
|
|
193
|
+
});
|
|
194
|
+
map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), 'top-right');
|
|
195
|
+
|
|
196
|
+
// Auto-fit if no explicit center/zoom and we have a bbox
|
|
197
|
+
if (!center && !isFiniteNum(zoom) && bbox) {
|
|
198
|
+
try {
|
|
199
|
+
map.fitBounds(
|
|
200
|
+
[[bbox[0], bbox[1]], [bbox[2], bbox[3]]],
|
|
201
|
+
{ padding: 40, animate: false, maxZoom: 14 },
|
|
202
|
+
);
|
|
203
|
+
} catch {
|
|
204
|
+
// ignore
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
map.on('load', () => {
|
|
209
|
+
if (destroyed) return;
|
|
210
|
+
|
|
211
|
+
// Raster tile overlays (e.g. NASA GIBS)
|
|
212
|
+
for (let i = 0; i < tileLayers.length; i++) {
|
|
213
|
+
const tl = tileLayers[i];
|
|
214
|
+
const sourceId = `auto-map-tile-src-${i}`;
|
|
215
|
+
const layerId = `auto-map-tile-layer-${i}`;
|
|
216
|
+
try {
|
|
217
|
+
map.addSource(sourceId, {
|
|
218
|
+
type: 'raster',
|
|
219
|
+
tiles: [tl.url],
|
|
220
|
+
tileSize: 256,
|
|
221
|
+
});
|
|
222
|
+
map.addLayer({
|
|
223
|
+
id: layerId,
|
|
224
|
+
type: 'raster',
|
|
225
|
+
source: sourceId,
|
|
226
|
+
paint: { 'raster-opacity': typeof tl.opacity === 'number' ? tl.opacity : 1 },
|
|
227
|
+
});
|
|
228
|
+
} catch {
|
|
229
|
+
// ignore — invalid tile url should not crash the widget
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (cluster && safeMarkers.length > 0) {
|
|
234
|
+
// Clustered markers via maplibre native cluster API
|
|
235
|
+
try {
|
|
236
|
+
map.addSource('auto-map-cluster-src', {
|
|
237
|
+
type: 'geojson',
|
|
238
|
+
data: buildPointsCollection(safeMarkers) as any,
|
|
239
|
+
cluster: true,
|
|
240
|
+
clusterRadius: 50,
|
|
241
|
+
clusterMaxZoom: 14,
|
|
242
|
+
});
|
|
243
|
+
map.addLayer({
|
|
244
|
+
id: 'auto-map-clusters',
|
|
245
|
+
type: 'circle',
|
|
246
|
+
source: 'auto-map-cluster-src',
|
|
247
|
+
filter: ['has', 'point_count'],
|
|
248
|
+
paint: {
|
|
249
|
+
'circle-color': [
|
|
250
|
+
'step', ['get', 'point_count'],
|
|
251
|
+
'#93c5fd', 10, '#60a5fa', 50, '#3b82f6',
|
|
252
|
+
],
|
|
253
|
+
'circle-radius': [
|
|
254
|
+
'step', ['get', 'point_count'],
|
|
255
|
+
15, 10, 20, 50, 25,
|
|
256
|
+
],
|
|
257
|
+
'circle-stroke-color': '#ffffff',
|
|
258
|
+
'circle-stroke-width': 2,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
map.addLayer({
|
|
262
|
+
id: 'auto-map-cluster-count',
|
|
263
|
+
type: 'symbol',
|
|
264
|
+
source: 'auto-map-cluster-src',
|
|
265
|
+
filter: ['has', 'point_count'],
|
|
266
|
+
layout: {
|
|
267
|
+
'text-field': ['get', 'point_count_abbreviated'],
|
|
268
|
+
'text-size': 12,
|
|
269
|
+
},
|
|
270
|
+
paint: { 'text-color': '#ffffff' },
|
|
271
|
+
});
|
|
272
|
+
map.addLayer({
|
|
273
|
+
id: 'auto-map-cluster-points',
|
|
274
|
+
type: 'circle',
|
|
275
|
+
source: 'auto-map-cluster-src',
|
|
276
|
+
filter: ['!', ['has', 'point_count']],
|
|
277
|
+
paint: {
|
|
278
|
+
'circle-color': '#3b82f6',
|
|
279
|
+
'circle-radius': 6,
|
|
280
|
+
'circle-stroke-color': '#ffffff',
|
|
281
|
+
'circle-stroke-width': 1.5,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
// Click on cluster → zoom in
|
|
285
|
+
map.on('click', 'auto-map-clusters', (e: any) => {
|
|
286
|
+
const features = map.queryRenderedFeatures(e.point, { layers: ['auto-map-clusters'] });
|
|
287
|
+
const clusterId = features[0]?.properties?.cluster_id;
|
|
288
|
+
const src = map.getSource('auto-map-cluster-src') as any;
|
|
289
|
+
if (clusterId != null && src?.getClusterExpansionZoom) {
|
|
290
|
+
src.getClusterExpansionZoom(clusterId, (err: any, zoomLevel: number) => {
|
|
291
|
+
if (err) return;
|
|
292
|
+
map.easeTo({ center: features[0].geometry.coordinates, zoom: zoomLevel });
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
// Click on individual point → popup
|
|
297
|
+
map.on('click', 'auto-map-cluster-points', (e: any) => {
|
|
298
|
+
const f = e.features?.[0];
|
|
299
|
+
if (!f) return;
|
|
300
|
+
const label = f.properties?.label;
|
|
301
|
+
if (label) {
|
|
302
|
+
new maplibregl.Popup({ offset: 12 })
|
|
303
|
+
.setLngLat(f.geometry.coordinates)
|
|
304
|
+
.setText(label)
|
|
305
|
+
.addTo(map);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
} catch {
|
|
309
|
+
// ignore
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
// Markers — native MapLibre markers (one per point)
|
|
313
|
+
for (const m of safeMarkers) {
|
|
314
|
+
const marker = new maplibregl.Marker({ color: m.color ?? '#3b82f6' })
|
|
315
|
+
.setLngLat([m.lon, m.lat]);
|
|
316
|
+
if (m.label) {
|
|
317
|
+
marker.setPopup(new maplibregl.Popup({ offset: 16 }).setText(m.label));
|
|
318
|
+
}
|
|
319
|
+
marker.addTo(map);
|
|
320
|
+
mountedMarkers.push(marker);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// GeoJSON layer (lines, polygons, extra points)
|
|
325
|
+
if (geojson) {
|
|
326
|
+
try {
|
|
327
|
+
map.addSource('auto-map-geojson', { type: 'geojson', data: geojson as any });
|
|
328
|
+
map.addLayer({
|
|
329
|
+
id: 'auto-map-fill',
|
|
330
|
+
type: 'fill',
|
|
331
|
+
source: 'auto-map-geojson',
|
|
332
|
+
filter: ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']],
|
|
333
|
+
paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.25 },
|
|
334
|
+
});
|
|
335
|
+
map.addLayer({
|
|
336
|
+
id: 'auto-map-line',
|
|
337
|
+
type: 'line',
|
|
338
|
+
source: 'auto-map-geojson',
|
|
339
|
+
filter: ['any',
|
|
340
|
+
['==', ['geometry-type'], 'LineString'],
|
|
341
|
+
['==', ['geometry-type'], 'MultiLineString'],
|
|
342
|
+
['==', ['geometry-type'], 'Polygon'],
|
|
343
|
+
['==', ['geometry-type'], 'MultiPolygon'],
|
|
344
|
+
],
|
|
345
|
+
paint: { 'line-color': '#1d4ed8', 'line-width': 2 },
|
|
346
|
+
});
|
|
347
|
+
map.addLayer({
|
|
348
|
+
id: 'auto-map-circle',
|
|
349
|
+
type: 'circle',
|
|
350
|
+
source: 'auto-map-geojson',
|
|
351
|
+
filter: ['==', ['geometry-type'], 'Point'],
|
|
352
|
+
paint: {
|
|
353
|
+
'circle-radius': 5,
|
|
354
|
+
'circle-color': '#3b82f6',
|
|
355
|
+
'circle-stroke-color': '#ffffff',
|
|
356
|
+
'circle-stroke-width': 1.5,
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
} catch {
|
|
360
|
+
// ignore — invalid geojson should not crash the widget
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
onDestroy(() => {
|
|
367
|
+
destroyed = true;
|
|
368
|
+
for (const mk of mountedMarkers) {
|
|
369
|
+
try { mk.remove(); } catch { /* noop */ }
|
|
370
|
+
}
|
|
371
|
+
mountedMarkers = [];
|
|
372
|
+
if (map) {
|
|
373
|
+
try { map.remove(); } catch { /* noop */ }
|
|
374
|
+
map = null;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
</script>
|
|
378
|
+
|
|
379
|
+
<div class="bg-surface border border-border rounded-lg overflow-hidden font-sans">
|
|
380
|
+
{#if !hasData}
|
|
381
|
+
<div class="p-4 text-text2 text-sm">(no data)</div>
|
|
382
|
+
{:else}
|
|
383
|
+
<div bind:this={containerEl} style="width: 100%; height: {height};"></div>
|
|
384
|
+
{/if}
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
<style>
|
|
388
|
+
/* Ensure MapLibre canvas fills its container even when light theme overrides body */
|
|
389
|
+
div :global(.maplibregl-map) {
|
|
390
|
+
font-family: inherit;
|
|
391
|
+
}
|
|
392
|
+
</style>
|
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
<script lang="ts">
|
|
4
4
|
export interface StatCardTrend { direction: 'up' | 'down' | 'flat'; value?: string; positive?: boolean; }
|
|
5
|
-
export interface
|
|
5
|
+
export interface StatCardItem {
|
|
6
6
|
label?: string;
|
|
7
7
|
value?: unknown;
|
|
8
8
|
unit?: string;
|
|
9
|
+
icon?: string;
|
|
9
10
|
delta?: string;
|
|
10
11
|
trend?: 'up' | 'down' | 'flat' | StatCardTrend;
|
|
11
12
|
previousValue?: unknown;
|
|
12
13
|
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
|
|
13
14
|
}
|
|
15
|
+
export interface StatCardData extends StatCardItem {
|
|
16
|
+
/** Grid mode: render N cards */
|
|
17
|
+
items?: StatCardItem[];
|
|
18
|
+
}
|
|
14
19
|
|
|
15
20
|
interface Props { data?: StatCardData | null; }
|
|
16
21
|
let { data = {} }: Props = $props();
|
|
@@ -23,15 +28,71 @@
|
|
|
23
28
|
info: '#3b82f6',
|
|
24
29
|
};
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
// Icon resolution: map common string identifiers → emoji/unicode.
|
|
32
|
+
// Unknown icons fall back silently to ℹ (info).
|
|
33
|
+
const ICON_MAP: Record<string, string> = {
|
|
34
|
+
info: 'ℹ',
|
|
35
|
+
check: '✓',
|
|
36
|
+
checkmark: '✓',
|
|
37
|
+
warning: '⚠',
|
|
38
|
+
warn: '⚠',
|
|
39
|
+
error: '✕',
|
|
40
|
+
close: '✕',
|
|
41
|
+
up: '↑',
|
|
42
|
+
'trending-up': '↑',
|
|
43
|
+
trending_up: '↑',
|
|
44
|
+
down: '↓',
|
|
45
|
+
'trending-down':'↓',
|
|
46
|
+
trending_down: '↓',
|
|
47
|
+
flat: '→',
|
|
48
|
+
star: '★',
|
|
49
|
+
heart: '♥',
|
|
50
|
+
fire: '🔥',
|
|
51
|
+
lightning: '⚡',
|
|
52
|
+
clock: '⏱',
|
|
53
|
+
calendar: '📅',
|
|
54
|
+
user: '👤',
|
|
55
|
+
users: '👥',
|
|
56
|
+
money: '💰',
|
|
57
|
+
dollar: '$',
|
|
58
|
+
euro: '€',
|
|
59
|
+
percent: '%',
|
|
60
|
+
chart: '📊',
|
|
61
|
+
globe: '🌐',
|
|
62
|
+
lock: '🔒',
|
|
63
|
+
key: '🔑',
|
|
64
|
+
mail: '✉',
|
|
65
|
+
phone: '📞',
|
|
66
|
+
pin: '📍',
|
|
67
|
+
tag: '🏷',
|
|
68
|
+
flag: '🚩',
|
|
69
|
+
box: '📦',
|
|
70
|
+
cpu: '💻',
|
|
71
|
+
database: '🗄',
|
|
72
|
+
cloud: '☁',
|
|
73
|
+
signal: '📶',
|
|
74
|
+
speed: '⚡',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function resolveIcon(icon: string | undefined): string | null {
|
|
78
|
+
if (!icon) return null;
|
|
79
|
+
// If it looks like an emoji or unicode symbol (not a plain ascii identifier), use as-is.
|
|
80
|
+
if (/\p{Emoji}/u.test(icon) && !/^[a-z0-9_-]+$/i.test(icon)) return icon;
|
|
81
|
+
// Try direct lookup, then lowercased.
|
|
82
|
+
return ICON_MAP[icon] ?? ICON_MAP[icon.toLowerCase()] ?? 'ℹ';
|
|
83
|
+
}
|
|
27
84
|
|
|
28
|
-
|
|
29
|
-
|
|
85
|
+
function accentFor(variant: string | undefined): string {
|
|
86
|
+
return COLORS[variant ?? 'default'] ?? 'var(--color-accent)';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function trendInfoFor(item: StatCardItem): { arrow: string; val: string; color: string } | null {
|
|
90
|
+
const t = item.trend;
|
|
30
91
|
if (!t) return null;
|
|
31
92
|
if (typeof t === 'string') {
|
|
32
93
|
const arr = t === 'up' ? '↑' : t === 'down' ? '↓' : '→';
|
|
33
94
|
const col = t === 'up' ? 'var(--color-teal)' : t === 'flat' ? 'var(--color-text2)' : 'var(--color-accent2)';
|
|
34
|
-
return { arrow: arr, val:
|
|
95
|
+
return { arrow: arr, val: item.delta ?? '', color: col };
|
|
35
96
|
}
|
|
36
97
|
const positive = t.positive ?? t.direction === 'up';
|
|
37
98
|
return {
|
|
@@ -39,22 +100,60 @@
|
|
|
39
100
|
val: t.value ?? '',
|
|
40
101
|
color: positive ? 'var(--color-teal)' : t.direction === 'flat' ? 'var(--color-text2)' : 'var(--color-accent2)',
|
|
41
102
|
};
|
|
42
|
-
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Determine render mode
|
|
106
|
+
const isGrid = $derived(Array.isArray(data?.items) && (data!.items as StatCardItem[]).length > 0);
|
|
107
|
+
const gridItems = $derived(isGrid ? (data!.items as StatCardItem[]) : []);
|
|
108
|
+
|
|
109
|
+
// Single-card derived values
|
|
110
|
+
const accent = $derived(accentFor(data?.variant));
|
|
111
|
+
const trendInfo = $derived(() => trendInfoFor(data ?? {}));
|
|
112
|
+
const iconGlyph = $derived(resolveIcon(data?.icon));
|
|
43
113
|
</script>
|
|
44
114
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
115
|
+
{#if isGrid}
|
|
116
|
+
<!-- Grid mode: N stat-cards -->
|
|
117
|
+
<div class="font-sans grid" style="grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem;">
|
|
118
|
+
{#each gridItems as item}
|
|
119
|
+
{@const a = accentFor(item.variant)}
|
|
120
|
+
{@const ti = trendInfoFor(item)}
|
|
121
|
+
{@const ig = resolveIcon(item.icon)}
|
|
122
|
+
<div class="bg-surface border border-border rounded-lg p-3 min-w-[120px]" style="border-top: 3px solid {a};">
|
|
123
|
+
<div class="text-center py-1">
|
|
124
|
+
{#if ig}<div class="text-xl mb-1" style="color:{a};">{ig}</div>{/if}
|
|
125
|
+
<div class="text-xs font-mono text-text2 uppercase tracking-widest mb-1">{item.label ?? ''}</div>
|
|
126
|
+
<div class="text-2xl md:text-3xl font-bold leading-none" style="color:{a};">
|
|
127
|
+
{item.value ?? '—'}{#if item.unit}<span class="text-sm text-text2 ml-1">{item.unit}</span>{/if}
|
|
128
|
+
</div>
|
|
129
|
+
{#if ti}
|
|
130
|
+
<div class="flex items-center gap-1 justify-center mt-1">
|
|
131
|
+
<span class="text-sm font-bold" style="color:{ti.color};">{ti.arrow}</span>
|
|
132
|
+
{#if ti.val}<span class="text-xs font-semibold" style="color:{ti.color};">{ti.val}</span>{/if}
|
|
133
|
+
</div>
|
|
134
|
+
{/if}
|
|
135
|
+
{#if item.previousValue !== undefined}<div class="text-xs text-text2 mt-1">prev: {item.previousValue}</div>{/if}
|
|
136
|
+
</div>
|
|
56
137
|
</div>
|
|
57
|
-
{/
|
|
58
|
-
|
|
138
|
+
{/each}
|
|
139
|
+
</div>
|
|
140
|
+
{:else}
|
|
141
|
+
<!-- Single card mode (classic) -->
|
|
142
|
+
<div class="bg-surface border border-border rounded-lg p-3 md:p-4 font-sans min-w-[140px] md:min-w-[160px]" style="border-top: 3px solid {accent};">
|
|
143
|
+
<div class="text-center py-2">
|
|
144
|
+
{#if iconGlyph}<div class="text-2xl mb-1" style="color:{accent};">{iconGlyph}</div>{/if}
|
|
145
|
+
<div class="text-xs font-mono text-text2 uppercase tracking-widest mb-2">{data?.label ?? ''}</div>
|
|
146
|
+
<div class="text-3xl md:text-4xl font-bold leading-none" style="color:{accent};">
|
|
147
|
+
{data?.value ?? '—'}{#if data?.unit}<span class="text-base text-text2 ml-1">{data.unit}</span>{/if}
|
|
148
|
+
</div>
|
|
149
|
+
{#if trendInfo()}
|
|
150
|
+
{@const t = trendInfo()!}
|
|
151
|
+
<div class="flex items-center gap-1 justify-center mt-1">
|
|
152
|
+
<span class="text-base font-bold" style="color:{t.color};">{t.arrow}</span>
|
|
153
|
+
{#if t.val}<span class="text-sm font-semibold" style="color:{t.color};">{t.val}</span>{/if}
|
|
154
|
+
</div>
|
|
155
|
+
{/if}
|
|
156
|
+
{#if data?.previousValue !== undefined}<div class="text-xs text-text2 mt-1">prev: {data.previousValue}</div>{/if}
|
|
157
|
+
</div>
|
|
59
158
|
</div>
|
|
60
|
-
|
|
159
|
+
{/if}
|