@object-ui/plugin-map 0.3.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.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * ObjectMap Component
11
+ *
12
+ * A specialized map visualization component that works with ObjectQL data sources.
13
+ * Displays records as markers/pins on a map based on location data.
14
+ * Implements the map view type from @objectstack/spec view.zod ListView schema.
15
+ *
16
+ * Features:
17
+ * - Interactive map with markers
18
+ * - Location-based data visualization
19
+ * - Marker clustering (when many points)
20
+ * - Popup/tooltip on marker click
21
+ * - Works with object/api/value data providers
22
+ *
23
+ * Note: This is a basic implementation. For production use, integrate with a
24
+ * proper mapping library like Mapbox, Leaflet, or Google Maps.
25
+ */
26
+
27
+ import React, { useEffect, useState, useMemo } from 'react';
28
+ import type { ObjectGridSchema, DataSource, ViewData } from '@object-ui/types';
29
+
30
+ export interface ObjectMapProps {
31
+ schema: ObjectGridSchema;
32
+ dataSource?: DataSource;
33
+ className?: string;
34
+ onMarkerClick?: (record: any) => void;
35
+ onEdit?: (record: any) => void;
36
+ onDelete?: (record: any) => void;
37
+ }
38
+
39
+ interface MapConfig {
40
+ /** Field containing latitude value */
41
+ latitudeField?: string;
42
+ /** Field containing longitude value */
43
+ longitudeField?: string;
44
+ /** Field containing combined location (e.g., "lat,lng" or location object) */
45
+ locationField?: string;
46
+ /** Field to use for marker title/label */
47
+ titleField?: string;
48
+ /** Field to use for marker description */
49
+ descriptionField?: string;
50
+ /** Default zoom level (1-20) */
51
+ zoom?: number;
52
+ /** Center coordinates [lat, lng] */
53
+ center?: [number, number];
54
+ }
55
+
56
+ /**
57
+ * Helper to get data configuration from schema
58
+ */
59
+ function getDataConfig(schema: ObjectGridSchema): ViewData | null {
60
+ if (schema.data) {
61
+ return schema.data;
62
+ }
63
+
64
+ if (schema.staticData) {
65
+ return {
66
+ provider: 'value',
67
+ items: schema.staticData,
68
+ };
69
+ }
70
+
71
+ if (schema.objectName) {
72
+ return {
73
+ provider: 'object',
74
+ object: schema.objectName,
75
+ };
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Helper to convert sort config to QueryParams format
83
+ */
84
+ function convertSortToQueryParams(sort: string | any[] | undefined): Record<string, 'asc' | 'desc'> | undefined {
85
+ if (!sort) return undefined;
86
+
87
+ // If it's a string like "name desc"
88
+ if (typeof sort === 'string') {
89
+ const parts = sort.split(' ');
90
+ const field = parts[0];
91
+ const order = (parts[1]?.toLowerCase() === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc';
92
+ return { [field]: order };
93
+ }
94
+
95
+ // If it's an array of SortConfig objects
96
+ if (Array.isArray(sort)) {
97
+ return sort.reduce((acc, item) => {
98
+ if (item.field && item.order) {
99
+ acc[item.field] = item.order;
100
+ }
101
+ return acc;
102
+ }, {} as Record<string, 'asc' | 'desc'>);
103
+ }
104
+
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Helper to get map configuration from schema
110
+ */
111
+ function getMapConfig(schema: ObjectGridSchema): MapConfig {
112
+ // Check if schema has map configuration
113
+ if (schema.filter && typeof schema.filter === 'object' && 'map' in schema.filter) {
114
+ return (schema.filter as any).map as MapConfig;
115
+ }
116
+
117
+ // For backward compatibility, check if schema has map config at root
118
+ if ((schema as any).map) {
119
+ return (schema as any).map as MapConfig;
120
+ }
121
+
122
+ // Default configuration
123
+ return {
124
+ latitudeField: 'latitude',
125
+ longitudeField: 'longitude',
126
+ locationField: 'location',
127
+ titleField: 'name',
128
+ descriptionField: 'description',
129
+ zoom: 10,
130
+ center: [0, 0],
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Extract coordinates from a record based on configuration
136
+ */
137
+ function extractCoordinates(record: any, config: MapConfig): [number, number] | null {
138
+ // Try latitude/longitude fields
139
+ if (config.latitudeField && config.longitudeField) {
140
+ const lat = record[config.latitudeField];
141
+ const lng = record[config.longitudeField];
142
+ if (typeof lat === 'number' && typeof lng === 'number') {
143
+ return [lat, lng];
144
+ }
145
+ }
146
+
147
+ // Try location field
148
+ if (config.locationField) {
149
+ const location = record[config.locationField];
150
+
151
+ // Handle object format: { lat: number, lng: number }
152
+ if (typeof location === 'object' && location !== null) {
153
+ const lat = location.lat || location.latitude;
154
+ const lng = location.lng || location.lon || location.longitude;
155
+ if (typeof lat === 'number' && typeof lng === 'number') {
156
+ return [lat, lng];
157
+ }
158
+ }
159
+
160
+ // Handle string format: "lat,lng"
161
+ if (typeof location === 'string') {
162
+ const parts = location.split(',').map(s => parseFloat(s.trim()));
163
+ if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
164
+ return [parts[0], parts[1]];
165
+ }
166
+ }
167
+
168
+ // Handle array format: [lat, lng]
169
+ if (Array.isArray(location) && location.length === 2) {
170
+ const lat = parseFloat(location[0]);
171
+ const lng = parseFloat(location[1]);
172
+ if (!isNaN(lat) && !isNaN(lng)) {
173
+ return [lat, lng];
174
+ }
175
+ }
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ export const ObjectMap: React.FC<ObjectMapProps> = ({
182
+ schema,
183
+ dataSource,
184
+ className,
185
+ onMarkerClick,
186
+ }) => {
187
+ const [data, setData] = useState<any[]>([]);
188
+ const [loading, setLoading] = useState(true);
189
+ const [error, setError] = useState<Error | null>(null);
190
+ const [objectSchema, setObjectSchema] = useState<any>(null);
191
+ const [selectedMarker, setSelectedMarker] = useState<string | null>(null);
192
+
193
+ const dataConfig = getDataConfig(schema);
194
+ const mapConfig = getMapConfig(schema);
195
+ const hasInlineData = dataConfig?.provider === 'value';
196
+
197
+ // Fetch data based on provider
198
+ useEffect(() => {
199
+ const fetchData = async () => {
200
+ try {
201
+ setLoading(true);
202
+
203
+ if (hasInlineData && dataConfig?.provider === 'value') {
204
+ setData(dataConfig.items as any[]);
205
+ setLoading(false);
206
+ return;
207
+ }
208
+
209
+ if (!dataSource) {
210
+ throw new Error('DataSource required for object/api providers');
211
+ }
212
+
213
+ if (dataConfig?.provider === 'object') {
214
+ const objectName = dataConfig.object;
215
+ const result = await dataSource.find(objectName, {
216
+ $filter: schema.filter,
217
+ $orderby: convertSortToQueryParams(schema.sort),
218
+ });
219
+ setData(result?.data || []);
220
+ } else if (dataConfig?.provider === 'api') {
221
+ console.warn('API provider not yet implemented for ObjectMap');
222
+ setData([]);
223
+ }
224
+
225
+ setLoading(false);
226
+ } catch (err) {
227
+ setError(err as Error);
228
+ setLoading(false);
229
+ }
230
+ };
231
+
232
+ fetchData();
233
+ }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]);
234
+
235
+ // Fetch object schema for field metadata
236
+ useEffect(() => {
237
+ const fetchObjectSchema = async () => {
238
+ try {
239
+ if (!dataSource) return;
240
+
241
+ const objectName = dataConfig?.provider === 'object'
242
+ ? dataConfig.object
243
+ : schema.objectName;
244
+
245
+ if (!objectName) return;
246
+
247
+ const schemaData = await dataSource.getObjectSchema(objectName);
248
+ setObjectSchema(schemaData);
249
+ } catch (err) {
250
+ console.error('Failed to fetch object schema:', err);
251
+ }
252
+ };
253
+
254
+ if (!hasInlineData && dataSource) {
255
+ fetchObjectSchema();
256
+ }
257
+ }, [schema.objectName, dataSource, hasInlineData, dataConfig]);
258
+
259
+ // Transform data to map markers
260
+ const markers = useMemo(() => {
261
+ return data
262
+ .map((record, index) => {
263
+ const coordinates = extractCoordinates(record, mapConfig);
264
+ if (!coordinates) return null;
265
+
266
+ const title = mapConfig.titleField ? record[mapConfig.titleField] : 'Marker';
267
+ const description = mapConfig.descriptionField ? record[mapConfig.descriptionField] : undefined;
268
+
269
+ return {
270
+ id: record.id || record._id || `marker-${index}`,
271
+ title,
272
+ description,
273
+ coordinates,
274
+ data: record,
275
+ };
276
+ })
277
+ .filter((marker): marker is NonNullable<typeof marker> => marker !== null);
278
+ }, [data, mapConfig]);
279
+
280
+ // Calculate map bounds
281
+ const bounds = useMemo(() => {
282
+ if (!markers.length) {
283
+ return {
284
+ center: mapConfig.center || [0, 0],
285
+ minLat: (mapConfig.center?.[0] || 0) - 0.1,
286
+ maxLat: (mapConfig.center?.[0] || 0) + 0.1,
287
+ minLng: (mapConfig.center?.[1] || 0) - 0.1,
288
+ maxLng: (mapConfig.center?.[1] || 0) + 0.1,
289
+ };
290
+ }
291
+
292
+ const lats = markers.map(m => m.coordinates[0]);
293
+ const lngs = markers.map(m => m.coordinates[1]);
294
+
295
+ return {
296
+ center: [
297
+ (Math.min(...lats) + Math.max(...lats)) / 2,
298
+ (Math.min(...lngs) + Math.max(...lngs)) / 2,
299
+ ] as [number, number],
300
+ minLat: Math.min(...lats),
301
+ maxLat: Math.max(...lats),
302
+ minLng: Math.min(...lngs),
303
+ maxLng: Math.max(...lngs),
304
+ };
305
+ }, [markers, mapConfig.center]);
306
+
307
+ if (loading) {
308
+ return (
309
+ <div className={className}>
310
+ <div className="flex items-center justify-center h-96 bg-muted rounded-lg">
311
+ <div className="text-muted-foreground">Loading map...</div>
312
+ </div>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ if (error) {
318
+ return (
319
+ <div className={className}>
320
+ <div className="flex items-center justify-center h-96 bg-muted rounded-lg">
321
+ <div className="text-destructive">Error: {error.message}</div>
322
+ </div>
323
+ </div>
324
+ );
325
+ }
326
+
327
+ return (
328
+ <div className={className}>
329
+ <div className="relative border rounded-lg overflow-hidden bg-muted" style={{ height: '600px' }}>
330
+ {/* Placeholder map - in production, replace with actual map library */}
331
+ <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-green-50 dark:from-blue-950 dark:to-green-950">
332
+ <div className="text-center">
333
+ <div className="text-4xl mb-2">🗺️</div>
334
+ <div className="text-sm text-muted-foreground mb-4">
335
+ Map Visualization (Placeholder)
336
+ </div>
337
+ <div className="text-xs text-muted-foreground max-w-md mx-auto">
338
+ This is a basic map placeholder. For production, integrate with Mapbox, Leaflet, or Google Maps.
339
+ <br />
340
+ <br />
341
+ <strong>Map Info:</strong>
342
+ <br />
343
+ Center: [{bounds.center[0].toFixed(4)}, {bounds.center[1].toFixed(4)}]
344
+ <br />
345
+ Markers: {markers.length}
346
+ </div>
347
+ </div>
348
+ </div>
349
+
350
+ {/* Marker List Overlay */}
351
+ <div className="absolute top-4 right-4 w-64 bg-background border rounded-lg shadow-lg max-h-96 overflow-y-auto">
352
+ <div className="p-3 border-b font-semibold bg-muted">
353
+ Locations ({markers.length})
354
+ </div>
355
+ {markers.length === 0 ? (
356
+ <div className="p-4 text-sm text-muted-foreground text-center">
357
+ No locations found with valid coordinates
358
+ </div>
359
+ ) : (
360
+ <div>
361
+ {markers.map(marker => (
362
+ <div
363
+ key={marker.id}
364
+ className={`p-3 border-b hover:bg-muted/50 cursor-pointer transition-colors ${
365
+ selectedMarker === marker.id ? 'bg-muted' : ''
366
+ }`}
367
+ onClick={() => {
368
+ setSelectedMarker(marker.id);
369
+ onMarkerClick?.(marker.data);
370
+ }}
371
+ >
372
+ <div className="font-medium text-sm">{marker.title}</div>
373
+ {marker.description && (
374
+ <div className="text-xs text-muted-foreground mt-1 line-clamp-2">
375
+ {marker.description}
376
+ </div>
377
+ )}
378
+ <div className="text-xs text-muted-foreground mt-1">
379
+ 📍 {marker.coordinates[0].toFixed(4)}, {marker.coordinates[1].toFixed(4)}
380
+ </div>
381
+ </div>
382
+ ))}
383
+ </div>
384
+ )}
385
+ </div>
386
+
387
+ {/* Legend */}
388
+ <div className="absolute bottom-4 left-4 bg-background border rounded-lg shadow-lg p-3">
389
+ <div className="text-xs font-semibold mb-2">Legend</div>
390
+ <div className="text-xs space-y-1">
391
+ <div className="flex items-center gap-2">
392
+ <div className="w-3 h-3 rounded-full bg-blue-500" />
393
+ <span>Location Marker</span>
394
+ </div>
395
+ </div>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ );
400
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import React from 'react';
10
+ import { ComponentRegistry } from '@object-ui/core';
11
+ import { ObjectMap } from './ObjectMap';
12
+ import type { ObjectMapProps } from './ObjectMap';
13
+
14
+ export { ObjectMap };
15
+ export type { ObjectMapProps };
16
+
17
+ // Register component
18
+ const ObjectMapRenderer: React.FC<{ schema: any }> = ({ schema }) => {
19
+ return <ObjectMap schema={schema} dataSource={null as any} />;
20
+ };
21
+
22
+ ComponentRegistry.register('object-map', ObjectMapRenderer, {
23
+ label: 'Object Map',
24
+ category: 'plugin',
25
+ inputs: [
26
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
27
+ { name: 'map', type: 'object', label: 'Map Config', description: 'latitudeField, longitudeField, titleField' },
28
+ ],
29
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "jsx": "react-jsx",
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "@/*": ["src/*"]
9
+ },
10
+ "noEmit": false,
11
+ "declaration": true,
12
+ "composite": true,
13
+ "declarationMap": true,
14
+ "skipLibCheck": true
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
18
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { defineConfig } from 'vite';
10
+ import react from '@vitejs/plugin-react';
11
+ import dts from 'vite-plugin-dts';
12
+ import { resolve } from 'path';
13
+
14
+ export default defineConfig({
15
+ plugins: [
16
+ react(),
17
+ dts({
18
+ insertTypesEntry: true,
19
+ include: ['src'],
20
+ exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'],
21
+ skipDiagnostics: true,
22
+ }),
23
+ ],
24
+ resolve: {
25
+ alias: {
26
+ '@': resolve(__dirname, './src'),
27
+ },
28
+ },
29
+ build: {
30
+ lib: {
31
+ entry: resolve(__dirname, 'src/index.tsx'),
32
+ name: 'ObjectUIPluginMap',
33
+ fileName: 'index',
34
+ },
35
+ rollupOptions: {
36
+ external: ['react', 'react-dom', '@object-ui/components', '@object-ui/core', '@object-ui/react', '@object-ui/types', 'lucide-react'],
37
+ output: {
38
+ globals: {
39
+ react: 'React',
40
+ 'react-dom': 'ReactDOM',
41
+ '@object-ui/components': 'ObjectUIComponents',
42
+ '@object-ui/core': 'ObjectUICore',
43
+ '@object-ui/react': 'ObjectUIReact',
44
+ '@object-ui/types': 'ObjectUITypes',
45
+ 'lucide-react': 'LucideReact',
46
+ },
47
+ },
48
+ },
49
+ },
50
+ });