kumo-svelte 0.3.0 → 0.7.0
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/ai/component-registry.json +7 -8
- package/ai/component-registry.md +1 -1
- package/dist/components/button/Button.svelte +3 -3
- package/dist/components/button/Button.svelte.d.ts +2 -2
- package/dist/components/button/button.test.js +13 -0
- package/dist/components/chart/BubbleMap.svelte +369 -0
- package/dist/components/chart/BubbleMap.svelte.d.ts +51 -0
- package/dist/components/chart/BubbleMap.test.d.ts +1 -0
- package/dist/components/chart/BubbleMap.test.js +104 -0
- package/dist/components/chart/Chart.svelte +10 -1
- package/dist/components/chart/Chart.svelte.d.ts +1 -0
- package/dist/components/chart/ChoroplethMap.svelte +321 -0
- package/dist/components/chart/ChoroplethMap.svelte.d.ts +33 -0
- package/dist/components/chart/Color.d.ts +17 -2
- package/dist/components/chart/Color.js +34 -9
- package/dist/components/chart/SankeyChart.svelte +2 -3
- package/dist/components/chart/index.d.ts +11 -7
- package/dist/components/chart/index.js +7 -5
- package/dist/components/chart/tooltip-utils.d.ts +3 -0
- package/dist/components/chart/tooltip-utils.js +8 -0
- package/dist/components/dialog/Dialog.svelte +6 -37
- package/dist/components/dialog/Dialog.svelte.d.ts +10 -39
- package/dist/components/dialog/Dialog.variants.d.ts +34 -0
- package/dist/components/dialog/Dialog.variants.js +34 -0
- package/dist/components/dialog/dialog.test.d.ts +1 -0
- package/dist/components/dialog/dialog.test.js +16 -0
- package/dist/components/dialog/index.d.ts +1 -0
- package/dist/components/dialog/index.js +1 -0
- package/dist/components/dropdown-menu/DropdownMenuContent.svelte +1 -0
- package/dist/components/input-group/InputGroup.svelte +7 -1
- package/dist/components/input-group/InputGroup.svelte.d.ts +1 -0
- package/dist/components/input-group/InputGroupInput.svelte +4 -0
- package/dist/components/input-group/InputGroupInput.svelte.d.ts +1 -0
- package/dist/components/input-group/context.d.ts +1 -0
- package/dist/components/toasty/KumoToastContent.svelte +13 -4
- package/dist/components/toasty/Toasty.svelte +4 -0
- package/dist/components/toasty/Toasty.svelte.d.ts +4 -0
- package/dist/components/toolbar/ToolbarInputGroup.svelte +1 -1
- package/dist/components/toolbar/context.js +1 -1
- package/dist/components/toolbar/toolbar.test.js +2 -1
- package/dist/docs/HomeGrid.svelte +26 -27
- package/dist/docs/demo-snippets/BubbleMapCloudflareLocationsDemo.svelte +40 -0
- package/dist/docs/demo-snippets/BubbleMapCloudflareLocationsDemo.svelte.d.ts +3 -0
- package/dist/docs/demo-snippets/BubbleMapDemo.svelte +37 -0
- package/dist/docs/demo-snippets/BubbleMapDemo.svelte.d.ts +3 -0
- package/dist/docs/demo-snippets/ChoroplethMapDemo.svelte +34 -0
- package/dist/docs/demo-snippets/ChoroplethMapDemo.svelte.d.ts +3 -0
- package/dist/docs/demo-snippets/DialogSizesDemo.svelte +65 -0
- package/dist/docs/demo-snippets/DialogSizesDemo.svelte.d.ts +3 -0
- package/dist/docs/demo-snippets/SidebarDemoShell.svelte +5 -1
- package/dist/docs/demo-snippets/map-demo-data.d.ts +25 -0
- package/dist/docs/demo-snippets/map-demo-data.js +73 -0
- package/dist/docs/nav.js +1 -0
- package/dist/docs/props-data/BubbleMap.d.ts +3 -0
- package/dist/docs/props-data/BubbleMap.js +172 -0
- package/dist/docs/props-data/ChoroplethMap.d.ts +3 -0
- package/dist/docs/props-data/ChoroplethMap.js +164 -0
- package/dist/docs/props-data/Dialog.js +1 -1
- package/dist/docs/props-data/Radio__Group.js +3 -3
- package/dist/docs/props-data/Radio__Item.js +1 -1
- package/dist/docs/props-data/TimeseriesChart.js +5 -5
- package/dist/docs/props-data/Toolbar.js +1 -1
- package/dist/index.d.ts +67 -67
- package/dist/index.js +48 -48
- package/dist/styles.css +1 -1
- package/dist/utils/cn.d.ts +2 -2
- package/dist/utils/cn.js +17 -4
- package/package.json +5 -4
- package/src/lib/components/button/Button.svelte +3 -3
- package/src/lib/components/button/button.test.ts +18 -0
- package/src/lib/components/chart/BubbleMap.svelte +369 -0
- package/src/lib/components/chart/BubbleMap.test.ts +129 -0
- package/src/lib/components/chart/Chart.svelte +10 -1
- package/src/lib/components/chart/ChoroplethMap.svelte +321 -0
- package/src/lib/components/chart/Color.ts +91 -37
- package/src/lib/components/chart/SankeyChart.svelte +2 -3
- package/src/lib/components/chart/index.ts +21 -7
- package/src/lib/components/chart/tooltip-utils.ts +10 -0
- package/src/lib/components/dialog/Dialog.svelte +6 -37
- package/src/lib/components/dialog/Dialog.variants.ts +36 -0
- package/src/lib/components/dialog/dialog.test.ts +17 -0
- package/src/lib/components/dialog/index.ts +1 -0
- package/src/lib/components/dropdown-menu/DropdownMenuContent.svelte +1 -0
- package/src/lib/components/input-group/InputGroup.svelte +7 -1
- package/src/lib/components/input-group/InputGroupInput.svelte +4 -0
- package/src/lib/components/input-group/context.ts +1 -0
- package/src/lib/components/toasty/KumoToastContent.svelte +13 -4
- package/src/lib/components/toasty/Toasty.svelte +4 -0
- package/src/lib/components/toolbar/ToolbarInputGroup.svelte +1 -1
- package/src/lib/components/toolbar/context.ts +2 -2
- package/src/lib/components/toolbar/toolbar.test.ts +2 -1
- package/src/lib/docs/HomeGrid.svelte +26 -27
- package/src/lib/docs/demo-snippets/BubbleMapCloudflareLocationsDemo.svelte +40 -0
- package/src/lib/docs/demo-snippets/BubbleMapDemo.svelte +37 -0
- package/src/lib/docs/demo-snippets/ChoroplethMapDemo.svelte +34 -0
- package/src/lib/docs/demo-snippets/DialogSizesDemo.svelte +65 -0
- package/src/lib/docs/demo-snippets/SidebarDemoShell.svelte +5 -1
- package/src/lib/docs/demo-snippets/map-demo-data.ts +103 -0
- package/src/lib/docs/nav.ts +1 -0
- package/src/lib/docs/props-data/BubbleMap.ts +181 -0
- package/src/lib/docs/props-data/ChoroplethMap.ts +173 -0
- package/src/lib/docs/props-data/Dialog.ts +1 -1
- package/src/lib/docs/props-data/Radio__Group.ts +3 -3
- package/src/lib/docs/props-data/Radio__Item.ts +1 -1
- package/src/lib/docs/props-data/TimeseriesChart.ts +5 -5
- package/src/lib/docs/props-data/Toolbar.ts +1 -1
- package/src/lib/index.ts +102 -93
- package/src/lib/styles.css +4 -0
- package/src/lib/utils/cn.ts +18 -4
- package/src/routes/charts/+page.md +24 -7
- package/src/routes/charts/maps/+page.md +188 -0
- package/src/routes/components/dialog/+page.md +13 -16
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.7.0",
|
|
3
3
|
"components": {
|
|
4
4
|
"Autocomplete": {
|
|
5
5
|
"name": "Autocomplete",
|
|
@@ -2054,7 +2054,7 @@
|
|
|
2054
2054
|
"xl"
|
|
2055
2055
|
],
|
|
2056
2056
|
"default": "base",
|
|
2057
|
-
"description": "
|
|
2057
|
+
"description": "Fixed dialog width preset: sm (288px), base (384px), lg (512px), or xl (768px)."
|
|
2058
2058
|
},
|
|
2059
2059
|
"style": {
|
|
2060
2060
|
"type": "string",
|
|
@@ -3636,7 +3636,7 @@
|
|
|
3636
3636
|
"value": {
|
|
3637
3637
|
"type": "Value",
|
|
3638
3638
|
"optional": true,
|
|
3639
|
-
"description": "Controlled value."
|
|
3639
|
+
"description": "Controlled value. Parameterized by the radio value type Value, which defaults to string."
|
|
3640
3640
|
},
|
|
3641
3641
|
"disabled": {
|
|
3642
3642
|
"type": "boolean",
|
|
@@ -3666,7 +3666,7 @@
|
|
|
3666
3666
|
"defaultValue": {
|
|
3667
3667
|
"type": "Value",
|
|
3668
3668
|
"optional": true,
|
|
3669
|
-
"description": "Initial uncontrolled value."
|
|
3669
|
+
"description": "Initial uncontrolled value. Parameterized by the radio value type Value, which defaults to string."
|
|
3670
3670
|
},
|
|
3671
3671
|
"required": {
|
|
3672
3672
|
"type": "boolean",
|
|
@@ -3676,7 +3676,7 @@
|
|
|
3676
3676
|
"onValueChange": {
|
|
3677
3677
|
"type": "(value: Value, eventDetails?: unknown) => void",
|
|
3678
3678
|
"optional": true,
|
|
3679
|
-
"description": "Called when the value changes."
|
|
3679
|
+
"description": "Called when the value changes. The second argument carries native event details about the interaction."
|
|
3680
3680
|
}
|
|
3681
3681
|
}
|
|
3682
3682
|
},
|
|
@@ -3733,7 +3733,7 @@
|
|
|
3733
3733
|
"type": "Value",
|
|
3734
3734
|
"optional": false,
|
|
3735
3735
|
"required": true,
|
|
3736
|
-
"description": "
|
|
3736
|
+
"description": "Item value. Parameterized by the radio value type Value to match the enclosing Radio.Group value type."
|
|
3737
3737
|
}
|
|
3738
3738
|
}
|
|
3739
3739
|
},
|
|
@@ -5212,7 +5212,7 @@
|
|
|
5212
5212
|
"lg"
|
|
5213
5213
|
],
|
|
5214
5214
|
"default": "base",
|
|
5215
|
-
"description": "Locks every
|
|
5215
|
+
"description": "Locks every toolbar item to this size."
|
|
5216
5216
|
},
|
|
5217
5217
|
"class": {
|
|
5218
5218
|
"type": "string",
|
|
@@ -5224,7 +5224,6 @@
|
|
|
5224
5224
|
"bg-kumo-control",
|
|
5225
5225
|
"border-kumo-line",
|
|
5226
5226
|
"ring-kumo-brand",
|
|
5227
|
-
"ring-kumo-focus",
|
|
5228
5227
|
"ring-kumo-line"
|
|
5229
5228
|
],
|
|
5230
5229
|
"subComponents": {
|
package/ai/component-registry.md
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
variant: {
|
|
47
47
|
primary: {
|
|
48
48
|
classes:
|
|
49
|
-
'relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) disabled:opacity-50',
|
|
49
|
+
'relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) focus:ring-(--kumo-button-emphasis-ring) focus-visible:ring-(--kumo-button-emphasis-ring) active:ring-(--kumo-button-emphasis-ring) disabled:opacity-50',
|
|
50
50
|
description: 'High-emphasis button for primary actions'
|
|
51
51
|
},
|
|
52
52
|
secondary: {
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
destructive: {
|
|
62
62
|
classes:
|
|
63
|
-
'relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) disabled:opacity-50',
|
|
63
|
+
'relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) focus:ring-(--kumo-button-emphasis-ring) focus-visible:ring-(--kumo-button-emphasis-ring) active:ring-(--kumo-button-emphasis-ring) disabled:opacity-50',
|
|
64
64
|
description: 'Danger button for destructive actions like delete'
|
|
65
65
|
},
|
|
66
66
|
'secondary-destructive': {
|
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
{#if emphasisToken}
|
|
191
191
|
<span
|
|
192
192
|
aria-hidden="true"
|
|
193
|
-
class="absolute inset-0
|
|
193
|
+
class="absolute inset-0 rounded-[inherit] bg-linear-to-b from-(--kumo-button-emphasis-gradient-start) to-(--kumo-button-emphasis-gradient-end) shadow-[inset_0_1px_0_0_var(--kumo-button-emphasis-bg)] group-hover:from-(--kumo-button-emphasis-bg)"
|
|
194
194
|
></span>
|
|
195
195
|
<span class="relative flex items-center gap-1.5">
|
|
196
196
|
{#if loading}
|
|
@@ -64,7 +64,7 @@ declare const Button: Component<{
|
|
|
64
64
|
};
|
|
65
65
|
readonly variant: {
|
|
66
66
|
readonly primary: {
|
|
67
|
-
readonly classes: "relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) disabled:opacity-50";
|
|
67
|
+
readonly classes: "relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) focus:ring-(--kumo-button-emphasis-ring) focus-visible:ring-(--kumo-button-emphasis-ring) active:ring-(--kumo-button-emphasis-ring) disabled:opacity-50";
|
|
68
68
|
readonly description: "High-emphasis button for primary actions";
|
|
69
69
|
};
|
|
70
70
|
readonly secondary: {
|
|
@@ -76,7 +76,7 @@ declare const Button: Component<{
|
|
|
76
76
|
readonly description: "Minimal button with no background";
|
|
77
77
|
};
|
|
78
78
|
readonly destructive: {
|
|
79
|
-
readonly classes: "relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) disabled:opacity-50";
|
|
79
|
+
readonly classes: "relative overflow-hidden bg-(--kumo-button-emphasis-bg) !text-white ring ring-(--kumo-button-emphasis-ring) focus:ring-(--kumo-button-emphasis-ring) focus-visible:ring-(--kumo-button-emphasis-ring) active:ring-(--kumo-button-emphasis-ring) disabled:opacity-50";
|
|
80
80
|
readonly description: "Danger button for destructive actions like delete";
|
|
81
81
|
};
|
|
82
82
|
readonly 'secondary-destructive': {
|
|
@@ -30,4 +30,17 @@ describe('Button', () => {
|
|
|
30
30
|
expect(button.disabled).toBe(true);
|
|
31
31
|
expect(button.getAttribute('aria-busy')).toBe('true');
|
|
32
32
|
});
|
|
33
|
+
it('keeps emphasized variant rings color-matched when pressed or focused', () => {
|
|
34
|
+
for (const variant of ['primary', 'destructive']) {
|
|
35
|
+
render(Button, {
|
|
36
|
+
variant,
|
|
37
|
+
'aria-label': variant
|
|
38
|
+
});
|
|
39
|
+
const className = screen.getByRole('button', { name: variant }).className;
|
|
40
|
+
expect(className).toContain('ring-(--kumo-button-emphasis-ring)');
|
|
41
|
+
expect(className).toContain('focus:ring-(--kumo-button-emphasis-ring)');
|
|
42
|
+
expect(className).toContain('focus-visible:ring-(--kumo-button-emphasis-ring)');
|
|
43
|
+
expect(className).toContain('active:ring-(--kumo-button-emphasis-ring)');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
33
46
|
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import type { EChartsType } from 'echarts/core';
|
|
4
|
+
import { geoMercator } from 'd3-geo';
|
|
5
|
+
import Chart, { type ChartEvents, type KumoChartOption } from './Chart.svelte';
|
|
6
|
+
import { ChartPalette } from './Color';
|
|
7
|
+
import { defaultValueFormat, escapeHtml } from './tooltip-utils';
|
|
8
|
+
|
|
9
|
+
export interface MapGeoJson {
|
|
10
|
+
type: 'FeatureCollection';
|
|
11
|
+
features: Array<{
|
|
12
|
+
type: 'Feature';
|
|
13
|
+
id?: string | number;
|
|
14
|
+
properties?: Record<string, unknown> | null;
|
|
15
|
+
geometry: unknown;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Accessor for a value on a data row: a key of `T`, or a function of the row. */
|
|
20
|
+
export type MapAccessor<T, V> = keyof T | ((row: T) => V);
|
|
21
|
+
|
|
22
|
+
function resolve<T, V>(row: T, accessor: MapAccessor<T, V>): V {
|
|
23
|
+
return typeof accessor === 'function' ? accessor(row) : (row[accessor] as V);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Per-datum style value: a constant, or a function of the row. */
|
|
27
|
+
export type MapStyle<T, V> = V | ((row: T) => V);
|
|
28
|
+
|
|
29
|
+
function resolveStyle<T, V>(row: T, style: MapStyle<T, V>): V {
|
|
30
|
+
return typeof style === 'function' ? (style as (row: T) => V)(row) : (style as V);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_ZOOM_FACTOR = 8;
|
|
34
|
+
const MERCATOR_MAX_LAT = 85.0511;
|
|
35
|
+
const DEFAULT_BOUNDING_COORDS: [[number, number], [number, number]] = [
|
|
36
|
+
[-180, 80],
|
|
37
|
+
[180, -58]
|
|
38
|
+
];
|
|
39
|
+
const geoJsonMapNames = new WeakMap<MapGeoJson, string>();
|
|
40
|
+
const mercatorProjection = geoMercator();
|
|
41
|
+
|
|
42
|
+
export interface MapProjection {
|
|
43
|
+
project: (point: number[]) => number[];
|
|
44
|
+
unproject: (point: number[]) => number[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_PROJECTION: MapProjection = {
|
|
48
|
+
project: (point) => {
|
|
49
|
+
const lat = Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, point[1]));
|
|
50
|
+
return mercatorProjection([point[0], lat]) ?? [0, 0];
|
|
51
|
+
},
|
|
52
|
+
unproject: (point) => mercatorProjection.invert?.(point as [number, number]) ?? [0, 0]
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function resolveProjection(projection: MapProjection | null | undefined): MapProjection | undefined {
|
|
56
|
+
if (projection === null) return undefined;
|
|
57
|
+
return projection ?? DEFAULT_PROJECTION;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function projectedAspect(
|
|
61
|
+
projection: MapProjection | undefined,
|
|
62
|
+
[[west, north], [east, south]]: [[number, number], [number, number]]
|
|
63
|
+
): number {
|
|
64
|
+
const project = projection ? projection.project : (point: number[]) => point;
|
|
65
|
+
const midLat = Math.min(north, Math.max(south, 0));
|
|
66
|
+
const width = Math.abs(project([east, midLat])[0] - project([west, midLat])[0]);
|
|
67
|
+
const height = Math.abs(project([0, north])[1] - project([0, south])[1]);
|
|
68
|
+
return width > 0 && height > 0 ? width / height : 16 / 9;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sanitizeMapName(name: string) {
|
|
72
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hashString(value: string) {
|
|
76
|
+
let hash = 0;
|
|
77
|
+
for (let i = 0; i < value.length; i++) {
|
|
78
|
+
hash = Math.imul(31, hash) + value.charCodeAt(i);
|
|
79
|
+
}
|
|
80
|
+
return (hash >>> 0).toString(36);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getMapName(geoJson: MapGeoJson, mapName?: string) {
|
|
84
|
+
if (mapName) return sanitizeMapName(mapName);
|
|
85
|
+
|
|
86
|
+
const existing = geoJsonMapNames.get(geoJson);
|
|
87
|
+
if (existing) return existing;
|
|
88
|
+
|
|
89
|
+
const generated = `kumo-map-${hashString(JSON.stringify(geoJson))}`;
|
|
90
|
+
geoJsonMapNames.set(geoJson, generated);
|
|
91
|
+
return generated;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildGeo({
|
|
95
|
+
mapName,
|
|
96
|
+
areaColor,
|
|
97
|
+
center,
|
|
98
|
+
zoom,
|
|
99
|
+
roam,
|
|
100
|
+
projection
|
|
101
|
+
}: {
|
|
102
|
+
mapName: string;
|
|
103
|
+
areaColor: string;
|
|
104
|
+
center?: [number, number];
|
|
105
|
+
zoom: number;
|
|
106
|
+
roam: boolean;
|
|
107
|
+
projection?: MapProjection;
|
|
108
|
+
}) {
|
|
109
|
+
return {
|
|
110
|
+
map: mapName,
|
|
111
|
+
nameProperty: 'name',
|
|
112
|
+
roam,
|
|
113
|
+
...(roam
|
|
114
|
+
? {
|
|
115
|
+
scaleLimit: {
|
|
116
|
+
min: Math.min(1, zoom),
|
|
117
|
+
max: zoom * MAX_ZOOM_FACTOR
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
center,
|
|
122
|
+
zoom,
|
|
123
|
+
boundingCoords: DEFAULT_BOUNDING_COORDS,
|
|
124
|
+
...(projection ? { projection } : { aspectScale: 1 }),
|
|
125
|
+
silent: true,
|
|
126
|
+
itemStyle: {
|
|
127
|
+
areaColor,
|
|
128
|
+
borderColor: areaColor,
|
|
129
|
+
borderWidth: 0.5
|
|
130
|
+
},
|
|
131
|
+
emphasis: { disabled: true }
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface BubbleMapProps<T = Record<string, unknown>> {
|
|
136
|
+
echarts: any;
|
|
137
|
+
geoJson: MapGeoJson;
|
|
138
|
+
mapName?: string;
|
|
139
|
+
data: T[];
|
|
140
|
+
lng: MapAccessor<T, number>;
|
|
141
|
+
lat: MapAccessor<T, number>;
|
|
142
|
+
value: MapAccessor<T, number>;
|
|
143
|
+
name?: MapAccessor<T, string>;
|
|
144
|
+
minRadius?: number;
|
|
145
|
+
maxRadius?: number;
|
|
146
|
+
bubbleSize?: (value: number) => number;
|
|
147
|
+
bubbleColor?: MapStyle<T, string>;
|
|
148
|
+
bubbleBorderColor?: MapStyle<T, string>;
|
|
149
|
+
bubbleBorderWidth?: MapStyle<T, number>;
|
|
150
|
+
center?: [number, number];
|
|
151
|
+
zoom?: number;
|
|
152
|
+
roam?: boolean;
|
|
153
|
+
projection?: MapProjection | null;
|
|
154
|
+
showTooltip?: boolean;
|
|
155
|
+
valueFormat?: (value: number) => string;
|
|
156
|
+
tooltipFormatter?: (row: T) => string;
|
|
157
|
+
onBubbleHover?: (row: T | undefined) => void;
|
|
158
|
+
onBubbleClick?: (row: T) => void;
|
|
159
|
+
aspectRatio?: number | string;
|
|
160
|
+
height?: number;
|
|
161
|
+
class?: string;
|
|
162
|
+
isDarkMode?: boolean;
|
|
163
|
+
chartRef?: EChartsType | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface BubblePoint<T> {
|
|
167
|
+
name?: string;
|
|
168
|
+
value: [number, number, number];
|
|
169
|
+
symbolSize: number;
|
|
170
|
+
itemStyle: { color: string; borderColor: string; borderWidth: number };
|
|
171
|
+
datum: T;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let {
|
|
175
|
+
echarts,
|
|
176
|
+
geoJson,
|
|
177
|
+
mapName: mapNameProp,
|
|
178
|
+
data,
|
|
179
|
+
lng,
|
|
180
|
+
lat,
|
|
181
|
+
value,
|
|
182
|
+
name,
|
|
183
|
+
minRadius = 6,
|
|
184
|
+
maxRadius = 26,
|
|
185
|
+
bubbleSize,
|
|
186
|
+
bubbleColor,
|
|
187
|
+
bubbleBorderColor = 'transparent',
|
|
188
|
+
bubbleBorderWidth = 0,
|
|
189
|
+
center,
|
|
190
|
+
zoom = 1.25,
|
|
191
|
+
roam = false,
|
|
192
|
+
projection,
|
|
193
|
+
showTooltip = true,
|
|
194
|
+
valueFormat = defaultValueFormat,
|
|
195
|
+
tooltipFormatter,
|
|
196
|
+
onBubbleHover,
|
|
197
|
+
onBubbleClick,
|
|
198
|
+
aspectRatio,
|
|
199
|
+
height,
|
|
200
|
+
class: className,
|
|
201
|
+
isDarkMode,
|
|
202
|
+
chartRef = $bindable(null)
|
|
203
|
+
}: BubbleMapProps = $props();
|
|
204
|
+
|
|
205
|
+
let detectedDarkMode = $state(false);
|
|
206
|
+
const effectiveDarkMode = $derived(isDarkMode ?? detectedDarkMode);
|
|
207
|
+
const mapName = $derived(getMapName(geoJson, mapNameProp));
|
|
208
|
+
|
|
209
|
+
onMount(() => {
|
|
210
|
+
const updateDetectedDarkMode = () => {
|
|
211
|
+
const root = document.documentElement;
|
|
212
|
+
const mode = root.dataset.mode;
|
|
213
|
+
if (mode === 'dark') {
|
|
214
|
+
detectedDarkMode = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (mode === 'light') {
|
|
218
|
+
detectedDarkMode = false;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (root.classList.contains('dark')) {
|
|
222
|
+
detectedDarkMode = true;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (root.classList.contains('light')) {
|
|
226
|
+
detectedDarkMode = false;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
detectedDarkMode = window.matchMedia?.('(prefers-color-scheme: dark)').matches === true;
|
|
230
|
+
};
|
|
231
|
+
const themeObserver = new MutationObserver(updateDetectedDarkMode);
|
|
232
|
+
updateDetectedDarkMode();
|
|
233
|
+
[document.documentElement, document.body].forEach((node) => {
|
|
234
|
+
themeObserver.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
|
|
235
|
+
});
|
|
236
|
+
const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
|
|
237
|
+
mediaQuery?.addEventListener('change', updateDetectedDarkMode);
|
|
238
|
+
|
|
239
|
+
return () => {
|
|
240
|
+
themeObserver.disconnect();
|
|
241
|
+
mediaQuery?.removeEventListener('change', updateDetectedDarkMode);
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
$effect.pre(() => {
|
|
246
|
+
// ECharts requires GeoJSON maps to be registered before setOption uses them.
|
|
247
|
+
echarts.registerMap(mapName, geoJson);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const resolvedProjection = $derived(resolveProjection(projection));
|
|
251
|
+
const resolvedAspectRatio = $derived(
|
|
252
|
+
height === undefined ? (aspectRatio ?? projectedAspect(resolvedProjection, DEFAULT_BOUNDING_COORDS)) : undefined
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const options = $derived.by(() => {
|
|
256
|
+
const palette = ChartPalette.mapColors(effectiveDarkMode);
|
|
257
|
+
const values = data.map((row) => resolve(row, value));
|
|
258
|
+
const vmin = values.length ? Math.min(...values) : 0;
|
|
259
|
+
const vmax = values.length ? Math.max(...values) : 1;
|
|
260
|
+
|
|
261
|
+
const radiusFor = (datumValue: number) => {
|
|
262
|
+
if (bubbleSize) return bubbleSize(datumValue);
|
|
263
|
+
if (vmax <= 0) return minRadius;
|
|
264
|
+
const ratio = Math.sqrt(Math.max(0, datumValue) / vmax);
|
|
265
|
+
return minRadius + ratio * (maxRadius - minRadius);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const points: BubblePoint<Record<string, unknown>>[] = data.map((row) => {
|
|
269
|
+
const datumValue = resolve(row, value);
|
|
270
|
+
return {
|
|
271
|
+
name: name ? resolve(row, name) : undefined,
|
|
272
|
+
value: [resolve(row, lng), resolve(row, lat), datumValue],
|
|
273
|
+
symbolSize: radiusFor(datumValue),
|
|
274
|
+
itemStyle: {
|
|
275
|
+
color: bubbleColor ? resolveStyle(row, bubbleColor) : palette.bubble,
|
|
276
|
+
borderColor: resolveStyle(row, bubbleBorderColor),
|
|
277
|
+
borderWidth: resolveStyle(row, bubbleBorderWidth)
|
|
278
|
+
},
|
|
279
|
+
datum: row
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
backgroundColor: 'transparent',
|
|
285
|
+
animation: true,
|
|
286
|
+
animationDuration: 500,
|
|
287
|
+
animationDurationUpdate: 0,
|
|
288
|
+
geo: buildGeo({
|
|
289
|
+
mapName,
|
|
290
|
+
areaColor: palette.area,
|
|
291
|
+
center,
|
|
292
|
+
zoom,
|
|
293
|
+
roam,
|
|
294
|
+
projection: resolvedProjection
|
|
295
|
+
}),
|
|
296
|
+
tooltip: showTooltip
|
|
297
|
+
? {
|
|
298
|
+
trigger: 'item',
|
|
299
|
+
triggerOn: 'mousemove',
|
|
300
|
+
backgroundColor: 'var(--color-kumo-base)',
|
|
301
|
+
borderColor: 'var(--color-kumo-line)',
|
|
302
|
+
borderWidth: 1,
|
|
303
|
+
padding: 8,
|
|
304
|
+
textStyle: {
|
|
305
|
+
color: 'var(--text-color-kumo-default)',
|
|
306
|
+
fontSize: 13
|
|
307
|
+
},
|
|
308
|
+
extraCssText: 'border-radius: 0.5rem;',
|
|
309
|
+
dangerousHtmlFormatter: (params: unknown) => {
|
|
310
|
+
const param = params as { name?: string; value?: number[]; data?: { datum?: Record<string, unknown> } };
|
|
311
|
+
const row = param.data?.datum;
|
|
312
|
+
if (tooltipFormatter && row !== undefined) return tooltipFormatter(row);
|
|
313
|
+
|
|
314
|
+
const tooltipValue = param.value?.[2];
|
|
315
|
+
const nameStr = param.name ? `<strong>${escapeHtml(param.name)}</strong>` : '';
|
|
316
|
+
const valueStr =
|
|
317
|
+
tooltipValue !== undefined
|
|
318
|
+
? `<span style="color:var(--text-color-kumo-subtle)">${escapeHtml(valueFormat(tooltipValue))}</span>`
|
|
319
|
+
: '';
|
|
320
|
+
return `<div style="display:flex;flex-direction:column;gap:2px;">${nameStr}${valueStr}</div>`;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
: undefined,
|
|
324
|
+
series: [
|
|
325
|
+
{
|
|
326
|
+
id: 'bubbles',
|
|
327
|
+
type: 'scatter',
|
|
328
|
+
coordinateSystem: 'geo',
|
|
329
|
+
data: points,
|
|
330
|
+
itemStyle: { opacity: 0.8 },
|
|
331
|
+
emphasis: { scale: 1.2, itemStyle: { opacity: 1 } },
|
|
332
|
+
z: 3
|
|
333
|
+
}
|
|
334
|
+
]
|
|
335
|
+
} satisfies KumoChartOption;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const events = $derived<Partial<ChartEvents>>({
|
|
339
|
+
...(onBubbleHover
|
|
340
|
+
? {
|
|
341
|
+
mouseover: (params: any) => {
|
|
342
|
+
const datum = params.data?.datum;
|
|
343
|
+
if (datum !== undefined) onBubbleHover?.(datum);
|
|
344
|
+
},
|
|
345
|
+
mouseout: () => onBubbleHover?.(undefined),
|
|
346
|
+
globalout: () => onBubbleHover?.(undefined)
|
|
347
|
+
}
|
|
348
|
+
: {}),
|
|
349
|
+
...(onBubbleClick
|
|
350
|
+
? {
|
|
351
|
+
click: (params: any) => {
|
|
352
|
+
const datum = params.data?.datum;
|
|
353
|
+
if (datum !== undefined) onBubbleClick?.(datum);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
: {})
|
|
357
|
+
});
|
|
358
|
+
</script>
|
|
359
|
+
|
|
360
|
+
<Chart
|
|
361
|
+
{echarts}
|
|
362
|
+
options={options}
|
|
363
|
+
bind:chartRef
|
|
364
|
+
{height}
|
|
365
|
+
aspectRatio={resolvedAspectRatio}
|
|
366
|
+
class={className}
|
|
367
|
+
isDarkMode={effectiveDarkMode}
|
|
368
|
+
onEvents={events}
|
|
369
|
+
/>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { EChartsType } from 'echarts/core';
|
|
2
|
+
export interface MapGeoJson {
|
|
3
|
+
type: 'FeatureCollection';
|
|
4
|
+
features: Array<{
|
|
5
|
+
type: 'Feature';
|
|
6
|
+
id?: string | number;
|
|
7
|
+
properties?: Record<string, unknown> | null;
|
|
8
|
+
geometry: unknown;
|
|
9
|
+
}>;
|
|
10
|
+
}
|
|
11
|
+
/** Accessor for a value on a data row: a key of `T`, or a function of the row. */
|
|
12
|
+
export type MapAccessor<T, V> = keyof T | ((row: T) => V);
|
|
13
|
+
/** Per-datum style value: a constant, or a function of the row. */
|
|
14
|
+
export type MapStyle<T, V> = V | ((row: T) => V);
|
|
15
|
+
export interface MapProjection {
|
|
16
|
+
project: (point: number[]) => number[];
|
|
17
|
+
unproject: (point: number[]) => number[];
|
|
18
|
+
}
|
|
19
|
+
export interface BubbleMapProps<T = Record<string, unknown>> {
|
|
20
|
+
echarts: any;
|
|
21
|
+
geoJson: MapGeoJson;
|
|
22
|
+
mapName?: string;
|
|
23
|
+
data: T[];
|
|
24
|
+
lng: MapAccessor<T, number>;
|
|
25
|
+
lat: MapAccessor<T, number>;
|
|
26
|
+
value: MapAccessor<T, number>;
|
|
27
|
+
name?: MapAccessor<T, string>;
|
|
28
|
+
minRadius?: number;
|
|
29
|
+
maxRadius?: number;
|
|
30
|
+
bubbleSize?: (value: number) => number;
|
|
31
|
+
bubbleColor?: MapStyle<T, string>;
|
|
32
|
+
bubbleBorderColor?: MapStyle<T, string>;
|
|
33
|
+
bubbleBorderWidth?: MapStyle<T, number>;
|
|
34
|
+
center?: [number, number];
|
|
35
|
+
zoom?: number;
|
|
36
|
+
roam?: boolean;
|
|
37
|
+
projection?: MapProjection | null;
|
|
38
|
+
showTooltip?: boolean;
|
|
39
|
+
valueFormat?: (value: number) => string;
|
|
40
|
+
tooltipFormatter?: (row: T) => string;
|
|
41
|
+
onBubbleHover?: (row: T | undefined) => void;
|
|
42
|
+
onBubbleClick?: (row: T) => void;
|
|
43
|
+
aspectRatio?: number | string;
|
|
44
|
+
height?: number;
|
|
45
|
+
class?: string;
|
|
46
|
+
isDarkMode?: boolean;
|
|
47
|
+
chartRef?: EChartsType | null;
|
|
48
|
+
}
|
|
49
|
+
declare const BubbleMap: import("svelte").Component<BubbleMapProps<Record<string, unknown>>, {}, "chartRef">;
|
|
50
|
+
type BubbleMap = ReturnType<typeof BubbleMap>;
|
|
51
|
+
export default BubbleMap;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { render, waitFor } from '@testing-library/svelte';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import BubbleMap, {} from './BubbleMap.svelte';
|
|
5
|
+
const geoJson = {
|
|
6
|
+
type: 'FeatureCollection',
|
|
7
|
+
features: [
|
|
8
|
+
{
|
|
9
|
+
type: 'Feature',
|
|
10
|
+
id: 'US',
|
|
11
|
+
properties: { name: 'United States' },
|
|
12
|
+
geometry: { type: 'Polygon', coordinates: [] }
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
};
|
|
16
|
+
const data = [
|
|
17
|
+
{ city: 'San Francisco', lat: 37.77, lon: -122.42, requests: 10 },
|
|
18
|
+
{ city: 'London', lat: 51.5, lon: -0.12, requests: 20 }
|
|
19
|
+
];
|
|
20
|
+
function createMockChart() {
|
|
21
|
+
return {
|
|
22
|
+
setOption: vi.fn(),
|
|
23
|
+
on: vi.fn(),
|
|
24
|
+
off: vi.fn(),
|
|
25
|
+
resize: vi.fn(),
|
|
26
|
+
dispose: vi.fn()
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function createMockEcharts(mockChart = createMockChart()) {
|
|
30
|
+
return {
|
|
31
|
+
init: vi.fn(() => mockChart),
|
|
32
|
+
registerMap: vi.fn()
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
class ResizeObserver {
|
|
37
|
+
observe = vi.fn();
|
|
38
|
+
disconnect = vi.fn();
|
|
39
|
+
}
|
|
40
|
+
vi.stubGlobal('ResizeObserver', ResizeObserver);
|
|
41
|
+
vi.stubGlobal('matchMedia', vi.fn(() => ({
|
|
42
|
+
addEventListener: vi.fn(),
|
|
43
|
+
removeEventListener: vi.fn(),
|
|
44
|
+
matches: false
|
|
45
|
+
})));
|
|
46
|
+
});
|
|
47
|
+
describe('BubbleMap', () => {
|
|
48
|
+
it('reuses the generated map name across remounts for the same GeoJSON', () => {
|
|
49
|
+
const mockEcharts = createMockEcharts();
|
|
50
|
+
const first = render(BubbleMap, {
|
|
51
|
+
echarts: mockEcharts,
|
|
52
|
+
geoJson,
|
|
53
|
+
data,
|
|
54
|
+
lng: 'lon',
|
|
55
|
+
lat: 'lat',
|
|
56
|
+
name: 'city',
|
|
57
|
+
value: 'requests'
|
|
58
|
+
});
|
|
59
|
+
first.unmount();
|
|
60
|
+
render(BubbleMap, {
|
|
61
|
+
echarts: mockEcharts,
|
|
62
|
+
geoJson,
|
|
63
|
+
data,
|
|
64
|
+
lng: 'lon',
|
|
65
|
+
lat: 'lat',
|
|
66
|
+
name: 'city',
|
|
67
|
+
value: 'requests'
|
|
68
|
+
});
|
|
69
|
+
expect(mockEcharts.registerMap).toHaveBeenCalledTimes(2);
|
|
70
|
+
expect(mockEcharts.registerMap.mock.calls[0][0]).toBe(mockEcharts.registerMap.mock.calls[1][0]);
|
|
71
|
+
});
|
|
72
|
+
it('sanitizes custom map names before registering them', () => {
|
|
73
|
+
const mockEcharts = createMockEcharts();
|
|
74
|
+
render(BubbleMap, {
|
|
75
|
+
echarts: mockEcharts,
|
|
76
|
+
geoJson,
|
|
77
|
+
mapName: 'world:traffic/map',
|
|
78
|
+
data,
|
|
79
|
+
lng: 'lon',
|
|
80
|
+
lat: 'lat',
|
|
81
|
+
name: 'city',
|
|
82
|
+
value: 'requests'
|
|
83
|
+
});
|
|
84
|
+
expect(mockEcharts.registerMap).toHaveBeenCalledWith('world-traffic-map', geoJson);
|
|
85
|
+
});
|
|
86
|
+
it('uses bubbleSize when provided', async () => {
|
|
87
|
+
const mockChart = createMockChart();
|
|
88
|
+
const mockEcharts = createMockEcharts(mockChart);
|
|
89
|
+
render(BubbleMap, {
|
|
90
|
+
echarts: mockEcharts,
|
|
91
|
+
geoJson,
|
|
92
|
+
data,
|
|
93
|
+
lng: 'lon',
|
|
94
|
+
lat: 'lat',
|
|
95
|
+
name: 'city',
|
|
96
|
+
value: 'requests',
|
|
97
|
+
bubbleSize: (value) => value / 2
|
|
98
|
+
});
|
|
99
|
+
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
|
|
100
|
+
const options = mockChart.setOption.mock.calls[0][0];
|
|
101
|
+
expect(options.series[0].data[0].symbolSize).toBe(5);
|
|
102
|
+
expect(options.series[0].data[1].symbolSize).toBe(10);
|
|
103
|
+
});
|
|
104
|
+
});
|