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