@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.
@@ -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}