@seed-ship/mcp-ui-solid 3.0.5 → 4.0.1

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 +213 -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 +213 -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 +83 -1
  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 +84 -2
  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 +251 -0
  113. package/src/components/MapRenderer.test.tsx +94 -5
  114. package/src/components/MapRenderer.tsx +246 -45
  115. package/src/components/ScratchpadPanel.tsx +19 -3
  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
package/dist/types.d.ts CHANGED
@@ -64,10 +64,17 @@ export interface ChartComponentParams {
64
64
  labels: string[];
65
65
  datasets: Array<{
66
66
  label: string;
67
- data: number[];
67
+ data: number[] | Array<{
68
+ x: string | number;
69
+ y: number;
70
+ }>;
68
71
  backgroundColor?: string | string[];
69
72
  borderColor?: string | string[];
70
73
  borderWidth?: number;
74
+ /** Fill area under line (useful for time-series) */
75
+ fill?: boolean | string;
76
+ /** Line tension (0 = straight, 0.4 = smooth) */
77
+ tension?: number;
71
78
  }>;
72
79
  };
73
80
  options?: {
@@ -88,6 +95,22 @@ export interface ChartComponentParams {
88
95
  * Enable PNG export button (v2.2.0)
89
96
  */
90
97
  exportable?: boolean;
98
+ /**
99
+ * Time-series axis configuration (v3.1.0).
100
+ * When set, x-axis labels are parsed as dates.
101
+ */
102
+ timeAxis?: {
103
+ /** Date format for parsing labels (Chart.js adapter format, e.g. 'yyyy-MM-dd') */
104
+ parser?: string;
105
+ /** Display unit for x-axis ticks */
106
+ unit?: 'day' | 'week' | 'month' | 'quarter' | 'year';
107
+ /** Date format for tooltip display */
108
+ tooltipFormat?: string;
109
+ /** Min date (ISO string) */
110
+ min?: string;
111
+ /** Max date (ISO string) */
112
+ max?: string;
113
+ };
91
114
  /**
92
115
  * Chart container height as CSS value (v2.2.0, default '250px')
93
116
  */
@@ -602,9 +625,57 @@ export interface MapClusterOptions {
602
625
  */
603
626
  animateAddingMarkers?: boolean;
604
627
  }
628
+ /**
629
+ * GeoJSON feature popup configuration (v3.1.0)
630
+ */
631
+ export interface MapPopupConfig {
632
+ /** Property key used as popup title */
633
+ titleField?: string;
634
+ /** Property keys to display in popup body */
635
+ fields?: string[];
636
+ /** Custom HTML template (use {{property}} placeholders) */
637
+ template?: string;
638
+ }
639
+ /**
640
+ * GeoJSON style configuration (v3.1.0)
641
+ * Supports static styles and choropleth (data-driven) coloring.
642
+ */
643
+ export interface MapGeoJSONStyle {
644
+ /** Fill color (CSS color or choropleth config) */
645
+ fillColor?: string;
646
+ /** Fill opacity (0-1, default: 0.6) */
647
+ fillOpacity?: number;
648
+ /** Stroke color (default: '#333') */
649
+ strokeColor?: string;
650
+ /** Stroke width (default: 1) */
651
+ strokeWeight?: number;
652
+ /** Stroke opacity (0-1, default: 1) */
653
+ strokeOpacity?: number;
654
+ /** Choropleth: property key for data-driven coloring */
655
+ choroplethField?: string;
656
+ /** Choropleth: color scale stops [value, color][] sorted ascending */
657
+ choroplethScale?: Array<[number, string]>;
658
+ /** Choropleth: color for features with missing/null values */
659
+ choroplethFallback?: string;
660
+ }
661
+ /**
662
+ * Named GeoJSON layer for multi-layer maps (v3.1.0)
663
+ */
664
+ export interface MapLayer {
665
+ /** Layer name (shown in layer control) */
666
+ name: string;
667
+ /** Is this layer visible by default? */
668
+ visible?: boolean;
669
+ /** GeoJSON FeatureCollection (inline or from API) */
670
+ geojson: unknown;
671
+ /** Per-layer style override */
672
+ style?: MapGeoJSONStyle;
673
+ /** Per-layer popup config */
674
+ popup?: MapPopupConfig;
675
+ }
605
676
  /**
606
677
  * Map component parameters (Sprint 6)
607
- * Updated Sprint Ultimate U.2: Added clustering support
678
+ * Updated v3.1.0: GeoJSON, choropleth, popups, layers
608
679
  */
609
680
  export interface MapComponentParams {
610
681
  /**
@@ -624,7 +695,7 @@ export interface MapComponentParams {
624
695
  */
625
696
  height?: string;
626
697
  /**
627
- * Auto-fit bounds to show all markers (default: false)
698
+ * Auto-fit bounds to show all markers/features (default: false)
628
699
  */
629
700
  fitBounds?: boolean;
630
701
  /**
@@ -645,15 +716,73 @@ export interface MapComponentParams {
645
716
  attribution?: string;
646
717
  /**
647
718
  * Enable marker clustering (Sprint Ultimate U.2)
648
- * - true: Enable with default options
649
- * - false: Disable clustering
650
- * - MapClusterOptions: Enable with custom options
651
719
  */
652
720
  clustering?: boolean | MapClusterOptions;
653
721
  /**
654
722
  * Custom CSS class (Sprint 7)
655
723
  */
656
724
  className?: string;
725
+ /**
726
+ * GeoJSON FeatureCollection to render on the map.
727
+ * Use this for polygons, lines, points from structured data.
728
+ */
729
+ geojson?: unknown;
730
+ /**
731
+ * Style for the GeoJSON layer.
732
+ * Supports static colors and choropleth (data-driven) coloring.
733
+ */
734
+ geojsonStyle?: MapGeoJSONStyle;
735
+ /**
736
+ * Popup configuration for GeoJSON features.
737
+ * Shown on feature click.
738
+ */
739
+ popup?: MapPopupConfig;
740
+ /**
741
+ * Named layers for multi-layer maps.
742
+ * Each layer has its own GeoJSON, style, and popup config.
743
+ * A Leaflet layer control is added when layers are present.
744
+ */
745
+ layers?: MapLayer[];
746
+ /**
747
+ * PMTiles vector tile source for large datasets (>5000 features).
748
+ * Requires protomaps-leaflet peer dependency.
749
+ * Pipeline: GeoParquet -> Tippecanoe -> PMTiles (static file on S3/CDN).
750
+ */
751
+ pmtiles?: MapPMTilesConfig;
752
+ }
753
+ /**
754
+ * PMTiles configuration for large vector tile datasets (v3.1.0)
755
+ */
756
+ export interface MapPMTilesConfig {
757
+ /** URL to the .pmtiles file (S3, CDN, local) */
758
+ url: string;
759
+ /** Attribution text for this tile source */
760
+ attribution?: string;
761
+ /** Style rules for vector features */
762
+ paintRules?: Array<{
763
+ /** Layer name in the PMTiles source */
764
+ dataLayer: string;
765
+ /** Symbol type */
766
+ symbolizer: 'polygon' | 'line' | 'circle';
767
+ /** Fill/stroke color (CSS color or function name) */
768
+ color?: string;
769
+ /** Stroke width */
770
+ width?: number;
771
+ /** Fill opacity */
772
+ opacity?: number;
773
+ }>;
774
+ /** Label rules for text labels */
775
+ labelRules?: Array<{
776
+ dataLayer: string;
777
+ /** Property key for label text */
778
+ textField: string;
779
+ /** Font size */
780
+ fontSize?: number;
781
+ }>;
782
+ /** Max zoom level */
783
+ maxZoom?: number;
784
+ /** Min zoom level */
785
+ minZoom?: number;
657
786
  }
658
787
  /**
659
788
  * Grid component parameters (Phase 5.0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "3.0.5",
3
+ "version": "4.0.1",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -112,6 +112,7 @@
112
112
  "highlight.js": "^11.9.0",
113
113
  "leaflet": "^1.9.4",
114
114
  "leaflet.markercluster": "^1.5.0",
115
+ "protomaps-leaflet": "^4.0.0",
115
116
  "solid-js": "^1.9.0"
116
117
  },
117
118
  "peerDependenciesMeta": {
@@ -132,6 +133,9 @@
132
133
  },
133
134
  "@tanstack/solid-virtual": {
134
135
  "optional": true
136
+ },
137
+ "protomaps-leaflet": {
138
+ "optional": true
135
139
  }
136
140
  },
137
141
  "dependencies": {
@@ -117,23 +117,45 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
117
117
  chartInstance = null
118
118
  }
119
119
 
120
+ // Build options, merging time-axis config if present (v3.1.0)
121
+ const baseOptions: any = {
122
+ responsive: true,
123
+ maintainAspectRatio: false,
124
+ ...chartParams.options,
125
+ plugins: {
126
+ ...chartParams.options?.plugins,
127
+ legend: {
128
+ display: true,
129
+ position: 'bottom',
130
+ ...chartParams.options?.plugins?.legend,
131
+ },
132
+ },
133
+ }
134
+
135
+ // Time-series axis (v3.1.0)
136
+ if (chartParams.timeAxis) {
137
+ const ta = chartParams.timeAxis
138
+ baseOptions.scales = {
139
+ ...baseOptions.scales,
140
+ x: {
141
+ ...baseOptions.scales?.x,
142
+ type: 'time',
143
+ time: {
144
+ parser: ta.parser,
145
+ unit: ta.unit,
146
+ tooltipFormat: ta.tooltipFormat,
147
+ },
148
+ ...(ta.min ? { min: ta.min } : {}),
149
+ ...(ta.max ? { max: ta.max } : {}),
150
+ },
151
+ }
152
+ }
153
+
120
154
  // Create new chart
121
155
  chartInstance = new Chart(canvasRef, {
122
156
  type: chartParams.type,
123
157
  data: chartParams.data,
124
- options: {
125
- responsive: true,
126
- maintainAspectRatio: false,
127
- ...chartParams.options,
128
- plugins: {
129
- ...chartParams.options?.plugins,
130
- legend: {
131
- display: true,
132
- position: 'bottom',
133
- ...chartParams.options?.plugins?.legend,
134
- },
135
- },
136
- },
158
+ options: baseOptions,
137
159
  })
138
160
 
139
161
  setIsLoading(false)
@@ -0,0 +1,251 @@
1
+ /**
2
+ * DataPreviewSection — paginated data table with export
3
+ * v4.0.1: Fixed rendering — defensive guards for store proxy content
4
+ *
5
+ * @experimental
6
+ *
7
+ * Features:
8
+ * - Column types (number right-aligned, string left-aligned)
9
+ * - Pagination (configurable page size)
10
+ * - CSV / JSON export buttons
11
+ * - Source attribution + freshness label
12
+ * - Number formatting (FR locale)
13
+ */
14
+
15
+ import { createSignal, createMemo, For, Show } from 'solid-js'
16
+ import type { DataPreviewContent, DataPreviewColumn } from '../types/chat-bus'
17
+
18
+ export interface DataPreviewSectionProps {
19
+ content: DataPreviewContent
20
+ }
21
+
22
+ /** Format a number for display (French locale) */
23
+ function formatNumber(value: unknown, format?: string): string {
24
+ if (typeof value !== 'number' || !isFinite(value)) return String(value ?? '')
25
+ if (format === 'percent') return `${(value * 100).toFixed(1)}%`
26
+ if (format === 'currency') return `${value.toLocaleString('fr-FR')} EUR`
27
+ if (Number.isInteger(value)) return value.toLocaleString('fr-FR')
28
+ return value.toLocaleString('fr-FR', { maximumFractionDigits: 2 })
29
+ }
30
+
31
+ /** Format a cell value based on column type */
32
+ function formatCell(value: unknown, col: DataPreviewColumn): string {
33
+ if (value == null) return '\u2014'
34
+ if (col.type === 'number') return formatNumber(value, col.format)
35
+ if (col.type === 'date' && typeof value === 'string') {
36
+ try {
37
+ return new Date(value).toLocaleDateString('fr-FR')
38
+ } catch {
39
+ return value
40
+ }
41
+ }
42
+ return String(value)
43
+ }
44
+
45
+ /** Generate CSV from columns + rows */
46
+ function toCSV(columns: DataPreviewColumn[], rows: Record<string, unknown>[]): string {
47
+ const header = columns.map(c => `"${c.label.replace(/"/g, '""')}"`).join(';')
48
+ const body = rows.map(row =>
49
+ columns.map(c => {
50
+ const val = row[c.key]
51
+ if (val == null) return ''
52
+ if (typeof val === 'string') return `"${val.replace(/"/g, '""')}"`
53
+ return String(val)
54
+ }).join(';')
55
+ ).join('\n')
56
+ return `${header}\n${body}`
57
+ }
58
+
59
+ /** Trigger browser download */
60
+ function downloadFile(content: string, filename: string, mimeType: string) {
61
+ const blob = new Blob([content], { type: mimeType })
62
+ const url = URL.createObjectURL(blob)
63
+ const a = document.createElement('a')
64
+ a.href = url
65
+ a.download = filename
66
+ a.click()
67
+ URL.revokeObjectURL(url)
68
+ }
69
+
70
+ /**
71
+ * Extract a valid DataPreviewContent from props.content.
72
+ * Handles: direct DataPreviewContent, or wrapped in an extra layer.
73
+ */
74
+ function resolveContent(raw: unknown): DataPreviewContent | null {
75
+ if (!raw || typeof raw !== 'object') return null
76
+ const obj = raw as Record<string, unknown>
77
+
78
+ // Direct shape: { columns: [...], rows: [...] }
79
+ if (Array.isArray(obj.columns) && Array.isArray(obj.rows)) {
80
+ return obj as unknown as DataPreviewContent
81
+ }
82
+
83
+ // Wrapped shape: { content: { columns: [...], rows: [...] } }
84
+ if (obj.content && typeof obj.content === 'object') {
85
+ const inner = obj.content as Record<string, unknown>
86
+ if (Array.isArray(inner.columns) && Array.isArray(inner.rows)) {
87
+ return inner as unknown as DataPreviewContent
88
+ }
89
+ }
90
+
91
+ return null
92
+ }
93
+
94
+ export function DataPreviewSection(props: DataPreviewSectionProps) {
95
+ const content = createMemo(() => {
96
+ const resolved = resolveContent(props.content)
97
+ if (!resolved) {
98
+ console.warn(
99
+ '[MCP-UI] DataPreviewSection: invalid content — expected { columns: [...], rows: [...] }, got:',
100
+ props.content
101
+ )
102
+ }
103
+ return resolved
104
+ })
105
+
106
+ const columns = () => content()?.columns || []
107
+ const rows = () => content()?.rows || []
108
+ const pageSize = () => content()?.pageSize || 25
109
+ const [page, setPage] = createSignal(0)
110
+
111
+ const totalRows = () => rows().length
112
+ const totalPages = () => Math.max(1, Math.ceil(totalRows() / pageSize()))
113
+
114
+ const pagedRows = createMemo(() => {
115
+ const start = page() * pageSize()
116
+ return rows().slice(start, start + pageSize())
117
+ })
118
+
119
+ const handleExportCSV = () => {
120
+ const c = content()
121
+ if (!c) return
122
+ const csv = toCSV(c.columns, c.rows)
123
+ downloadFile(csv, 'data-export.csv', 'text/csv;charset=utf-8')
124
+ }
125
+
126
+ const handleExportJSON = () => {
127
+ const json = JSON.stringify(rows(), null, 2)
128
+ downloadFile(json, 'data-export.json', 'application/json')
129
+ }
130
+
131
+ const columnAlign = (col: DataPreviewColumn) => {
132
+ if (col.align) return col.align
133
+ if (col.type === 'number') return 'right'
134
+ return 'left'
135
+ }
136
+
137
+ return (
138
+ <Show when={content()} fallback={
139
+ <div class="text-xs text-amber-600 dark:text-amber-400 p-2">
140
+ [DataPreviewSection] Invalid content format
141
+ </div>
142
+ }>
143
+ {(c) => (
144
+ <div class="data-preview-section">
145
+ {/* Header with source + export */}
146
+ <div class="flex items-center justify-between mb-2">
147
+ <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
148
+ <Show when={c().source}>
149
+ <span class="font-medium">{c().source}</span>
150
+ </Show>
151
+ <Show when={c().freshness}>
152
+ <span class="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
153
+ {c().freshness}
154
+ </span>
155
+ </Show>
156
+ </div>
157
+
158
+ <Show when={c().exportable !== false}>
159
+ <div class="flex items-center gap-1">
160
+ <button
161
+ class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
162
+ onClick={handleExportCSV}
163
+ title="Export CSV"
164
+ >
165
+ CSV
166
+ </button>
167
+ <button
168
+ class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
169
+ onClick={handleExportJSON}
170
+ title="Export JSON"
171
+ >
172
+ JSON
173
+ </button>
174
+ </div>
175
+ </Show>
176
+ </div>
177
+
178
+ {/* Table */}
179
+ <div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-700">
180
+ <table class="w-full text-sm">
181
+ <thead>
182
+ <tr class="bg-gray-50 dark:bg-gray-800">
183
+ <For each={columns()}>
184
+ {(col) => (
185
+ <th
186
+ class="px-3 py-2 font-medium text-xs text-gray-600 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700"
187
+ style={{ "text-align": columnAlign(col) }}
188
+ >
189
+ {col.label}
190
+ </th>
191
+ )}
192
+ </For>
193
+ </tr>
194
+ </thead>
195
+ <tbody>
196
+ <For each={pagedRows()}>
197
+ {(row, i) => (
198
+ <tr
199
+ class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
200
+ classList={{ 'bg-gray-25 dark:bg-gray-850': i() % 2 === 1 }}
201
+ >
202
+ <For each={columns()}>
203
+ {(col) => (
204
+ <td
205
+ class="px-3 py-2 text-gray-800 dark:text-gray-200"
206
+ style={{ "text-align": columnAlign(col) }}
207
+ >
208
+ {formatCell(row[col.key], col)}
209
+ </td>
210
+ )}
211
+ </For>
212
+ </tr>
213
+ )}
214
+ </For>
215
+ </tbody>
216
+ </table>
217
+ </div>
218
+
219
+ {/* Footer: pagination + row count */}
220
+ <div class="flex items-center justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
221
+ <span>
222
+ {c().totalRows
223
+ ? `${totalRows()} / ${c().totalRows!.toLocaleString('fr-FR')} rows`
224
+ : `${totalRows()} row${totalRows() !== 1 ? 's' : ''}`}
225
+ </span>
226
+
227
+ <Show when={totalPages() > 1}>
228
+ <div class="flex items-center gap-1">
229
+ <button
230
+ class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
231
+ disabled={page() === 0}
232
+ onClick={() => setPage(p => p - 1)}
233
+ >
234
+ &laquo;
235
+ </button>
236
+ <span>{page() + 1} / {totalPages()}</span>
237
+ <button
238
+ class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
239
+ disabled={page() >= totalPages() - 1}
240
+ onClick={() => setPage(p => p + 1)}
241
+ >
242
+ &raquo;
243
+ </button>
244
+ </div>
245
+ </Show>
246
+ </div>
247
+ </div>
248
+ )}
249
+ </Show>
250
+ )
251
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * MapRenderer Tests
3
- * Sprint 6 Refinement
3
+ * Sprint 6 + v3.1.0: GeoJSON, choropleth, popups
4
4
  */
5
5
 
6
6
  import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -24,6 +24,7 @@ const mapMock = {
24
24
  remove: removeMock,
25
25
  getZoom: vi.fn(() => 13),
26
26
  fitBounds: fitBoundsMock.mockReturnThis(),
27
+ addLayer: addLayerMock,
27
28
  }
28
29
 
29
30
  const markerMock = {
@@ -37,11 +38,21 @@ const tileLayerMock = {
37
38
  }
38
39
 
39
40
  const controlMock = {
40
- attribution: vi.fn(() => ({ addTo: vi.fn() }))
41
+ attribution: vi.fn(() => ({ addTo: vi.fn() })),
42
+ layers: vi.fn(() => ({ addTo: vi.fn() }))
41
43
  }
42
44
 
43
45
  const featureGroupMock = {
44
- getBounds: vi.fn(() => ({ pad: vi.fn() }))
46
+ getBounds: vi.fn(() => ({ pad: vi.fn(), isValid: vi.fn(() => true) }))
47
+ }
48
+
49
+ const geoJSONMock = {
50
+ addTo: vi.fn().mockReturnThis(),
51
+ getBounds: vi.fn(() => ({ pad: vi.fn(), isValid: vi.fn(() => true) })),
52
+ }
53
+
54
+ const circleMarkerMock = {
55
+ bindPopup: vi.fn().mockReturnThis(),
45
56
  }
46
57
 
47
58
  vi.mock('leaflet', () => ({
@@ -51,6 +62,11 @@ vi.mock('leaflet', () => ({
51
62
  marker: vi.fn(() => markerMock),
52
63
  featureGroup: vi.fn(() => featureGroupMock),
53
64
  control: controlMock,
65
+ geoJSON: vi.fn(() => geoJSONMock),
66
+ circleMarker: vi.fn(() => circleMarkerMock),
67
+ GeoJSON: class {},
68
+ CircleMarker: class {},
69
+ Marker: class {},
54
70
  Icon: {
55
71
  Default: {
56
72
  prototype: { _getIconUrl: vi.fn() },
@@ -82,8 +98,7 @@ describe('MapRenderer', () => {
82
98
  expect(mapDiv).toBeTruthy()
83
99
  })
84
100
 
85
- it('renders with new marker format', () => {
86
- // This test mostly verifies type check and structural validity since actual logic is mocked
101
+ it('renders with marker format', () => {
87
102
  const { container } = render(() => <MapRenderer params={{
88
103
  markers: [{ position: [10, 20], tooltip: 'Hello', popup: 'World' }]
89
104
  }} />)
@@ -97,4 +112,78 @@ describe('MapRenderer', () => {
97
112
  }} />)
98
113
  expect(container).toBeTruthy()
99
114
  })
115
+
116
+ // ─── GeoJSON tests (v3.1.0) ────────────────────────
117
+
118
+ const SAMPLE_GEOJSON = {
119
+ type: 'FeatureCollection',
120
+ features: [
121
+ {
122
+ type: 'Feature',
123
+ geometry: { type: 'Polygon', coordinates: [[[2.3, 48.8], [2.4, 48.8], [2.4, 48.9], [2.3, 48.9], [2.3, 48.8]]] },
124
+ properties: { name: 'Zone A', prix_m2: 3500 }
125
+ },
126
+ {
127
+ type: 'Feature',
128
+ geometry: { type: 'Polygon', coordinates: [[[2.4, 48.8], [2.5, 48.8], [2.5, 48.9], [2.4, 48.9], [2.4, 48.8]]] },
129
+ properties: { name: 'Zone B', prix_m2: 4200 }
130
+ }
131
+ ]
132
+ }
133
+
134
+ it('renders with GeoJSON data', () => {
135
+ const { container } = render(() => <MapRenderer params={{
136
+ geojson: SAMPLE_GEOJSON,
137
+ fitBounds: true,
138
+ }} />)
139
+ expect(container).toBeTruthy()
140
+ })
141
+
142
+ it('renders with GeoJSON + popup config', () => {
143
+ const { container } = render(() => <MapRenderer params={{
144
+ geojson: SAMPLE_GEOJSON,
145
+ popup: { titleField: 'name', fields: ['prix_m2'] },
146
+ }} />)
147
+ expect(container).toBeTruthy()
148
+ })
149
+
150
+ it('renders with choropleth style', () => {
151
+ const { container } = render(() => <MapRenderer params={{
152
+ geojson: SAMPLE_GEOJSON,
153
+ geojsonStyle: {
154
+ choroplethField: 'prix_m2',
155
+ choroplethScale: [[2000, '#eff3ff'], [3000, '#6baed6'], [5000, '#084594']],
156
+ fillOpacity: 0.7,
157
+ },
158
+ }} />)
159
+ expect(container).toBeTruthy()
160
+ })
161
+
162
+ it('renders with multiple named layers', () => {
163
+ const { container } = render(() => <MapRenderer params={{
164
+ layers: [
165
+ { name: 'Zones', geojson: SAMPLE_GEOJSON, visible: true },
166
+ { name: 'Points', geojson: { type: 'FeatureCollection', features: [] }, visible: false },
167
+ ],
168
+ }} />)
169
+ expect(container).toBeTruthy()
170
+ })
171
+
172
+ it('renders with custom height', () => {
173
+ const { container } = render(() => <MapRenderer params={{
174
+ geojson: SAMPLE_GEOJSON,
175
+ height: '600px',
176
+ }} />)
177
+ const mapDiv = container.querySelector('div[style*="height: 600px"]')
178
+ expect(mapDiv).toBeTruthy()
179
+ })
180
+
181
+ it('renders with className', () => {
182
+ const { container } = render(() => <MapRenderer params={{
183
+ geojson: SAMPLE_GEOJSON,
184
+ className: 'custom-map',
185
+ }} />)
186
+ const wrapper = container.querySelector('.custom-map')
187
+ expect(wrapper).toBeTruthy()
188
+ })
100
189
  })