@webmcp-auto-ui/ui 2.5.36 → 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.
@@ -23,15 +23,36 @@
23
23
  let sortCol = $state<string|null>(null);
24
24
  let sortAsc = $state(true);
25
25
 
26
- const rows = $derived<Record<string,unknown>[]>(
27
- Array.isArray(data?.rows) && (data!.rows as unknown[]).length
28
- ? data!.rows as Record<string,unknown>[]
29
- : []
30
- );
26
+ // ── Tolerance 1: alias `headers` → `columns` ─────────────────────────────
27
+ // Some callers pass `headers` (array of strings or column objects) instead of
28
+ // `columns`. Normalise so downstream logic only sees `columns`.
29
+ const resolvedColumnDefs = $derived.by<DataTableColumn[] | null>(() => {
30
+ const src = (data as DataTableData & { headers?: unknown }).headers ?? data?.columns;
31
+ if (!Array.isArray(src) || !src.length) return null;
32
+ // headers can be plain strings ["A","B"] or full column objects [{key,label}]
33
+ return (src as unknown[]).map((h) =>
34
+ typeof h === 'string' ? { key: h, label: h } : h as DataTableColumn
35
+ );
36
+ });
37
+
38
+ // ── Tolerance 2: rows as array-of-arrays ─────────────────────────────────
39
+ // If the first row is an array, map each row to an object using `resolvedColumnDefs`
40
+ // (falling back to positional keys "0","1",… when no column defs are available).
41
+ const rows = $derived.by<Record<string,unknown>[]>(() => {
42
+ if (!Array.isArray(data?.rows) || !(data!.rows as unknown[]).length) return [];
43
+ const raw = data!.rows as unknown[];
44
+ if (!Array.isArray(raw[0])) return raw as Record<string,unknown>[];
45
+ // array-of-arrays: convert to array-of-objects
46
+ const keys = resolvedColumnDefs?.map(c => c.key)
47
+ ?? (raw[0] as unknown[]).map((_, i) => String(i));
48
+ return (raw as unknown[][]).map(arr =>
49
+ Object.fromEntries(keys.map((k, i) => [k, arr[i]]))
50
+ );
51
+ });
31
52
 
32
53
  const columns = $derived<DataTableColumn[]>(
33
- Array.isArray(data?.columns) && (data!.columns as unknown[]).length
34
- ? data!.columns as DataTableColumn[]
54
+ resolvedColumnDefs && resolvedColumnDefs.length
55
+ ? resolvedColumnDefs
35
56
  : rows.length > 0
36
57
  ? Object.keys(rows[0] as object).map(k => ({ key: k, label: k }))
37
58
  : []
@@ -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 StatCardData {
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
- const accent = $derived(COLORS[data?.variant ?? 'default'] ?? 'var(--color-accent)');
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
- const trendInfo = $derived(() => {
29
- const t = data?.trend;
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: data?.delta ?? '', color: col };
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
- <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};">
46
- <div class="text-center py-2">
47
- <div class="text-xs font-mono text-text2 uppercase tracking-widest mb-2">{data?.label ?? ''}</div>
48
- <div class="text-3xl md:text-4xl font-bold leading-none" style="color:{accent};">
49
- {data?.value ?? '—'}{#if data?.unit}<span class="text-base text-text2 ml-1">{data.unit}</span>{/if}
50
- </div>
51
- {#if trendInfo()}
52
- {@const t = trendInfo()!}
53
- <div class="flex items-center gap-1 justify-center mt-1">
54
- <span class="text-base font-bold" style="color:{t.color};">{t.arrow}</span>
55
- {#if t.val}<span class="text-sm font-semibold" style="color:{t.color};">{t.val}</span>{/if}
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
- {/if}
58
- {#if data?.previousValue !== undefined}<div class="text-xs text-text2 mt-1">prev: {data.previousValue}</div>{/if}
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
- </div>
159
+ {/if}