@seed-ship/mcp-ui-solid 6.8.2 → 6.9.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/CHANGELOG.md +34 -0
- package/dist/components/ChartJSRenderer.cjs +27 -13
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +28 -14
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/DegradedFallback.cjs +73 -0
- package/dist/components/DegradedFallback.cjs.map +1 -0
- package/dist/components/DegradedFallback.d.ts +37 -0
- package/dist/components/DegradedFallback.d.ts.map +1 -0
- package/dist/components/DegradedFallback.js +73 -0
- package/dist/components/DegradedFallback.js.map +1 -0
- package/dist/components/GraphRenderer.cjs +30 -15
- package/dist/components/GraphRenderer.cjs.map +1 -1
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js +31 -16
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/components/MapRenderer.cjs +128 -107
- package/dist/components/MapRenderer.cjs.map +1 -1
- package/dist/components/MapRenderer.d.ts.map +1 -1
- package/dist/components/MapRenderer.js +129 -108
- package/dist/components/MapRenderer.js.map +1 -1
- package/dist/index.cjs +4 -4
- package/dist/index.js +1 -1
- package/dist/utils/degraded-projections.cjs +87 -0
- package/dist/utils/degraded-projections.cjs.map +1 -0
- package/dist/utils/degraded-projections.d.ts +64 -0
- package/dist/utils/degraded-projections.d.ts.map +1 -0
- package/dist/utils/degraded-projections.js +87 -0
- package/dist/utils/degraded-projections.js.map +1 -0
- package/package.json +1 -1
- package/src/components/ChartJSRenderer.tsx +94 -85
- package/src/components/DegradedFallback.test.tsx +61 -0
- package/src/components/DegradedFallback.tsx +93 -0
- package/src/components/GraphRenderer.tsx +26 -4
- package/src/components/MapRenderer.tsx +446 -392
- package/src/utils/degraded-projections.test.ts +113 -0
- package/src/utils/degraded-projections.ts +149 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -4,32 +4,43 @@
|
|
|
4
4
|
* v3.1.0: GeoJSON, choropleth, popups, multi-layer, PMTiles
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { Component, createEffect, onCleanup, createSignal, Show } from 'solid-js'
|
|
8
|
-
import { isServer } from 'solid-js/web'
|
|
9
|
-
import type {
|
|
10
|
-
|
|
7
|
+
import { Component, createEffect, onCleanup, createSignal, Show } from 'solid-js';
|
|
8
|
+
import { isServer } from 'solid-js/web';
|
|
9
|
+
import type {
|
|
10
|
+
UIComponent,
|
|
11
|
+
MapComponentParams,
|
|
12
|
+
MapClusterOptions,
|
|
13
|
+
MapGeoJSONStyle,
|
|
14
|
+
MapPopupConfig,
|
|
15
|
+
MapLayer,
|
|
16
|
+
MapPMTilesConfig,
|
|
17
|
+
} from '../types';
|
|
18
|
+
import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
|
|
19
|
+
import { DegradedFallback } from './DegradedFallback';
|
|
20
|
+
import { mapToDegradedTable } from '../utils/degraded-projections';
|
|
21
|
+
import { useTelemetry } from '../context/MCPUITelemetryContext';
|
|
11
22
|
|
|
12
23
|
// Lazy load leaflet (it doesn't support SSR well)
|
|
13
|
-
let L: any = null
|
|
24
|
+
let L: any = null;
|
|
14
25
|
// Track if marker cluster CSS has been loaded
|
|
15
|
-
let clusterCssLoaded = false
|
|
26
|
+
let clusterCssLoaded = false;
|
|
16
27
|
|
|
17
28
|
export interface MapRendererProps {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
/**
|
|
30
|
+
* UIComponent containing map params
|
|
31
|
+
*/
|
|
32
|
+
component?: UIComponent;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Direct map params
|
|
36
|
+
*/
|
|
37
|
+
params?: MapComponentParams;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Forwarded to the underlying `<ExpandableWrapper>` (v6.3.1).
|
|
41
|
+
* @see ExpandableWrapperProps.toolbarVariant
|
|
42
|
+
*/
|
|
43
|
+
toolbarVariant?: 'hover' | 'always-visible';
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
// ─── Helpers ────────────────────────────────────────────────
|
|
@@ -38,97 +49,105 @@ export interface MapRendererProps {
|
|
|
38
49
|
* Resolve choropleth color for a feature based on property value and scale stops.
|
|
39
50
|
*/
|
|
40
51
|
function getChoroplethColor(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
value: unknown,
|
|
53
|
+
scale: Array<[number, string]>,
|
|
54
|
+
fallback: string
|
|
44
55
|
): string {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
if (value == null || typeof value !== 'number' || !isFinite(value)) return fallback;
|
|
57
|
+
|
|
58
|
+
// Scale is sorted ascending: [[0, '#eff3ff'], [100, '#084594']]
|
|
59
|
+
if (scale.length === 0) return fallback;
|
|
60
|
+
if (value <= scale[0][0]) return scale[0][1];
|
|
61
|
+
if (value >= scale[scale.length - 1][0]) return scale[scale.length - 1][1];
|
|
62
|
+
|
|
63
|
+
// Find surrounding stops and interpolate (use upper bracket color)
|
|
64
|
+
for (let i = 1; i < scale.length; i++) {
|
|
65
|
+
if (value <= scale[i][0]) return scale[i][1];
|
|
66
|
+
}
|
|
67
|
+
return scale[scale.length - 1][1];
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
/**
|
|
60
71
|
* Build a Leaflet style function from MapGeoJSONStyle config.
|
|
61
72
|
*/
|
|
62
|
-
function buildStyleFn(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
function buildStyleFn(
|
|
74
|
+
style: MapGeoJSONStyle | undefined
|
|
75
|
+
): (feature: any) => Record<string, unknown> {
|
|
76
|
+
if (!style) {
|
|
77
|
+
return () => ({
|
|
78
|
+
fillColor: '#3388ff',
|
|
79
|
+
fillOpacity: 0.6,
|
|
80
|
+
color: '#333',
|
|
81
|
+
weight: 1,
|
|
82
|
+
opacity: 1,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (feature: any) => {
|
|
87
|
+
let fillColor = style.fillColor || '#3388ff';
|
|
88
|
+
|
|
89
|
+
// Choropleth: override fillColor based on feature property
|
|
90
|
+
if (style.choroplethField && style.choroplethScale && feature?.properties) {
|
|
91
|
+
const val = feature.properties[style.choroplethField];
|
|
92
|
+
fillColor = getChoroplethColor(
|
|
93
|
+
val,
|
|
94
|
+
style.choroplethScale,
|
|
95
|
+
style.choroplethFallback || '#ccc'
|
|
96
|
+
);
|
|
71
97
|
}
|
|
72
98
|
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
fillColor,
|
|
84
|
-
fillOpacity: style.fillOpacity ?? 0.6,
|
|
85
|
-
color: style.strokeColor || '#333',
|
|
86
|
-
weight: style.strokeWeight ?? 1,
|
|
87
|
-
opacity: style.strokeOpacity ?? 1,
|
|
88
|
-
}
|
|
89
|
-
}
|
|
99
|
+
return {
|
|
100
|
+
fillColor,
|
|
101
|
+
fillOpacity: style.fillOpacity ?? 0.6,
|
|
102
|
+
color: style.strokeColor || '#333',
|
|
103
|
+
weight: style.strokeWeight ?? 1,
|
|
104
|
+
opacity: style.strokeOpacity ?? 1,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
/**
|
|
93
110
|
* Build popup HTML from a feature's properties using popup config.
|
|
94
111
|
*/
|
|
95
112
|
function buildPopupContent(feature: any, popup: MapPopupConfig | undefined): string | null {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
if (!popup || !feature?.properties) return null;
|
|
114
|
+
const props = feature.properties;
|
|
115
|
+
|
|
116
|
+
// Custom template
|
|
117
|
+
if (popup.template) {
|
|
118
|
+
return popup.template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
119
|
+
const val = props[key];
|
|
120
|
+
return val != null ? String(val) : '';
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Auto-generated popup
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
|
|
127
|
+
if (popup.titleField && props[popup.titleField] != null) {
|
|
128
|
+
parts.push(`<strong>${escapeHtml(String(props[popup.titleField]))}</strong>`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fields = popup.fields || Object.keys(props).slice(0, 8);
|
|
132
|
+
for (const key of fields) {
|
|
133
|
+
if (key === popup.titleField) continue;
|
|
134
|
+
const val = props[key];
|
|
135
|
+
if (val == null) continue;
|
|
136
|
+
const formatted = typeof val === 'number' ? val.toLocaleString('fr-FR') : String(val);
|
|
137
|
+
parts.push(
|
|
138
|
+
`<span style="color:#666;font-size:11px">${escapeHtml(key)}</span>: ${escapeHtml(formatted)}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return parts.join('<br/>');
|
|
124
143
|
}
|
|
125
144
|
|
|
126
145
|
function escapeHtml(str: string): string {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
146
|
+
return str
|
|
147
|
+
.replace(/&/g, '&')
|
|
148
|
+
.replace(/</g, '<')
|
|
149
|
+
.replace(/>/g, '>')
|
|
150
|
+
.replace(/"/g, '"');
|
|
132
151
|
}
|
|
133
152
|
|
|
134
153
|
/**
|
|
@@ -136,38 +155,38 @@ function escapeHtml(str: string): string {
|
|
|
136
155
|
* Returns the layer for bounds calculation.
|
|
137
156
|
*/
|
|
138
157
|
function addGeoJSONLayer(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
158
|
+
mapInst: any,
|
|
159
|
+
leaflet: any,
|
|
160
|
+
geojson: unknown,
|
|
161
|
+
style?: MapGeoJSONStyle,
|
|
162
|
+
popup?: MapPopupConfig
|
|
144
163
|
): any {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
164
|
+
const styleFn = buildStyleFn(style);
|
|
165
|
+
|
|
166
|
+
const layer = leaflet.geoJSON(geojson, {
|
|
167
|
+
style: styleFn,
|
|
168
|
+
pointToLayer: (feature: any, latlng: any) => {
|
|
169
|
+
// Render points as circle markers for consistency
|
|
170
|
+
const s = styleFn(feature);
|
|
171
|
+
return leaflet.circleMarker(latlng, {
|
|
172
|
+
radius: 6,
|
|
173
|
+
fillColor: s.fillColor,
|
|
174
|
+
fillOpacity: s.fillOpacity,
|
|
175
|
+
color: s.color,
|
|
176
|
+
weight: s.weight,
|
|
177
|
+
opacity: s.opacity,
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
onEachFeature: (feature: any, featureLayer: any) => {
|
|
181
|
+
const html = buildPopupContent(feature, popup);
|
|
182
|
+
if (html) {
|
|
183
|
+
featureLayer.bindPopup(html, { maxWidth: 300 });
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
layer.addTo(mapInst);
|
|
189
|
+
return layer;
|
|
171
190
|
}
|
|
172
191
|
|
|
173
192
|
// ─── Component ──────────────────────────────────────────────
|
|
@@ -179,285 +198,320 @@ function addGeoJSONLayer(
|
|
|
179
198
|
* tile layers, and choropleth-only data don't get round-tripped.
|
|
180
199
|
*/
|
|
181
200
|
function mapToGeoJSON(p: MapComponentParams | undefined): string {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
if (!p) return '{"type":"FeatureCollection","features":[]}';
|
|
202
|
+
const features: any[] = [];
|
|
203
|
+
for (const marker of p.markers ?? []) {
|
|
204
|
+
const pos: any = marker.position as any;
|
|
205
|
+
// Accept both [lat, lng] tuple and {lat, lng} object shapes (v5.0.2 spec)
|
|
206
|
+
const lat = Array.isArray(pos) ? pos[0] : pos?.lat;
|
|
207
|
+
const lng = Array.isArray(pos) ? pos[1] : pos?.lng;
|
|
208
|
+
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
|
|
209
|
+
features.push({
|
|
210
|
+
type: 'Feature',
|
|
211
|
+
geometry: { type: 'Point', coordinates: [lng, lat] },
|
|
212
|
+
properties: {
|
|
213
|
+
...(marker.tooltip ? { tooltip: marker.tooltip } : {}),
|
|
214
|
+
...(marker.popup ? { popup: marker.popup } : {}),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return JSON.stringify({ type: 'FeatureCollection', features }, null, 2);
|
|
200
219
|
}
|
|
201
220
|
|
|
202
221
|
export const MapRenderer: Component<MapRendererProps> = (props) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
222
|
+
let mapContainer: HTMLDivElement | undefined;
|
|
223
|
+
let mapInstance: any = null;
|
|
224
|
+
const [isLeafletLoaded, setIsLeafletLoaded] = createSignal(false);
|
|
225
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
226
|
+
const isExpanded = useExpanded();
|
|
227
|
+
const telemetry = useTelemetry();
|
|
228
|
+
|
|
229
|
+
const params = () => props.params || (props.component?.params as MapComponentParams);
|
|
230
|
+
|
|
231
|
+
// v6.2.0 — Leaflet has to be told to re-measure when its container
|
|
232
|
+
// resizes (e.g. transitioning to fullscreen via ExpandableWrapper).
|
|
233
|
+
// We give the DOM a tick to settle the new dimensions, then ask
|
|
234
|
+
// Leaflet to reflow tiles.
|
|
235
|
+
createEffect(() => {
|
|
236
|
+
const expanded = isExpanded();
|
|
237
|
+
if (!mapInstance) return;
|
|
238
|
+
// Read the signal so the effect re-runs on toggle ; the value is
|
|
239
|
+
// observed for its side effects on layout.
|
|
240
|
+
void expanded;
|
|
241
|
+
setTimeout(() => mapInstance?.invalidateSize?.(), 100);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Initialize Map
|
|
245
|
+
createEffect(async () => {
|
|
246
|
+
if (isServer) return; // Don't run on server
|
|
247
|
+
|
|
248
|
+
if (!L) {
|
|
249
|
+
try {
|
|
250
|
+
const module = await import('leaflet');
|
|
251
|
+
L = module.default || module;
|
|
252
|
+
await import('leaflet/dist/leaflet.css'); // Import CSS
|
|
253
|
+
setIsLeafletLoaded(true);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.warn('Failed to load leaflet', e);
|
|
256
|
+
setError('Map library could not be loaded.');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
setIsLeafletLoaded(true);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (isLeafletLoaded() && mapContainer && !mapInstance) {
|
|
264
|
+
const p = params();
|
|
265
|
+
const center = p?.center || [51.505, -0.09]; // Default to London
|
|
266
|
+
const zoom = p?.zoom || 13;
|
|
267
|
+
|
|
268
|
+
mapInstance = L.map(mapContainer, {
|
|
269
|
+
zoomControl: p?.zoomControl !== false,
|
|
270
|
+
scrollWheelZoom: p?.scrollWheelZoom !== false,
|
|
271
|
+
attributionControl: false,
|
|
272
|
+
}).setView(center, zoom);
|
|
273
|
+
|
|
274
|
+
// Add OpenStreetMap tile layer
|
|
275
|
+
const tileLayerUrl = p?.tileLayer || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
276
|
+
L.tileLayer(tileLayerUrl, {
|
|
277
|
+
attribution:
|
|
278
|
+
p?.attribution ||
|
|
279
|
+
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
280
|
+
}).addTo(mapInstance);
|
|
281
|
+
|
|
282
|
+
if (p?.attribution !== '') {
|
|
283
|
+
L.control.attribution({ prefix: false }).addTo(mapInstance);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Fix marker icons (Leaflet issue with bundlers)
|
|
287
|
+
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
|
288
|
+
L.Icon.Default.mergeOptions({
|
|
289
|
+
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
|
290
|
+
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
|
291
|
+
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Update markers and view
|
|
296
|
+
if (mapInstance && L) {
|
|
297
|
+
try {
|
|
298
|
+
const p = params();
|
|
299
|
+
const allBoundsLayers: any[] = [];
|
|
300
|
+
|
|
301
|
+
// Clear existing layers (markers, cluster groups, GeoJSON)
|
|
302
|
+
mapInstance.eachLayer((layer: any) => {
|
|
303
|
+
if (
|
|
304
|
+
layer instanceof L.Marker ||
|
|
305
|
+
layer instanceof L.GeoJSON ||
|
|
306
|
+
layer instanceof L.CircleMarker ||
|
|
307
|
+
layer._group ||
|
|
308
|
+
layer._featureGroup
|
|
309
|
+
) {
|
|
310
|
+
mapInstance.removeLayer(layer);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ─── Markers (legacy) ────────────────────────
|
|
315
|
+
const markers: any[] = [];
|
|
316
|
+
const shouldCluster = p?.clustering && p?.markers && p.markers.length > 0;
|
|
317
|
+
|
|
318
|
+
if (shouldCluster) {
|
|
319
|
+
try {
|
|
320
|
+
await import('leaflet.markercluster');
|
|
321
|
+
if (!clusterCssLoaded) {
|
|
322
|
+
await import('leaflet.markercluster/dist/MarkerCluster.css');
|
|
323
|
+
await import('leaflet.markercluster/dist/MarkerCluster.Default.css');
|
|
324
|
+
clusterCssLoaded = true;
|
|
238
325
|
}
|
|
326
|
+
const clusterOpts: MapClusterOptions =
|
|
327
|
+
typeof p.clustering === 'object' ? p.clustering : {};
|
|
328
|
+
const clusterGroup = (L as any).markerClusterGroup({
|
|
329
|
+
maxClusterRadius: clusterOpts.maxClusterRadius ?? 80,
|
|
330
|
+
spiderfyOnMaxZoom: clusterOpts.spiderfyOnMaxZoom ?? true,
|
|
331
|
+
showCoverageOnHover: clusterOpts.showCoverageOnHover ?? true,
|
|
332
|
+
disableClusteringAtZoom: clusterOpts.disableClusteringAtZoom,
|
|
333
|
+
animate: clusterOpts.animateAddingMarkers ?? true,
|
|
334
|
+
});
|
|
335
|
+
p?.markers?.forEach((marker) => {
|
|
336
|
+
const m = L.marker(marker.position);
|
|
337
|
+
if (marker.tooltip) m.bindTooltip(marker.tooltip);
|
|
338
|
+
if (marker.popup) m.bindPopup(marker.popup);
|
|
339
|
+
clusterGroup.addLayer(m);
|
|
340
|
+
markers.push(m);
|
|
341
|
+
});
|
|
342
|
+
mapInstance.addLayer(clusterGroup);
|
|
343
|
+
} catch {
|
|
344
|
+
p?.markers?.forEach((marker) => {
|
|
345
|
+
const m = L.marker(marker.position).addTo(mapInstance);
|
|
346
|
+
if (marker.tooltip) m.bindTooltip(marker.tooltip);
|
|
347
|
+
if (marker.popup) m.bindPopup(marker.popup);
|
|
348
|
+
markers.push(m);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
239
351
|
} else {
|
|
240
|
-
|
|
352
|
+
p?.markers?.forEach((marker) => {
|
|
353
|
+
const m = L.marker(marker.position).addTo(mapInstance);
|
|
354
|
+
if (marker.tooltip) m.bindTooltip(marker.tooltip);
|
|
355
|
+
if (marker.popup) m.bindPopup(marker.popup);
|
|
356
|
+
markers.push(m);
|
|
357
|
+
});
|
|
241
358
|
}
|
|
242
359
|
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
const center = p?.center || [51.505, -0.09] // Default to London
|
|
246
|
-
const zoom = p?.zoom || 13
|
|
247
|
-
|
|
248
|
-
mapInstance = L.map(mapContainer, {
|
|
249
|
-
zoomControl: p?.zoomControl !== false,
|
|
250
|
-
scrollWheelZoom: p?.scrollWheelZoom !== false,
|
|
251
|
-
attributionControl: false
|
|
252
|
-
}).setView(center, zoom)
|
|
253
|
-
|
|
254
|
-
// Add OpenStreetMap tile layer
|
|
255
|
-
const tileLayerUrl = p?.tileLayer || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
|
256
|
-
L.tileLayer(tileLayerUrl, {
|
|
257
|
-
attribution: p?.attribution || '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
258
|
-
}).addTo(mapInstance)
|
|
259
|
-
|
|
260
|
-
if (p?.attribution !== '') {
|
|
261
|
-
L.control.attribution({ prefix: false }).addTo(mapInstance)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Fix marker icons (Leaflet issue with bundlers)
|
|
265
|
-
delete (L.Icon.Default.prototype as any)._getIconUrl
|
|
266
|
-
L.Icon.Default.mergeOptions({
|
|
267
|
-
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
|
268
|
-
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
|
269
|
-
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
|
270
|
-
})
|
|
360
|
+
if (markers.length > 0) {
|
|
361
|
+
allBoundsLayers.push(...markers);
|
|
271
362
|
}
|
|
272
363
|
|
|
273
|
-
//
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// Clear existing layers (markers, cluster groups, GeoJSON)
|
|
279
|
-
mapInstance.eachLayer((layer: any) => {
|
|
280
|
-
if (layer instanceof L.Marker || layer instanceof L.GeoJSON
|
|
281
|
-
|| layer instanceof L.CircleMarker
|
|
282
|
-
|| layer._group || layer._featureGroup) {
|
|
283
|
-
mapInstance.removeLayer(layer)
|
|
284
|
-
}
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
// ─── Markers (legacy) ────────────────────────
|
|
288
|
-
const markers: any[] = []
|
|
289
|
-
const shouldCluster = p?.clustering && p?.markers && p.markers.length > 0
|
|
290
|
-
|
|
291
|
-
if (shouldCluster) {
|
|
292
|
-
try {
|
|
293
|
-
await import('leaflet.markercluster')
|
|
294
|
-
if (!clusterCssLoaded) {
|
|
295
|
-
await import('leaflet.markercluster/dist/MarkerCluster.css')
|
|
296
|
-
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
|
|
297
|
-
clusterCssLoaded = true
|
|
298
|
-
}
|
|
299
|
-
const clusterOpts: MapClusterOptions = typeof p.clustering === 'object' ? p.clustering : {}
|
|
300
|
-
const clusterGroup = (L as any).markerClusterGroup({
|
|
301
|
-
maxClusterRadius: clusterOpts.maxClusterRadius ?? 80,
|
|
302
|
-
spiderfyOnMaxZoom: clusterOpts.spiderfyOnMaxZoom ?? true,
|
|
303
|
-
showCoverageOnHover: clusterOpts.showCoverageOnHover ?? true,
|
|
304
|
-
disableClusteringAtZoom: clusterOpts.disableClusteringAtZoom,
|
|
305
|
-
animate: clusterOpts.animateAddingMarkers ?? true
|
|
306
|
-
})
|
|
307
|
-
p?.markers?.forEach(marker => {
|
|
308
|
-
const m = L.marker(marker.position)
|
|
309
|
-
if (marker.tooltip) m.bindTooltip(marker.tooltip)
|
|
310
|
-
if (marker.popup) m.bindPopup(marker.popup)
|
|
311
|
-
clusterGroup.addLayer(m)
|
|
312
|
-
markers.push(m)
|
|
313
|
-
})
|
|
314
|
-
mapInstance.addLayer(clusterGroup)
|
|
315
|
-
} catch {
|
|
316
|
-
p?.markers?.forEach(marker => {
|
|
317
|
-
const m = L.marker(marker.position).addTo(mapInstance)
|
|
318
|
-
if (marker.tooltip) m.bindTooltip(marker.tooltip)
|
|
319
|
-
if (marker.popup) m.bindPopup(marker.popup)
|
|
320
|
-
markers.push(m)
|
|
321
|
-
})
|
|
322
|
-
}
|
|
323
|
-
} else {
|
|
324
|
-
p?.markers?.forEach(marker => {
|
|
325
|
-
const m = L.marker(marker.position).addTo(mapInstance)
|
|
326
|
-
if (marker.tooltip) m.bindTooltip(marker.tooltip)
|
|
327
|
-
if (marker.popup) m.bindPopup(marker.popup)
|
|
328
|
-
markers.push(m)
|
|
329
|
-
})
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (markers.length > 0) {
|
|
333
|
-
allBoundsLayers.push(...markers)
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ─── GeoJSON (v3.1.0) ───────────────────────
|
|
337
|
-
if (p?.geojson) {
|
|
338
|
-
const geoLayer = addGeoJSONLayer(mapInstance, L, p.geojson, p.geojsonStyle, p.popup)
|
|
339
|
-
allBoundsLayers.push(geoLayer)
|
|
340
|
-
}
|
|
364
|
+
// ─── GeoJSON (v3.1.0) ───────────────────────
|
|
365
|
+
if (p?.geojson) {
|
|
366
|
+
const geoLayer = addGeoJSONLayer(mapInstance, L, p.geojson, p.geojsonStyle, p.popup);
|
|
367
|
+
allBoundsLayers.push(geoLayer);
|
|
368
|
+
}
|
|
341
369
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Add layer control if multiple layers
|
|
364
|
-
if (Object.keys(overlays).length > 1) {
|
|
365
|
-
L.control.layers(null, overlays, { collapsed: true }).addTo(mapInstance)
|
|
366
|
-
}
|
|
370
|
+
// ─── Named layers (v3.1.0) ──────────────────
|
|
371
|
+
if (p?.layers && p.layers.length > 0) {
|
|
372
|
+
const overlays: Record<string, any> = {};
|
|
373
|
+
|
|
374
|
+
for (const layerDef of p.layers) {
|
|
375
|
+
const geoLayer = addGeoJSONLayer(
|
|
376
|
+
mapInstance,
|
|
377
|
+
L,
|
|
378
|
+
layerDef.geojson,
|
|
379
|
+
layerDef.style || p?.geojsonStyle,
|
|
380
|
+
layerDef.popup || p?.popup
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
overlays[layerDef.name] = geoLayer;
|
|
384
|
+
allBoundsLayers.push(geoLayer);
|
|
385
|
+
|
|
386
|
+
// Respect initial visibility
|
|
387
|
+
if (layerDef.visible === false) {
|
|
388
|
+
mapInstance.removeLayer(geoLayer);
|
|
367
389
|
}
|
|
390
|
+
}
|
|
368
391
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const pmConfig = p.pmtiles
|
|
375
|
-
|
|
376
|
-
const paintRules = pmConfig.paintRules?.map(rule => ({
|
|
377
|
-
dataLayer: rule.dataLayer,
|
|
378
|
-
symbolizer: new (protomaps as any)[
|
|
379
|
-
rule.symbolizer === 'polygon' ? 'PolygonSymbolizer' :
|
|
380
|
-
rule.symbolizer === 'line' ? 'LineSymbolizer' :
|
|
381
|
-
'CircleSymbolizer'
|
|
382
|
-
]({
|
|
383
|
-
fill: rule.color || '#3388ff',
|
|
384
|
-
stroke: rule.color || '#333',
|
|
385
|
-
width: rule.width ?? 1,
|
|
386
|
-
opacity: rule.opacity ?? 0.6,
|
|
387
|
-
}),
|
|
388
|
-
})) || []
|
|
389
|
-
|
|
390
|
-
const labelRules = pmConfig.labelRules?.map(rule => ({
|
|
391
|
-
dataLayer: rule.dataLayer,
|
|
392
|
-
symbolizer: new (protomaps as any).TextSymbolizer({
|
|
393
|
-
label_props: [rule.textField],
|
|
394
|
-
fontSize: rule.fontSize ?? 12,
|
|
395
|
-
}),
|
|
396
|
-
})) || []
|
|
397
|
-
|
|
398
|
-
const pmLayer = (protomaps as any).leafletLayer({
|
|
399
|
-
url: pmConfig.url,
|
|
400
|
-
attribution: pmConfig.attribution,
|
|
401
|
-
paintRules,
|
|
402
|
-
labelRules,
|
|
403
|
-
maxZoom: pmConfig.maxZoom,
|
|
404
|
-
minZoom: pmConfig.minZoom,
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
pmLayer.addTo(mapInstance)
|
|
408
|
-
} catch (e) {
|
|
409
|
-
console.warn('[MCP-UI] Failed to load protomaps-leaflet for PMTiles:', e)
|
|
410
|
-
}
|
|
411
|
-
}
|
|
392
|
+
// Add layer control if multiple layers
|
|
393
|
+
if (Object.keys(overlays).length > 1) {
|
|
394
|
+
L.control.layers(null, overlays, { collapsed: true }).addTo(mapInstance);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
412
397
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
398
|
+
// ─── PMTiles (v3.1.0) ────────────────────────
|
|
399
|
+
if (p?.pmtiles) {
|
|
400
|
+
try {
|
|
401
|
+
// @ts-ignore — optional peer dependency, may not be installed
|
|
402
|
+
const protomaps = await import(/* @vite-ignore */ 'protomaps-leaflet');
|
|
403
|
+
const pmConfig = p.pmtiles;
|
|
404
|
+
|
|
405
|
+
const paintRules =
|
|
406
|
+
pmConfig.paintRules?.map((rule) => ({
|
|
407
|
+
dataLayer: rule.dataLayer,
|
|
408
|
+
symbolizer: new (protomaps as any)[
|
|
409
|
+
rule.symbolizer === 'polygon'
|
|
410
|
+
? 'PolygonSymbolizer'
|
|
411
|
+
: rule.symbolizer === 'line'
|
|
412
|
+
? 'LineSymbolizer'
|
|
413
|
+
: 'CircleSymbolizer'
|
|
414
|
+
]({
|
|
415
|
+
fill: rule.color || '#3388ff',
|
|
416
|
+
stroke: rule.color || '#333',
|
|
417
|
+
width: rule.width ?? 1,
|
|
418
|
+
opacity: rule.opacity ?? 0.6,
|
|
419
|
+
}),
|
|
420
|
+
})) || [];
|
|
421
|
+
|
|
422
|
+
const labelRules =
|
|
423
|
+
pmConfig.labelRules?.map((rule) => ({
|
|
424
|
+
dataLayer: rule.dataLayer,
|
|
425
|
+
symbolizer: new (protomaps as any).TextSymbolizer({
|
|
426
|
+
label_props: [rule.textField],
|
|
427
|
+
fontSize: rule.fontSize ?? 12,
|
|
428
|
+
}),
|
|
429
|
+
})) || [];
|
|
430
|
+
|
|
431
|
+
const pmLayer = (protomaps as any).leafletLayer({
|
|
432
|
+
url: pmConfig.url,
|
|
433
|
+
attribution: pmConfig.attribution,
|
|
434
|
+
paintRules,
|
|
435
|
+
labelRules,
|
|
436
|
+
maxZoom: pmConfig.maxZoom,
|
|
437
|
+
minZoom: pmConfig.minZoom,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
pmLayer.addTo(mapInstance);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
console.warn('[MCP-UI] Failed to load protomaps-leaflet for PMTiles:', e);
|
|
443
|
+
}
|
|
423
444
|
}
|
|
424
|
-
})
|
|
425
445
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
446
|
+
// ─── Fit bounds ─────────────────────────────
|
|
447
|
+
if (p?.fitBounds && allBoundsLayers.length > 0) {
|
|
448
|
+
const group = L.featureGroup(allBoundsLayers);
|
|
449
|
+
const bounds = group.getBounds();
|
|
450
|
+
if (bounds.isValid()) {
|
|
451
|
+
mapInstance.fitBounds(bounds.pad(0.1));
|
|
452
|
+
}
|
|
453
|
+
} else if (p?.center) {
|
|
454
|
+
mapInstance.setView(p.center, p.zoom || mapInstance.getZoom());
|
|
431
455
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
456
|
+
} catch (err) {
|
|
457
|
+
// Fallback ladder (P2.5): a Leaflet drawing failure degrades to
|
|
458
|
+
// the coordinate table below instead of a blank/partial map.
|
|
459
|
+
const message = err instanceof Error ? err.message : 'Failed to render map';
|
|
460
|
+
setError(message);
|
|
461
|
+
telemetry?.dispatch({
|
|
462
|
+
type: 'render:error',
|
|
463
|
+
errorMessage: message,
|
|
464
|
+
id: props.component?.id ?? '',
|
|
465
|
+
componentType: 'map',
|
|
466
|
+
ts: Date.now(),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Cleanup
|
|
473
|
+
onCleanup(() => {
|
|
474
|
+
if (mapInstance) {
|
|
475
|
+
mapInstance.remove();
|
|
476
|
+
mapInstance = null;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<ExpandableWrapper
|
|
482
|
+
title={'Map'}
|
|
483
|
+
copyData={mapToGeoJSON(params())}
|
|
484
|
+
copyLabel="Copy markers as GeoJSON"
|
|
485
|
+
toolbarVariant={props.toolbarVariant}
|
|
486
|
+
>
|
|
487
|
+
<div
|
|
488
|
+
class={`w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden ${params()?.className || ''} ${
|
|
489
|
+
isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
|
|
490
|
+
}`}
|
|
491
|
+
>
|
|
492
|
+
<Show when={error()}>
|
|
493
|
+
{/* Fallback ladder (P2.5): degrade to a coordinate table
|
|
494
|
+
rather than a bare error string. */}
|
|
495
|
+
<div class="p-3">
|
|
496
|
+
<DegradedFallback
|
|
497
|
+
message={`Map rendering failed: ${error()}`}
|
|
498
|
+
caption="Showing the map data as a coordinate table — the interactive map is unavailable."
|
|
499
|
+
{...mapToDegradedTable(params() ?? {})}
|
|
500
|
+
/>
|
|
501
|
+
</div>
|
|
502
|
+
</Show>
|
|
503
|
+
<Show when={!error()}>
|
|
504
|
+
<div
|
|
505
|
+
ref={mapContainer}
|
|
506
|
+
style={
|
|
507
|
+
isExpanded()
|
|
508
|
+
? { height: '100%', width: '100%', 'z-index': 0 }
|
|
509
|
+
: { height: params()?.height || '400px', width: '100%', 'z-index': 0 }
|
|
510
|
+
}
|
|
511
|
+
class={`relative z-0 ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
|
|
512
|
+
/>
|
|
513
|
+
</Show>
|
|
514
|
+
</div>
|
|
515
|
+
</ExpandableWrapper>
|
|
516
|
+
);
|
|
517
|
+
};
|