@seed-ship/mcp-ui-solid 3.0.5 → 4.0.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 (126) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +253 -280
  3. package/dist/components/ChartJSRenderer.cjs +37 -15
  4. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  5. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  6. package/dist/components/ChartJSRenderer.js +37 -15
  7. package/dist/components/ChartJSRenderer.js.map +1 -1
  8. package/dist/components/DataPreviewSection.cjs +172 -0
  9. package/dist/components/DataPreviewSection.cjs.map +1 -0
  10. package/dist/components/DataPreviewSection.d.ts +19 -0
  11. package/dist/components/DataPreviewSection.d.ts.map +1 -0
  12. package/dist/components/DataPreviewSection.js +172 -0
  13. package/dist/components/DataPreviewSection.js.map +1 -0
  14. package/dist/components/MapRenderer.cjs +168 -26
  15. package/dist/components/MapRenderer.cjs.map +1 -1
  16. package/dist/components/MapRenderer.d.ts +2 -2
  17. package/dist/components/MapRenderer.d.ts.map +1 -1
  18. package/dist/components/MapRenderer.js +169 -27
  19. package/dist/components/MapRenderer.js.map +1 -1
  20. package/dist/components/ScratchpadPanel.cjs +74 -0
  21. package/dist/components/ScratchpadPanel.cjs.map +1 -1
  22. package/dist/components/ScratchpadPanel.d.ts.map +1 -1
  23. package/dist/components/ScratchpadPanel.js +75 -1
  24. package/dist/components/ScratchpadPanel.js.map +1 -1
  25. package/dist/components/VerifiedText.cjs +166 -0
  26. package/dist/components/VerifiedText.cjs.map +1 -0
  27. package/dist/components/VerifiedText.d.ts +22 -0
  28. package/dist/components/VerifiedText.d.ts.map +1 -0
  29. package/dist/components/VerifiedText.js +166 -0
  30. package/dist/components/VerifiedText.js.map +1 -0
  31. package/dist/components/index.d.ts +4 -0
  32. package/dist/components/index.d.ts.map +1 -1
  33. package/dist/components.cjs +4 -0
  34. package/dist/components.cjs.map +1 -1
  35. package/dist/components.d.cts +4 -0
  36. package/dist/components.d.ts +4 -0
  37. package/dist/components.js +4 -0
  38. package/dist/components.js.map +1 -1
  39. package/dist/hooks/index.d.ts +2 -0
  40. package/dist/hooks/index.d.ts.map +1 -1
  41. package/dist/hooks/useDataValidator.cjs +31 -0
  42. package/dist/hooks/useDataValidator.cjs.map +1 -0
  43. package/dist/hooks/useDataValidator.d.ts +42 -0
  44. package/dist/hooks/useDataValidator.d.ts.map +1 -0
  45. package/dist/hooks/useDataValidator.js +31 -0
  46. package/dist/hooks/useDataValidator.js.map +1 -0
  47. package/dist/hooks.cjs +2 -0
  48. package/dist/hooks.cjs.map +1 -1
  49. package/dist/hooks.d.cts +2 -0
  50. package/dist/hooks.d.ts +2 -0
  51. package/dist/hooks.js +2 -0
  52. package/dist/hooks.js.map +1 -1
  53. package/dist/index.cjs +8 -0
  54. package/dist/index.cjs.map +1 -1
  55. package/dist/index.d.cts +9 -5
  56. package/dist/index.d.ts +9 -5
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/node_modules/.pnpm/@mapbox_point-geometry@1.1.0/node_modules/@mapbox/point-geometry/index.cjs +290 -0
  61. package/dist/node_modules/.pnpm/@mapbox_point-geometry@1.1.0/node_modules/@mapbox/point-geometry/index.cjs.map +1 -0
  62. package/dist/node_modules/.pnpm/@mapbox_point-geometry@1.1.0/node_modules/@mapbox/point-geometry/index.js +291 -0
  63. package/dist/node_modules/.pnpm/@mapbox_point-geometry@1.1.0/node_modules/@mapbox/point-geometry/index.js.map +1 -0
  64. package/dist/node_modules/.pnpm/@mapbox_vector-tile@2.0.4/node_modules/@mapbox/vector-tile/index.cjs +243 -0
  65. package/dist/node_modules/.pnpm/@mapbox_vector-tile@2.0.4/node_modules/@mapbox/vector-tile/index.cjs.map +1 -0
  66. package/dist/node_modules/.pnpm/@mapbox_vector-tile@2.0.4/node_modules/@mapbox/vector-tile/index.js +243 -0
  67. package/dist/node_modules/.pnpm/@mapbox_vector-tile@2.0.4/node_modules/@mapbox/vector-tile/index.js.map +1 -0
  68. package/dist/node_modules/.pnpm/color2k@2.0.3/node_modules/color2k/dist/index.exports.import.es.cjs +137 -0
  69. package/dist/node_modules/.pnpm/color2k@2.0.3/node_modules/color2k/dist/index.exports.import.es.cjs.map +1 -0
  70. package/dist/node_modules/.pnpm/color2k@2.0.3/node_modules/color2k/dist/index.exports.import.es.js +137 -0
  71. package/dist/node_modules/.pnpm/color2k@2.0.3/node_modules/color2k/dist/index.exports.import.es.js.map +1 -0
  72. package/dist/node_modules/.pnpm/pbf@4.0.1/node_modules/pbf/index.cjs +686 -0
  73. package/dist/node_modules/.pnpm/pbf@4.0.1/node_modules/pbf/index.cjs.map +1 -0
  74. package/dist/node_modules/.pnpm/pbf@4.0.1/node_modules/pbf/index.js +687 -0
  75. package/dist/node_modules/.pnpm/pbf@4.0.1/node_modules/pbf/index.js.map +1 -0
  76. package/dist/node_modules/.pnpm/pmtiles@3.2.1/node_modules/pmtiles/dist/index.cjs +1366 -0
  77. package/dist/node_modules/.pnpm/pmtiles@3.2.1/node_modules/pmtiles/dist/index.cjs.map +1 -0
  78. package/dist/node_modules/.pnpm/pmtiles@3.2.1/node_modules/pmtiles/dist/index.js +1366 -0
  79. package/dist/node_modules/.pnpm/pmtiles@3.2.1/node_modules/pmtiles/dist/index.js.map +1 -0
  80. package/dist/node_modules/.pnpm/potpack@1.0.2/node_modules/potpack/index.cjs +54 -0
  81. package/dist/node_modules/.pnpm/potpack@1.0.2/node_modules/potpack/index.cjs.map +1 -0
  82. package/dist/node_modules/.pnpm/potpack@1.0.2/node_modules/potpack/index.js +55 -0
  83. package/dist/node_modules/.pnpm/potpack@1.0.2/node_modules/potpack/index.js.map +1 -0
  84. package/dist/node_modules/.pnpm/protomaps-leaflet@4.1.1/node_modules/protomaps-leaflet/dist/esm/index.cjs +1256 -0
  85. package/dist/node_modules/.pnpm/protomaps-leaflet@4.1.1/node_modules/protomaps-leaflet/dist/esm/index.cjs.map +1 -0
  86. package/dist/node_modules/.pnpm/protomaps-leaflet@4.1.1/node_modules/protomaps-leaflet/dist/esm/index.js +1256 -0
  87. package/dist/node_modules/.pnpm/protomaps-leaflet@4.1.1/node_modules/protomaps-leaflet/dist/esm/index.js.map +1 -0
  88. package/dist/node_modules/.pnpm/quickselect@2.0.0/node_modules/quickselect/index.cjs +47 -0
  89. package/dist/node_modules/.pnpm/quickselect@2.0.0/node_modules/quickselect/index.cjs.map +1 -0
  90. package/dist/node_modules/.pnpm/quickselect@2.0.0/node_modules/quickselect/index.js +48 -0
  91. package/dist/node_modules/.pnpm/quickselect@2.0.0/node_modules/quickselect/index.js.map +1 -0
  92. package/dist/node_modules/.pnpm/rbush@3.0.1/node_modules/rbush/index.cjs +378 -0
  93. package/dist/node_modules/.pnpm/rbush@3.0.1/node_modules/rbush/index.cjs.map +1 -0
  94. package/dist/node_modules/.pnpm/rbush@3.0.1/node_modules/rbush/index.js +379 -0
  95. package/dist/node_modules/.pnpm/rbush@3.0.1/node_modules/rbush/index.js.map +1 -0
  96. package/dist/services/data-validator.cjs +85 -0
  97. package/dist/services/data-validator.cjs.map +1 -0
  98. package/dist/services/data-validator.d.ts +28 -0
  99. package/dist/services/data-validator.d.ts.map +1 -0
  100. package/dist/services/data-validator.js +85 -0
  101. package/dist/services/data-validator.js.map +1 -0
  102. package/dist/services/index.d.ts +1 -0
  103. package/dist/services/index.d.ts.map +1 -1
  104. package/dist/types/chat-bus.d.ts +88 -1
  105. package/dist/types/chat-bus.d.ts.map +1 -1
  106. package/dist/types/index.d.ts +135 -6
  107. package/dist/types/index.d.ts.map +1 -1
  108. package/dist/types.d.cts +135 -6
  109. package/dist/types.d.ts +135 -6
  110. package/package.json +5 -1
  111. package/src/components/ChartJSRenderer.tsx +35 -13
  112. package/src/components/DataPreviewSection.tsx +206 -0
  113. package/src/components/MapRenderer.test.tsx +94 -5
  114. package/src/components/MapRenderer.tsx +246 -45
  115. package/src/components/ScratchpadPanel.tsx +10 -2
  116. package/src/components/VerifiedText.tsx +187 -0
  117. package/src/components/index.ts +7 -0
  118. package/src/hooks/index.ts +7 -0
  119. package/src/hooks/useDataValidator.ts +68 -0
  120. package/src/index.ts +26 -1
  121. package/src/services/data-validator.test.ts +151 -0
  122. package/src/services/data-validator.ts +149 -0
  123. package/src/services/index.ts +2 -0
  124. package/src/types/chat-bus.ts +98 -1
  125. package/src/types/index.ts +145 -6
  126. package/tsconfig.tsbuildinfo +1 -1
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * MapRenderer - Interactive map Component
3
- * Sprint 6: Code & Maps
4
- * Sprint Ultimate U.2: Marker Clustering Support
3
+ * Sprint 6: Markers + clustering
4
+ * v3.1.0: GeoJSON, choropleth, popups, multi-layer, PMTiles
5
5
  */
6
6
 
7
7
  import { Component, createEffect, onCleanup, createSignal, Show } from 'solid-js'
8
8
  import { isServer } from 'solid-js/web'
9
- import type { UIComponent, MapComponentParams, MapClusterOptions } from '../types'
9
+ import type { UIComponent, MapComponentParams, MapClusterOptions, MapGeoJSONStyle, MapPopupConfig, MapLayer, MapPMTilesConfig } from '../types'
10
10
 
11
11
  // Lazy load leaflet (it doesn't support SSR well)
12
12
  let L: any = null
@@ -25,6 +25,146 @@ export interface MapRendererProps {
25
25
  params?: MapComponentParams
26
26
  }
27
27
 
28
+ // ─── Helpers ────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Resolve choropleth color for a feature based on property value and scale stops.
32
+ */
33
+ function getChoroplethColor(
34
+ value: unknown,
35
+ scale: Array<[number, string]>,
36
+ fallback: string
37
+ ): string {
38
+ if (value == null || typeof value !== 'number' || !isFinite(value)) return fallback
39
+
40
+ // Scale is sorted ascending: [[0, '#eff3ff'], [100, '#084594']]
41
+ if (scale.length === 0) return fallback
42
+ if (value <= scale[0][0]) return scale[0][1]
43
+ if (value >= scale[scale.length - 1][0]) return scale[scale.length - 1][1]
44
+
45
+ // Find surrounding stops and interpolate (use upper bracket color)
46
+ for (let i = 1; i < scale.length; i++) {
47
+ if (value <= scale[i][0]) return scale[i][1]
48
+ }
49
+ return scale[scale.length - 1][1]
50
+ }
51
+
52
+ /**
53
+ * Build a Leaflet style function from MapGeoJSONStyle config.
54
+ */
55
+ function buildStyleFn(style: MapGeoJSONStyle | undefined): (feature: any) => Record<string, unknown> {
56
+ if (!style) {
57
+ return () => ({
58
+ fillColor: '#3388ff',
59
+ fillOpacity: 0.6,
60
+ color: '#333',
61
+ weight: 1,
62
+ opacity: 1,
63
+ })
64
+ }
65
+
66
+ return (feature: any) => {
67
+ let fillColor = style.fillColor || '#3388ff'
68
+
69
+ // Choropleth: override fillColor based on feature property
70
+ if (style.choroplethField && style.choroplethScale && feature?.properties) {
71
+ const val = feature.properties[style.choroplethField]
72
+ fillColor = getChoroplethColor(val, style.choroplethScale, style.choroplethFallback || '#ccc')
73
+ }
74
+
75
+ return {
76
+ fillColor,
77
+ fillOpacity: style.fillOpacity ?? 0.6,
78
+ color: style.strokeColor || '#333',
79
+ weight: style.strokeWeight ?? 1,
80
+ opacity: style.strokeOpacity ?? 1,
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Build popup HTML from a feature's properties using popup config.
87
+ */
88
+ function buildPopupContent(feature: any, popup: MapPopupConfig | undefined): string | null {
89
+ if (!popup || !feature?.properties) return null
90
+ const props = feature.properties
91
+
92
+ // Custom template
93
+ if (popup.template) {
94
+ return popup.template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
95
+ const val = props[key]
96
+ return val != null ? String(val) : ''
97
+ })
98
+ }
99
+
100
+ // Auto-generated popup
101
+ const parts: string[] = []
102
+
103
+ if (popup.titleField && props[popup.titleField] != null) {
104
+ parts.push(`<strong>${escapeHtml(String(props[popup.titleField]))}</strong>`)
105
+ }
106
+
107
+ const fields = popup.fields || Object.keys(props).slice(0, 8)
108
+ for (const key of fields) {
109
+ if (key === popup.titleField) continue
110
+ const val = props[key]
111
+ if (val == null) continue
112
+ const formatted = typeof val === 'number' ? val.toLocaleString('fr-FR') : String(val)
113
+ parts.push(`<span style="color:#666;font-size:11px">${escapeHtml(key)}</span>: ${escapeHtml(formatted)}`)
114
+ }
115
+
116
+ return parts.join('<br/>')
117
+ }
118
+
119
+ function escapeHtml(str: string): string {
120
+ return str
121
+ .replace(/&/g, '&amp;')
122
+ .replace(/</g, '&lt;')
123
+ .replace(/>/g, '&gt;')
124
+ .replace(/"/g, '&quot;')
125
+ }
126
+
127
+ /**
128
+ * Add a GeoJSON layer to the map with style and popup support.
129
+ * Returns the layer for bounds calculation.
130
+ */
131
+ function addGeoJSONLayer(
132
+ mapInst: any,
133
+ leaflet: any,
134
+ geojson: unknown,
135
+ style?: MapGeoJSONStyle,
136
+ popup?: MapPopupConfig
137
+ ): any {
138
+ const styleFn = buildStyleFn(style)
139
+
140
+ const layer = leaflet.geoJSON(geojson, {
141
+ style: styleFn,
142
+ pointToLayer: (feature: any, latlng: any) => {
143
+ // Render points as circle markers for consistency
144
+ const s = styleFn(feature)
145
+ return leaflet.circleMarker(latlng, {
146
+ radius: 6,
147
+ fillColor: s.fillColor,
148
+ fillOpacity: s.fillOpacity,
149
+ color: s.color,
150
+ weight: s.weight,
151
+ opacity: s.opacity,
152
+ })
153
+ },
154
+ onEachFeature: (feature: any, featureLayer: any) => {
155
+ const html = buildPopupContent(feature, popup)
156
+ if (html) {
157
+ featureLayer.bindPopup(html, { maxWidth: 300 })
158
+ }
159
+ },
160
+ })
161
+
162
+ layer.addTo(mapInst)
163
+ return layer
164
+ }
165
+
166
+ // ─── Component ──────────────────────────────────────────────
167
+
28
168
  export const MapRenderer: Component<MapRendererProps> = (props) => {
29
169
  let mapContainer: HTMLDivElement | undefined
30
170
  let mapInstance: any = null
@@ -82,38 +222,33 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
82
222
  })
83
223
  }
84
224
 
85
- // Update markers and view - Sprint Ultimate U.2: Clustering support
225
+ // Update markers and view
86
226
  if (mapInstance && L) {
87
227
  const p = params()
228
+ const allBoundsLayers: any[] = []
88
229
 
89
- // Clear existing layers (markers and cluster groups)
230
+ // Clear existing layers (markers, cluster groups, GeoJSON)
90
231
  mapInstance.eachLayer((layer: any) => {
91
- if (layer instanceof L.Marker || layer._group || layer._featureGroup) {
232
+ if (layer instanceof L.Marker || layer instanceof L.GeoJSON
233
+ || layer instanceof L.CircleMarker
234
+ || layer._group || layer._featureGroup) {
92
235
  mapInstance.removeLayer(layer)
93
236
  }
94
237
  })
95
238
 
239
+ // ─── Markers (legacy) ────────────────────────
96
240
  const markers: any[] = []
97
241
  const shouldCluster = p?.clustering && p?.markers && p.markers.length > 0
98
242
 
99
243
  if (shouldCluster) {
100
- // Sprint Ultimate U.2: Use marker clustering
101
244
  try {
102
- // Lazy load markercluster plugin
103
- // Import markercluster plugin for side-effects (registers with Leaflet)
104
245
  await import('leaflet.markercluster')
105
-
106
- // Load cluster CSS if not already loaded
107
246
  if (!clusterCssLoaded) {
108
247
  await import('leaflet.markercluster/dist/MarkerCluster.css')
109
248
  await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
110
249
  clusterCssLoaded = true
111
250
  }
112
-
113
- // Get cluster options
114
251
  const clusterOpts: MapClusterOptions = typeof p.clustering === 'object' ? p.clustering : {}
115
-
116
- // Create cluster group with options
117
252
  const clusterGroup = (L as any).markerClusterGroup({
118
253
  maxClusterRadius: clusterOpts.maxClusterRadius ?? 80,
119
254
  spiderfyOnMaxZoom: clusterOpts.spiderfyOnMaxZoom ?? true,
@@ -121,53 +256,119 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
121
256
  disableClusteringAtZoom: clusterOpts.disableClusteringAtZoom,
122
257
  animate: clusterOpts.animateAddingMarkers ?? true
123
258
  })
124
-
125
- // Add markers to cluster group
126
259
  p?.markers?.forEach(marker => {
127
260
  const m = L.marker(marker.position)
128
- if (marker.tooltip) {
129
- m.bindTooltip(marker.tooltip)
130
- }
131
- if (marker.popup) {
132
- m.bindPopup(marker.popup)
133
- }
261
+ if (marker.tooltip) m.bindTooltip(marker.tooltip)
262
+ if (marker.popup) m.bindPopup(marker.popup)
134
263
  clusterGroup.addLayer(m)
135
264
  markers.push(m)
136
265
  })
137
-
138
266
  mapInstance.addLayer(clusterGroup)
139
- } catch (e) {
140
- console.warn('Failed to load leaflet.markercluster, falling back to regular markers', e)
141
- // Fallback to regular markers
267
+ } catch {
142
268
  p?.markers?.forEach(marker => {
143
269
  const m = L.marker(marker.position).addTo(mapInstance)
144
- if (marker.tooltip) {
145
- m.bindTooltip(marker.tooltip)
146
- }
147
- if (marker.popup) {
148
- m.bindPopup(marker.popup)
149
- }
270
+ if (marker.tooltip) m.bindTooltip(marker.tooltip)
271
+ if (marker.popup) m.bindPopup(marker.popup)
150
272
  markers.push(m)
151
273
  })
152
274
  }
153
275
  } else {
154
- // Standard marker rendering (no clustering)
155
276
  p?.markers?.forEach(marker => {
156
277
  const m = L.marker(marker.position).addTo(mapInstance)
157
- if (marker.tooltip) {
158
- m.bindTooltip(marker.tooltip)
159
- }
160
- if (marker.popup) {
161
- m.bindPopup(marker.popup)
162
- }
278
+ if (marker.tooltip) m.bindTooltip(marker.tooltip)
279
+ if (marker.popup) m.bindPopup(marker.popup)
163
280
  markers.push(m)
164
281
  })
165
282
  }
166
283
 
167
- // Handle fitBounds
168
- if (p?.fitBounds && markers.length > 0) {
169
- const group = L.featureGroup(markers)
170
- mapInstance.fitBounds(group.getBounds().pad(0.1))
284
+ if (markers.length > 0) {
285
+ allBoundsLayers.push(...markers)
286
+ }
287
+
288
+ // ─── GeoJSON (v3.1.0) ───────────────────────
289
+ if (p?.geojson) {
290
+ const geoLayer = addGeoJSONLayer(mapInstance, L, p.geojson, p.geojsonStyle, p.popup)
291
+ allBoundsLayers.push(geoLayer)
292
+ }
293
+
294
+ // ─── Named layers (v3.1.0) ──────────────────
295
+ if (p?.layers && p.layers.length > 0) {
296
+ const overlays: Record<string, any> = {}
297
+
298
+ for (const layerDef of p.layers) {
299
+ const geoLayer = addGeoJSONLayer(
300
+ mapInstance, L,
301
+ layerDef.geojson,
302
+ layerDef.style || p?.geojsonStyle,
303
+ layerDef.popup || p?.popup
304
+ )
305
+
306
+ overlays[layerDef.name] = geoLayer
307
+ allBoundsLayers.push(geoLayer)
308
+
309
+ // Respect initial visibility
310
+ if (layerDef.visible === false) {
311
+ mapInstance.removeLayer(geoLayer)
312
+ }
313
+ }
314
+
315
+ // Add layer control if multiple layers
316
+ if (Object.keys(overlays).length > 1) {
317
+ L.control.layers(null, overlays, { collapsed: true }).addTo(mapInstance)
318
+ }
319
+ }
320
+
321
+ // ─── PMTiles (v3.1.0) ────────────────────────
322
+ if (p?.pmtiles) {
323
+ try {
324
+ // @ts-ignore — optional peer dependency, may not be installed
325
+ const protomaps = await import(/* @vite-ignore */ 'protomaps-leaflet')
326
+ const pmConfig = p.pmtiles
327
+
328
+ const paintRules = pmConfig.paintRules?.map(rule => ({
329
+ dataLayer: rule.dataLayer,
330
+ symbolizer: new (protomaps as any)[
331
+ rule.symbolizer === 'polygon' ? 'PolygonSymbolizer' :
332
+ rule.symbolizer === 'line' ? 'LineSymbolizer' :
333
+ 'CircleSymbolizer'
334
+ ]({
335
+ fill: rule.color || '#3388ff',
336
+ stroke: rule.color || '#333',
337
+ width: rule.width ?? 1,
338
+ opacity: rule.opacity ?? 0.6,
339
+ }),
340
+ })) || []
341
+
342
+ const labelRules = pmConfig.labelRules?.map(rule => ({
343
+ dataLayer: rule.dataLayer,
344
+ symbolizer: new (protomaps as any).TextSymbolizer({
345
+ label_props: [rule.textField],
346
+ fontSize: rule.fontSize ?? 12,
347
+ }),
348
+ })) || []
349
+
350
+ const pmLayer = (protomaps as any).leafletLayer({
351
+ url: pmConfig.url,
352
+ attribution: pmConfig.attribution,
353
+ paintRules,
354
+ labelRules,
355
+ maxZoom: pmConfig.maxZoom,
356
+ minZoom: pmConfig.minZoom,
357
+ })
358
+
359
+ pmLayer.addTo(mapInstance)
360
+ } catch (e) {
361
+ console.warn('[MCP-UI] Failed to load protomaps-leaflet for PMTiles:', e)
362
+ }
363
+ }
364
+
365
+ // ─── Fit bounds ─────────────────────────────
366
+ if (p?.fitBounds && allBoundsLayers.length > 0) {
367
+ const group = L.featureGroup(allBoundsLayers)
368
+ const bounds = group.getBounds()
369
+ if (bounds.isValid()) {
370
+ mapInstance.fitBounds(bounds.pad(0.1))
371
+ }
171
372
  } else if (p?.center) {
172
373
  mapInstance.setView(p.center, p.zoom || mapInstance.getZoom())
173
374
  }
@@ -183,7 +384,7 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
183
384
  })
184
385
 
185
386
  return (
186
- <div class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
387
+ <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 || ''}`}>
187
388
  <Show when={error()}>
188
389
  <div class="p-4 text-red-500 bg-red-50 dark:bg-red-900/20 text-center">
189
390
  {error()}
@@ -6,9 +6,13 @@
6
6
  */
7
7
 
8
8
  import { Component, Show, For, Switch, Match, createSignal, createEffect, onCleanup } from 'solid-js'
9
- import type { ScratchpadState, ScratchpadSection } from '../types/chat-bus'
10
- import type { FormFieldParams } from '../types'
9
+ import type { ScratchpadState, ScratchpadSection, VerifiedTextContent, DataPreviewContent, MapSectionContent } from '../types/chat-bus'
10
+ import type { FormFieldParams, ChartComponentParams } from '../types'
11
11
  import { FormFieldRenderer } from './FormFieldRenderer'
12
+ import { VerifiedText } from './VerifiedText'
13
+ import { DataPreviewSection } from './DataPreviewSection'
14
+ import { MapRenderer } from './MapRenderer'
15
+ import { ChartJSRenderer } from './ChartJSRenderer'
12
16
 
13
17
  export interface ScratchpadPanelProps {
14
18
  state: ScratchpadState
@@ -365,6 +369,10 @@ const SectionRenderer: Component<{
365
369
  <Match when={props.section.type === 'error'}><ErrorSectionRenderer content={props.section.content} onAction={props.onAction} /></Match>
366
370
  <Match when={props.section.type === 'source_card'}><SourceCardSection content={props.section.content} /></Match>
367
371
  <Match when={props.section.type === 'diff'}><DiffSection content={props.section.content} /></Match>
372
+ <Match when={props.section.type === 'verified_text'}><VerifiedText {...(props.section.content as VerifiedTextContent)} onHallucinationClick={(h) => props.onAction?.('hallucination_click', h)} /></Match>
373
+ <Match when={props.section.type === 'data_preview'}><DataPreviewSection content={props.section.content as DataPreviewContent} /></Match>
374
+ <Match when={props.section.type === 'map'}>{(() => { const c = props.section.content as MapSectionContent; return <MapRenderer params={{ geojson: c.geojson, center: c.center, zoom: c.zoom, geojsonStyle: c.style, popup: c.popup, layers: c.layers, height: c.height || '300px', fitBounds: true }} /> })()}</Match>
375
+ <Match when={props.section.type === 'chart'}><ChartJSRenderer component={{ id: props.section.id, type: 'chart', position: { colStart: 1, colSpan: 12 }, params: { ...(props.section.content as ChartComponentParams), renderer: 'native', height: (props.section.content as any)?.height || '250px' } }} /></Match>
368
376
  <Match when={true}><pre class="text-xs text-gray-500 overflow-auto">{JSON.stringify(props.section.content, null, 2)}</pre></Match>
369
377
  </Switch>
370
378
  </div>
@@ -0,0 +1,187 @@
1
+ /**
2
+ * VerifiedText — renders LLM text with inline verification badges
3
+ * v3.1.0: Highlights verified vs hallucinated numbers
4
+ *
5
+ * @experimental
6
+ *
7
+ * Modes:
8
+ * - highlight: ✅/⚠️ badges next to numbers (default)
9
+ * - strip: replaces hallucinated numbers with [non vérifié]
10
+ * - annotate: tooltip on hover with closest source number
11
+ */
12
+
13
+ import { createMemo, For } from 'solid-js'
14
+ import type { DataValidation, HallucinatedNumber } from '../types/chat-bus'
15
+
16
+ export interface VerifiedTextProps {
17
+ text: string
18
+ validation: DataValidation
19
+ /** Display mode (default: 'highlight') */
20
+ mode?: 'highlight' | 'strip' | 'annotate'
21
+ /** Callback when a hallucinated number is clicked */
22
+ onHallucinationClick?: (item: HallucinatedNumber) => void
23
+ }
24
+
25
+ interface TextSegment {
26
+ type: 'text' | 'verified' | 'hallucinated'
27
+ content: string
28
+ item?: HallucinatedNumber
29
+ }
30
+
31
+ /**
32
+ * Build annotated segments by splitting text at number positions.
33
+ * Verified numbers get ✅, hallucinated get ⚠️.
34
+ */
35
+ function buildAnnotatedSegments(text: string, validation: DataValidation): TextSegment[] {
36
+ // Build position maps
37
+ const hallucinatedPositions = new Map<number, HallucinatedNumber>()
38
+ for (const h of validation.hallucinated) {
39
+ hallucinatedPositions.set(h.position, h)
40
+ }
41
+
42
+ const verifiedPositions = new Set<number>()
43
+ for (const n of validation.llmNumbers) {
44
+ if (!hallucinatedPositions.has(n.position)) {
45
+ verifiedPositions.add(n.position)
46
+ }
47
+ }
48
+
49
+ // Build all number positions sorted
50
+ const allPositions = validation.llmNumbers
51
+ .map(n => ({ position: n.position, length: n.context.length - 20 })) // approximate original match length
52
+ .sort((a, b) => a.position - b.position)
53
+
54
+ // Re-extract number lengths from text for precise splitting
55
+ const numberRegex = /\d[\d\s,.]*\d|\d+/g
56
+ const matches: Array<{ start: number; end: number }> = []
57
+ let m: RegExpExecArray | null
58
+ while ((m = numberRegex.exec(text)) !== null) {
59
+ matches.push({ start: m.index, end: m.index + m[0].length })
60
+ }
61
+
62
+ // Build a lookup from position → match
63
+ const matchByPosition = new Map<number, { start: number; end: number }>()
64
+ for (const match of matches) {
65
+ matchByPosition.set(match.start, match)
66
+ }
67
+
68
+ // Build segments
69
+ const segments: TextSegment[] = []
70
+ let cursor = 0
71
+
72
+ for (const num of validation.llmNumbers) {
73
+ const match = matchByPosition.get(num.position)
74
+ if (!match) continue
75
+
76
+ // Text before this number
77
+ if (match.start > cursor) {
78
+ segments.push({ type: 'text', content: text.slice(cursor, match.start) })
79
+ }
80
+
81
+ const numberText = text.slice(match.start, match.end)
82
+ const hallucinated = hallucinatedPositions.get(num.position)
83
+
84
+ if (hallucinated) {
85
+ segments.push({ type: 'hallucinated', content: numberText, item: hallucinated })
86
+ } else {
87
+ segments.push({ type: 'verified', content: numberText })
88
+ }
89
+
90
+ cursor = match.end
91
+ }
92
+
93
+ // Remaining text
94
+ if (cursor < text.length) {
95
+ segments.push({ type: 'text', content: text.slice(cursor) })
96
+ }
97
+
98
+ return segments
99
+ }
100
+
101
+ export function VerifiedText(props: VerifiedTextProps) {
102
+ const mode = () => props.mode || 'highlight'
103
+
104
+ const segments = createMemo<TextSegment[]>(() => {
105
+ if (!props.validation || props.validation.valid) {
106
+ return [{ type: 'text' as const, content: props.text }]
107
+ }
108
+ return buildAnnotatedSegments(props.text, props.validation)
109
+ })
110
+
111
+ return (
112
+ <div class="verified-text text-sm leading-relaxed">
113
+ <For each={segments()}>
114
+ {(seg) => {
115
+ if (seg.type === 'text') {
116
+ return <span>{seg.content}</span>
117
+ }
118
+
119
+ if (seg.type === 'verified') {
120
+ return (
121
+ <span
122
+ class="inline-flex items-center gap-0.5 px-0.5 rounded bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300"
123
+ title="Verified against source data"
124
+ >
125
+ {seg.content}
126
+ <span class="text-xs opacity-70" aria-label="verified">&#x2705;</span>
127
+ </span>
128
+ )
129
+ }
130
+
131
+ // hallucinated
132
+ const h = seg.item!
133
+ const tooltipText = () => {
134
+ if (h.closest != null && h.distance != null) {
135
+ return `Not found in source data. Closest: ${h.closest} (${Math.round(h.distance * 100)}% off)`
136
+ }
137
+ return 'Not found in source data'
138
+ }
139
+
140
+ if (mode() === 'strip') {
141
+ return (
142
+ <span
143
+ class="inline-flex items-center px-1 rounded bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 italic text-xs"
144
+ title={tooltipText()}
145
+ >
146
+ [non v&eacute;rifi&eacute;]
147
+ </span>
148
+ )
149
+ }
150
+
151
+ return (
152
+ <span
153
+ class="inline-flex items-center gap-0.5 px-0.5 rounded bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300 cursor-help"
154
+ title={tooltipText()}
155
+ onClick={() => props.onHallucinationClick?.(h)}
156
+ role={props.onHallucinationClick ? 'button' : undefined}
157
+ >
158
+ {seg.content}
159
+ <span class="text-xs" aria-label="unverified">&#x26A0;&#xFE0F;</span>
160
+ </span>
161
+ )
162
+ }}
163
+ </For>
164
+
165
+ {/* Confidence bar */}
166
+ {props.validation && !props.validation.valid && (
167
+ <div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
168
+ <div class="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
169
+ <div
170
+ class="h-full rounded-full transition-all"
171
+ classList={{
172
+ 'bg-green-500': props.validation.confidence >= 0.8,
173
+ 'bg-amber-500': props.validation.confidence >= 0.5 && props.validation.confidence < 0.8,
174
+ 'bg-red-500': props.validation.confidence < 0.5,
175
+ }}
176
+ style={{ width: `${Math.round(props.validation.confidence * 100)}%` }}
177
+ />
178
+ </div>
179
+ <span>
180
+ {Math.round(props.validation.confidence * 100)}% verified
181
+ ({props.validation.hallucinated.length} unverified)
182
+ </span>
183
+ </div>
184
+ )}
185
+ </div>
186
+ )
187
+ }
@@ -64,6 +64,13 @@ export type { CodeBlockRendererProps } from './CodeBlockRenderer'
64
64
  export { MapRenderer } from './MapRenderer'
65
65
  export type { MapRendererProps } from './MapRenderer'
66
66
 
67
+ // Data Verification (v3.1.0 — anti-hallucination)
68
+ export { VerifiedText } from './VerifiedText'
69
+ export type { VerifiedTextProps } from './VerifiedText'
70
+
71
+ export { DataPreviewSection } from './DataPreviewSection'
72
+ export type { DataPreviewSectionProps } from './DataPreviewSection'
73
+
67
74
  // Sprint Ultimate: RenderContext for circular dependency resolution
68
75
  export { RenderContext, RenderProvider, useRenderContext } from './RenderContext'
69
76
  export type { RenderContextValue, RenderComponentFn } from './RenderContext'
@@ -57,3 +57,10 @@ export type {
57
57
  UseAutocompleteOptions,
58
58
  UseAutocompleteReturn,
59
59
  } from './useAutocomplete'
60
+
61
+ // Data Validator hooks (v3.1.0 — anti-hallucination)
62
+ export { useDataValidator } from './useDataValidator'
63
+ export type {
64
+ UseDataValidatorOptions,
65
+ UseDataValidatorReturn,
66
+ } from './useDataValidator'
@@ -0,0 +1,68 @@
1
+ /**
2
+ * useDataValidator — reactive SolidJS hook for data validation
3
+ * v3.1.0: Wraps validateAgainstSource in a reactive memo
4
+ *
5
+ * @experimental
6
+ */
7
+
8
+ import { createMemo } from 'solid-js'
9
+ import { validateAgainstSource } from '../services/data-validator'
10
+ import type { DataValidation, DataValidationOptions } from '../types/chat-bus'
11
+
12
+ export interface UseDataValidatorOptions extends DataValidationOptions {
13
+ /** Disable validation (returns null) */
14
+ enabled?: boolean
15
+ }
16
+
17
+ export interface UseDataValidatorReturn {
18
+ /** Reactive validation result (null if disabled or no text) */
19
+ validation: () => DataValidation | null
20
+ /** Is the text valid (no hallucinations)? */
21
+ valid: () => boolean
22
+ /** Confidence score 0-1 */
23
+ confidence: () => number
24
+ /** Count of hallucinated numbers */
25
+ hallucinatedCount: () => number
26
+ }
27
+
28
+ /**
29
+ * Reactive hook that validates LLM text against source data rows.
30
+ * Re-validates automatically when text or rows change.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * const { validation, valid, confidence } = useDataValidator(
35
+ * () => llmResponse(),
36
+ * () => sourceRows(),
37
+ * { tolerance: 0.02, ignoreColumns: ['code_geo'] }
38
+ * )
39
+ *
40
+ * return (
41
+ * <Show when={!valid()}>
42
+ * <span>⚠️ {validation()!.hallucinated.length} unverified numbers</span>
43
+ * </Show>
44
+ * )
45
+ * ```
46
+ */
47
+ export function useDataValidator(
48
+ text: () => string,
49
+ sourceRows: () => Record<string, unknown>[],
50
+ options: UseDataValidatorOptions = {}
51
+ ): UseDataValidatorReturn {
52
+ const { enabled = true, ...validationOptions } = options
53
+
54
+ const validation = createMemo<DataValidation | null>(() => {
55
+ if (!enabled) return null
56
+ const t = text()
57
+ const rows = sourceRows()
58
+ if (!t || !rows || rows.length === 0) return null
59
+ return validateAgainstSource(t, rows, validationOptions)
60
+ })
61
+
62
+ return {
63
+ validation,
64
+ valid: () => validation()?.valid ?? true,
65
+ confidence: () => validation()?.confidence ?? 1,
66
+ hallucinatedCount: () => validation()?.hallucinated.length ?? 0,
67
+ }
68
+ }