@object-ui/plugin-map 3.1.5 → 3.3.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.
- package/CHANGELOG.md +28 -0
- package/README.md +21 -1
- package/dist/{chunk-vKJrgz-R.js → chunk-D8eiyYIV.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1368 -998
- package/dist/index.umd.cjs +69 -52
- package/dist/{maplibre-gl-Dl-lwKEH.js → maplibre-gl-D8guyJSV.js} +6170 -6079
- package/dist/packages/plugin-map/src/ObjectMap.d.ts.map +1 -0
- package/dist/packages/plugin-map/src/index.d.ts.map +1 -0
- package/package.json +37 -14
- package/.turbo/turbo-build.log +0 -29
- package/dist/src/ObjectMap.d.ts.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/src/ObjectMap.test.tsx +0 -110
- package/src/ObjectMap.tsx +0 -633
- package/src/index.test.tsx +0 -27
- package/src/index.tsx +0 -43
- package/tsconfig.json +0 -18
- package/vite.config.ts +0 -54
- package/vitest.config.ts +0 -13
- package/vitest.setup.ts +0 -78
- /package/dist/{src → packages/plugin-map/src}/ObjectMap.d.ts +0 -0
- /package/dist/{src → packages/plugin-map/src}/index.d.ts +0 -0
package/src/ObjectMap.tsx
DELETED
|
@@ -1,633 +0,0 @@
|
|
|
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
|
-
* - Popup/tooltip on marker click
|
|
20
|
-
* - Works with object/api/value data providers
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import React, { useEffect, useState, useMemo } from 'react';
|
|
24
|
-
import type { ObjectGridSchema, DataSource, ViewData } from '@object-ui/types';
|
|
25
|
-
import { useNavigationOverlay } from '@object-ui/react';
|
|
26
|
-
import { NavigationOverlay, cn } from '@object-ui/components';
|
|
27
|
-
import { extractRecords, buildExpandFields } from '@object-ui/core';
|
|
28
|
-
import { z } from 'zod';
|
|
29
|
-
import MapGL, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre';
|
|
30
|
-
import maplibregl from 'maplibre-gl';
|
|
31
|
-
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
32
|
-
|
|
33
|
-
const MapConfigSchema = z.object({
|
|
34
|
-
latitudeField: z.string().optional(),
|
|
35
|
-
longitudeField: z.string().optional(),
|
|
36
|
-
locationField: z.string().optional(),
|
|
37
|
-
titleField: z.string().optional(),
|
|
38
|
-
descriptionField: z.string().optional(),
|
|
39
|
-
zoom: z.number().optional(),
|
|
40
|
-
center: z.tuple([z.number(), z.number()]).optional(),
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
export interface ObjectMapProps {
|
|
44
|
-
schema: ObjectGridSchema;
|
|
45
|
-
dataSource?: DataSource;
|
|
46
|
-
className?: string;
|
|
47
|
-
onMarkerClick?: (record: any) => void;
|
|
48
|
-
onRowClick?: (record: any) => void;
|
|
49
|
-
onEdit?: (record: any) => void;
|
|
50
|
-
onDelete?: (record: any) => void;
|
|
51
|
-
/** Enable marker clustering for dense data */
|
|
52
|
-
enableClustering?: boolean;
|
|
53
|
-
/** Cluster radius in pixels (default: 50) */
|
|
54
|
-
clusterRadius?: number;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface MapConfig {
|
|
58
|
-
/** Field containing latitude value */
|
|
59
|
-
latitudeField?: string;
|
|
60
|
-
/** Field containing longitude value */
|
|
61
|
-
longitudeField?: string;
|
|
62
|
-
/** Field containing combined location (e.g., "lat,lng" or location object) */
|
|
63
|
-
locationField?: string;
|
|
64
|
-
/** Field to use for marker title/label */
|
|
65
|
-
titleField?: string;
|
|
66
|
-
/** Field to use for marker description */
|
|
67
|
-
descriptionField?: string;
|
|
68
|
-
/** Default zoom level (1-20) */
|
|
69
|
-
zoom?: number;
|
|
70
|
-
/** Center coordinates [lat, lng] */
|
|
71
|
-
center?: [number, number];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Helper to get data configuration from schema
|
|
76
|
-
*/
|
|
77
|
-
function getDataConfig(schema: ObjectGridSchema): ViewData | null {
|
|
78
|
-
if (schema.data) {
|
|
79
|
-
return schema.data;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (schema.staticData) {
|
|
83
|
-
return {
|
|
84
|
-
provider: 'value',
|
|
85
|
-
items: schema.staticData,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (schema.objectName) {
|
|
90
|
-
return {
|
|
91
|
-
provider: 'object',
|
|
92
|
-
object: schema.objectName,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Helper to convert sort config to QueryParams format
|
|
101
|
-
*/
|
|
102
|
-
function convertSortToQueryParams(sort: string | any[] | undefined): Record<string, 'asc' | 'desc'> | undefined {
|
|
103
|
-
if (!sort) return undefined;
|
|
104
|
-
|
|
105
|
-
// If it's a string like "name desc"
|
|
106
|
-
if (typeof sort === 'string') {
|
|
107
|
-
const parts = sort.split(' ');
|
|
108
|
-
const field = parts[0];
|
|
109
|
-
const order = (parts[1]?.toLowerCase() === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc';
|
|
110
|
-
return { [field]: order };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// If it's an array of SortConfig objects
|
|
114
|
-
if (Array.isArray(sort)) {
|
|
115
|
-
return sort.reduce((acc, item) => {
|
|
116
|
-
if (item.field && item.order) {
|
|
117
|
-
acc[item.field] = item.order;
|
|
118
|
-
}
|
|
119
|
-
return acc;
|
|
120
|
-
}, {} as Record<string, 'asc' | 'desc'>);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Helper to get map configuration from schema
|
|
128
|
-
*/
|
|
129
|
-
function getMapConfig(schema: ObjectGridSchema | any): MapConfig {
|
|
130
|
-
// 1. Check top-level properties (ObjectMapSchema style)
|
|
131
|
-
if (schema.locationField || schema.latitudeField) {
|
|
132
|
-
return {
|
|
133
|
-
locationField: schema.locationField,
|
|
134
|
-
latitudeField: schema.latitudeField,
|
|
135
|
-
longitudeField: schema.longitudeField,
|
|
136
|
-
titleField: schema.titleField || 'name',
|
|
137
|
-
descriptionField: schema.descriptionField,
|
|
138
|
-
zoom: schema.zoom,
|
|
139
|
-
center: schema.center
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
let config: MapConfig | null = null;
|
|
144
|
-
// Check if schema has map configuration
|
|
145
|
-
if (schema.filter && typeof schema.filter === 'object' && 'map' in schema.filter) {
|
|
146
|
-
config = (schema.filter as any).map as MapConfig;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// For backward compatibility, check if schema has map config at root
|
|
150
|
-
else if ((schema as any).map) {
|
|
151
|
-
config = (schema as any).map as MapConfig;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (config) {
|
|
155
|
-
const result = MapConfigSchema.safeParse(config);
|
|
156
|
-
if (!result.success) {
|
|
157
|
-
console.warn(`[ObjectMap] Invalid map configuration:`, result.error.format());
|
|
158
|
-
}
|
|
159
|
-
return config;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Default configuration
|
|
163
|
-
return {
|
|
164
|
-
latitudeField: 'latitude',
|
|
165
|
-
longitudeField: 'longitude',
|
|
166
|
-
locationField: 'location',
|
|
167
|
-
titleField: 'name',
|
|
168
|
-
descriptionField: 'description',
|
|
169
|
-
zoom: 10,
|
|
170
|
-
center: [0, 0],
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Extract coordinates from a record based on configuration
|
|
176
|
-
*/
|
|
177
|
-
function extractCoordinates(record: any, config: MapConfig): [number, number] | null {
|
|
178
|
-
// Try latitude/longitude fields
|
|
179
|
-
if (config.latitudeField && config.longitudeField) {
|
|
180
|
-
const lat = record[config.latitudeField];
|
|
181
|
-
const lng = record[config.longitudeField];
|
|
182
|
-
if (typeof lat === 'number' && typeof lng === 'number') {
|
|
183
|
-
return [lat, lng];
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Try location field
|
|
188
|
-
if (config.locationField) {
|
|
189
|
-
const location = record[config.locationField];
|
|
190
|
-
|
|
191
|
-
// Handle object format: { lat: number, lng: number }
|
|
192
|
-
if (typeof location === 'object' && location !== null) {
|
|
193
|
-
const lat = location.lat || location.latitude;
|
|
194
|
-
const lng = location.lng || location.lon || location.longitude;
|
|
195
|
-
if (typeof lat === 'number' && typeof lng === 'number') {
|
|
196
|
-
return [lat, lng];
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Handle string format: "lat,lng"
|
|
201
|
-
if (typeof location === 'string') {
|
|
202
|
-
const parts = location.split(',').map(s => parseFloat(s.trim()));
|
|
203
|
-
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
|
204
|
-
return [parts[0], parts[1]];
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Handle array format: [lat, lng]
|
|
209
|
-
if (Array.isArray(location) && location.length === 2) {
|
|
210
|
-
const lat = parseFloat(location[0]);
|
|
211
|
-
const lng = parseFloat(location[1]);
|
|
212
|
-
if (!isNaN(lat) && !isNaN(lng)) {
|
|
213
|
-
return [lat, lng];
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
interface MarkerData {
|
|
222
|
-
id: string;
|
|
223
|
-
title: string;
|
|
224
|
-
description?: string;
|
|
225
|
-
coordinates: [number, number];
|
|
226
|
-
data: any;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
interface ClusterData {
|
|
230
|
-
id: string;
|
|
231
|
-
coordinates: [number, number];
|
|
232
|
-
markers: MarkerData[];
|
|
233
|
-
isCluster: boolean;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Simple grid-based marker clustering.
|
|
238
|
-
* Groups markers that are close to each other at a given zoom level.
|
|
239
|
-
*/
|
|
240
|
-
function clusterMarkers(markers: MarkerData[], zoom: number, radius: number = 50): ClusterData[] {
|
|
241
|
-
if (markers.length <= 1 || zoom >= 15) {
|
|
242
|
-
return markers.map(m => ({
|
|
243
|
-
id: m.id,
|
|
244
|
-
coordinates: m.coordinates,
|
|
245
|
-
markers: [m],
|
|
246
|
-
isCluster: false,
|
|
247
|
-
}));
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Grid cell size based on zoom (larger cells at lower zoom)
|
|
251
|
-
const cellSize = radius / Math.pow(2, zoom);
|
|
252
|
-
const grid = new Map<string, MarkerData[]>();
|
|
253
|
-
|
|
254
|
-
markers.forEach(marker => {
|
|
255
|
-
const cellX = Math.floor(marker.coordinates[0] / cellSize);
|
|
256
|
-
const cellY = Math.floor(marker.coordinates[1] / cellSize);
|
|
257
|
-
const key = `${cellX}:${cellY}`;
|
|
258
|
-
if (!grid.has(key)) grid.set(key, []);
|
|
259
|
-
grid.get(key)!.push(marker);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
const clusters: ClusterData[] = [];
|
|
263
|
-
grid.forEach((group, key) => {
|
|
264
|
-
if (group.length === 1) {
|
|
265
|
-
clusters.push({
|
|
266
|
-
id: group[0].id,
|
|
267
|
-
coordinates: group[0].coordinates,
|
|
268
|
-
markers: group,
|
|
269
|
-
isCluster: false,
|
|
270
|
-
});
|
|
271
|
-
} else {
|
|
272
|
-
// Compute centroid
|
|
273
|
-
const avgLng = group.reduce((sum, m) => sum + m.coordinates[0], 0) / group.length;
|
|
274
|
-
const avgLat = group.reduce((sum, m) => sum + m.coordinates[1], 0) / group.length;
|
|
275
|
-
clusters.push({
|
|
276
|
-
id: `cluster-${key}`,
|
|
277
|
-
coordinates: [avgLng, avgLat],
|
|
278
|
-
markers: group,
|
|
279
|
-
isCluster: true,
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
return clusters;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
export const ObjectMap: React.FC<ObjectMapProps> = ({
|
|
288
|
-
schema,
|
|
289
|
-
dataSource,
|
|
290
|
-
className,
|
|
291
|
-
onMarkerClick,
|
|
292
|
-
onRowClick,
|
|
293
|
-
onEdit,
|
|
294
|
-
onDelete,
|
|
295
|
-
enableClustering,
|
|
296
|
-
clusterRadius = 50,
|
|
297
|
-
...rest
|
|
298
|
-
}) => {
|
|
299
|
-
const [data, setData] = useState<any[]>([]);
|
|
300
|
-
const [loading, setLoading] = useState(true);
|
|
301
|
-
const [error, setError] = useState<Error | null>(null);
|
|
302
|
-
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
303
|
-
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
|
|
304
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
305
|
-
|
|
306
|
-
const rawDataConfig = getDataConfig(schema);
|
|
307
|
-
// Memoize dataConfig using deep comparison to prevent infinite loops
|
|
308
|
-
const dataConfig = useMemo(() => {
|
|
309
|
-
return rawDataConfig;
|
|
310
|
-
}, [JSON.stringify(rawDataConfig)]);
|
|
311
|
-
|
|
312
|
-
const mapConfig = getMapConfig(schema);
|
|
313
|
-
const hasInlineData = dataConfig?.provider === 'value';
|
|
314
|
-
|
|
315
|
-
// Fetch data based on provider
|
|
316
|
-
useEffect(() => {
|
|
317
|
-
const fetchData = async () => {
|
|
318
|
-
try {
|
|
319
|
-
setLoading(true);
|
|
320
|
-
|
|
321
|
-
// Prioritize data passed via props (from ListView)
|
|
322
|
-
if ((rest as any).data) { // Check props.data directly first
|
|
323
|
-
const passed = (rest as any).data;
|
|
324
|
-
if (Array.isArray(passed)) {
|
|
325
|
-
setData(passed);
|
|
326
|
-
setLoading(false);
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Check schema.data next
|
|
332
|
-
if ((schema as any).data) {
|
|
333
|
-
const passed = (schema as any).data;
|
|
334
|
-
if (Array.isArray(passed)) {
|
|
335
|
-
setData(passed);
|
|
336
|
-
setLoading(false);
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (hasInlineData && dataConfig?.provider === 'value') {
|
|
342
|
-
setData(dataConfig.items as any[]);
|
|
343
|
-
setLoading(false);
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (!dataSource || typeof dataSource.find !== 'function') {
|
|
348
|
-
throw new Error('DataSource required for object/api providers');
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (dataConfig?.provider === 'object') {
|
|
352
|
-
const objectName = dataConfig.object;
|
|
353
|
-
// Auto-inject $expand for lookup/master_detail fields
|
|
354
|
-
const expand = buildExpandFields(objectSchema?.fields);
|
|
355
|
-
const result = await dataSource.find(objectName, {
|
|
356
|
-
$filter: schema.filter,
|
|
357
|
-
$orderby: convertSortToQueryParams(schema.sort),
|
|
358
|
-
...(expand.length > 0 ? { $expand: expand } : {}),
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
let items: any[] = extractRecords(result);
|
|
362
|
-
setData(items);
|
|
363
|
-
} else if (dataConfig?.provider === 'api') {
|
|
364
|
-
console.warn('API provider not yet implemented for ObjectMap');
|
|
365
|
-
setData([]);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
setLoading(false);
|
|
369
|
-
} catch (err) {
|
|
370
|
-
setError(err as Error);
|
|
371
|
-
setLoading(false);
|
|
372
|
-
}
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
fetchData();
|
|
376
|
-
}, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema]);
|
|
377
|
-
|
|
378
|
-
// Fetch object schema for field metadata
|
|
379
|
-
useEffect(() => {
|
|
380
|
-
const fetchObjectSchema = async () => {
|
|
381
|
-
try {
|
|
382
|
-
if (!dataSource) return;
|
|
383
|
-
|
|
384
|
-
const objectName = dataConfig?.provider === 'object'
|
|
385
|
-
? dataConfig.object
|
|
386
|
-
: schema.objectName;
|
|
387
|
-
|
|
388
|
-
if (!objectName) return;
|
|
389
|
-
|
|
390
|
-
const schemaData = await dataSource.getObjectSchema(objectName);
|
|
391
|
-
setObjectSchema(schemaData);
|
|
392
|
-
} catch (err) {
|
|
393
|
-
console.error('Failed to fetch object schema:', err);
|
|
394
|
-
}
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
if (!hasInlineData && dataSource) {
|
|
398
|
-
fetchObjectSchema();
|
|
399
|
-
}
|
|
400
|
-
}, [schema.objectName, dataSource, hasInlineData, dataConfig]);
|
|
401
|
-
|
|
402
|
-
// Transform data to map markers
|
|
403
|
-
const { markers, invalidCount } = useMemo(() => {
|
|
404
|
-
let invalid = 0;
|
|
405
|
-
const validMarkers = data
|
|
406
|
-
.map((record, index) => {
|
|
407
|
-
const coordinates = extractCoordinates(record, mapConfig);
|
|
408
|
-
if (!coordinates) {
|
|
409
|
-
invalid++;
|
|
410
|
-
return null;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const title = mapConfig.titleField ? record[mapConfig.titleField] : 'Marker';
|
|
414
|
-
const description = mapConfig.descriptionField ? record[mapConfig.descriptionField] : undefined;
|
|
415
|
-
|
|
416
|
-
// Ensure lat/lng are within valid ranges
|
|
417
|
-
const [lat, lng] = coordinates;
|
|
418
|
-
if (!isFinite(lat) || !isFinite(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
419
|
-
invalid++;
|
|
420
|
-
return null;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return {
|
|
424
|
-
id: record.id || record._id || `marker-${index}`,
|
|
425
|
-
title,
|
|
426
|
-
description,
|
|
427
|
-
coordinates: [lng, lat] as [number, number], // maplibre uses [lng, lat]
|
|
428
|
-
data: record,
|
|
429
|
-
};
|
|
430
|
-
})
|
|
431
|
-
.filter((marker): marker is NonNullable<typeof marker> => marker !== null);
|
|
432
|
-
|
|
433
|
-
return { markers: validMarkers, invalidCount: invalid };
|
|
434
|
-
}, [data, mapConfig]);
|
|
435
|
-
|
|
436
|
-
const selectedMarker = useMemo(() =>
|
|
437
|
-
markers.find(m => m.id === selectedMarkerId),
|
|
438
|
-
[markers, selectedMarkerId]);
|
|
439
|
-
|
|
440
|
-
const [currentZoom, setCurrentZoom] = useState(mapConfig.zoom || 3);
|
|
441
|
-
|
|
442
|
-
const navigation = useNavigationOverlay({
|
|
443
|
-
navigation: (schema as any).navigation,
|
|
444
|
-
objectName: schema.objectName,
|
|
445
|
-
onRowClick,
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
const filteredMarkers = useMemo(() => {
|
|
449
|
-
if (!searchQuery.trim()) return markers;
|
|
450
|
-
const q = searchQuery.toLowerCase();
|
|
451
|
-
return markers.filter(m =>
|
|
452
|
-
m.title?.toLowerCase().includes(q) ||
|
|
453
|
-
m.description?.toLowerCase().includes(q)
|
|
454
|
-
);
|
|
455
|
-
}, [markers, searchQuery]);
|
|
456
|
-
|
|
457
|
-
// Cluster markers when clustering is enabled
|
|
458
|
-
const clusteredData = useMemo(() => {
|
|
459
|
-
const shouldCluster = enableClustering ?? ((schema as any).enableClustering || filteredMarkers.length > 100);
|
|
460
|
-
if (!shouldCluster) {
|
|
461
|
-
return filteredMarkers.map(m => ({
|
|
462
|
-
id: m.id,
|
|
463
|
-
coordinates: m.coordinates,
|
|
464
|
-
markers: [m],
|
|
465
|
-
isCluster: false,
|
|
466
|
-
}));
|
|
467
|
-
}
|
|
468
|
-
return clusterMarkers(filteredMarkers, currentZoom, clusterRadius);
|
|
469
|
-
}, [filteredMarkers, currentZoom, enableClustering, clusterRadius, schema]);
|
|
470
|
-
|
|
471
|
-
// Calculate map bounds
|
|
472
|
-
const initialViewState = useMemo(() => {
|
|
473
|
-
if (!filteredMarkers.length) {
|
|
474
|
-
return {
|
|
475
|
-
longitude: mapConfig.center?.[1] || 0,
|
|
476
|
-
latitude: mapConfig.center?.[0] || 0,
|
|
477
|
-
zoom: mapConfig.zoom || 2
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Simple bounds calculation
|
|
482
|
-
const lngs = filteredMarkers.map(m => m.coordinates[0]);
|
|
483
|
-
const lats = filteredMarkers.map(m => m.coordinates[1]);
|
|
484
|
-
|
|
485
|
-
const minLng = Math.min(...lngs);
|
|
486
|
-
const maxLng = Math.max(...lngs);
|
|
487
|
-
const minLat = Math.min(...lats);
|
|
488
|
-
const maxLat = Math.max(...lats);
|
|
489
|
-
|
|
490
|
-
return {
|
|
491
|
-
longitude: (minLng + maxLng) / 2,
|
|
492
|
-
latitude: (minLat + maxLat) / 2,
|
|
493
|
-
zoom: mapConfig.zoom || 3, // Auto-zoom logic could be improved here
|
|
494
|
-
};
|
|
495
|
-
}, [filteredMarkers, mapConfig]);
|
|
496
|
-
|
|
497
|
-
if (loading) {
|
|
498
|
-
return (
|
|
499
|
-
<div className={cn("min-w-0 overflow-hidden", className)}>
|
|
500
|
-
<div className="flex items-center justify-center h-96 bg-muted rounded-lg border">
|
|
501
|
-
<div className="text-muted-foreground">Loading map...</div>
|
|
502
|
-
</div>
|
|
503
|
-
</div>
|
|
504
|
-
);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
if (error) {
|
|
508
|
-
return (
|
|
509
|
-
<div className={cn("min-w-0 overflow-hidden", className)}>
|
|
510
|
-
<div className="flex items-center justify-center h-96 bg-muted rounded-lg border">
|
|
511
|
-
<div className="text-destructive">Error: {error.message}</div>
|
|
512
|
-
</div>
|
|
513
|
-
</div>
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return (
|
|
518
|
-
<div className={cn("min-w-0 overflow-hidden", className)}>
|
|
519
|
-
{invalidCount > 0 && (
|
|
520
|
-
<div className="mb-2 p-2 text-sm text-yellow-800 bg-yellow-50 border border-yellow-200 rounded">
|
|
521
|
-
{`${invalidCount} record${invalidCount !== 1 ? 's' : ''} with missing or invalid coordinates excluded from the map.`}
|
|
522
|
-
</div>
|
|
523
|
-
)}
|
|
524
|
-
{markers.length > 0 && (
|
|
525
|
-
<div className="mb-2">
|
|
526
|
-
<input
|
|
527
|
-
type="text"
|
|
528
|
-
value={searchQuery}
|
|
529
|
-
onChange={(e) => setSearchQuery(e.target.value)}
|
|
530
|
-
placeholder="Search locations…"
|
|
531
|
-
className="w-full px-3 py-2 text-sm border rounded-md bg-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
532
|
-
/>
|
|
533
|
-
</div>
|
|
534
|
-
)}
|
|
535
|
-
<div className="relative border rounded-lg overflow-hidden bg-muted h-[300px] sm:h-[400px] md:h-[500px] lg:h-[600px] w-full">
|
|
536
|
-
<MapGL
|
|
537
|
-
initialViewState={initialViewState}
|
|
538
|
-
style={{ width: '100%', height: '100%' }}
|
|
539
|
-
mapStyle="https://demotiles.maplibre.org/style.json"
|
|
540
|
-
touchZoomRotate={true}
|
|
541
|
-
dragRotate={true}
|
|
542
|
-
touchPitch={true}
|
|
543
|
-
onZoom={(e) => setCurrentZoom(Math.round(e.viewState.zoom))}
|
|
544
|
-
>
|
|
545
|
-
<NavigationControl position="top-right" showCompass={true} showZoom={true} />
|
|
546
|
-
|
|
547
|
-
{clusteredData.map(cluster => (
|
|
548
|
-
cluster.isCluster ? (
|
|
549
|
-
<Marker
|
|
550
|
-
key={cluster.id}
|
|
551
|
-
longitude={cluster.coordinates[0]}
|
|
552
|
-
latitude={cluster.coordinates[1]}
|
|
553
|
-
anchor="center"
|
|
554
|
-
>
|
|
555
|
-
<div
|
|
556
|
-
className="flex items-center justify-center rounded-full bg-primary text-primary-foreground font-bold text-xs cursor-pointer hover:scale-110 transition-transform shadow-md"
|
|
557
|
-
style={{
|
|
558
|
-
width: Math.min(48, 24 + cluster.markers.length * 2),
|
|
559
|
-
height: Math.min(48, 24 + cluster.markers.length * 2),
|
|
560
|
-
}}
|
|
561
|
-
title={`${cluster.markers.length} markers`}
|
|
562
|
-
>
|
|
563
|
-
{cluster.markers.length}
|
|
564
|
-
</div>
|
|
565
|
-
</Marker>
|
|
566
|
-
) : (
|
|
567
|
-
<Marker
|
|
568
|
-
key={cluster.id}
|
|
569
|
-
longitude={cluster.coordinates[0]}
|
|
570
|
-
latitude={cluster.coordinates[1]}
|
|
571
|
-
anchor="bottom"
|
|
572
|
-
onClick={(e) => {
|
|
573
|
-
e.originalEvent.stopPropagation();
|
|
574
|
-
const marker = cluster.markers[0];
|
|
575
|
-
setSelectedMarkerId(marker.id);
|
|
576
|
-
navigation.handleClick(marker.data);
|
|
577
|
-
onMarkerClick?.(marker.data);
|
|
578
|
-
}}
|
|
579
|
-
>
|
|
580
|
-
<div className="text-2xl cursor-pointer hover:scale-110 transition-transform">
|
|
581
|
-
📍
|
|
582
|
-
</div>
|
|
583
|
-
</Marker>
|
|
584
|
-
)
|
|
585
|
-
))}
|
|
586
|
-
|
|
587
|
-
{selectedMarker && (
|
|
588
|
-
<Popup
|
|
589
|
-
longitude={selectedMarker.coordinates[0]}
|
|
590
|
-
latitude={selectedMarker.coordinates[1]}
|
|
591
|
-
anchor="top"
|
|
592
|
-
onClose={() => setSelectedMarkerId(null)}
|
|
593
|
-
closeOnClick={false}
|
|
594
|
-
>
|
|
595
|
-
<div className="p-2 min-w-[150px] sm:min-w-[200px]">
|
|
596
|
-
<h3 className="font-bold text-sm mb-1">{selectedMarker.title}</h3>
|
|
597
|
-
{selectedMarker.description && (
|
|
598
|
-
<p className="text-xs text-muted-foreground">{selectedMarker.description}</p>
|
|
599
|
-
)}
|
|
600
|
-
<div className="mt-2 text-xs flex gap-2">
|
|
601
|
-
{onEdit && (
|
|
602
|
-
<button className="text-blue-500 hover:underline" onClick={() => onEdit(selectedMarker.data)}>Edit</button>
|
|
603
|
-
)}
|
|
604
|
-
{onDelete && (
|
|
605
|
-
<button className="text-red-500 hover:underline" onClick={() => onDelete(selectedMarker.data)}>Delete</button>
|
|
606
|
-
)}
|
|
607
|
-
</div>
|
|
608
|
-
</div>
|
|
609
|
-
</Popup>
|
|
610
|
-
)}
|
|
611
|
-
</MapGL>
|
|
612
|
-
</div>
|
|
613
|
-
{navigation.isOverlay && (
|
|
614
|
-
<NavigationOverlay {...navigation} title="Location Details">
|
|
615
|
-
{(record) => (
|
|
616
|
-
<div className="space-y-3">
|
|
617
|
-
{Object.entries(record).map(([key, value]) => (
|
|
618
|
-
<div key={key} className="flex flex-col">
|
|
619
|
-
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
620
|
-
{key.replace(/_/g, ' ')}
|
|
621
|
-
</span>
|
|
622
|
-
<span className="text-sm">{String(value ?? '—')}</span>
|
|
623
|
-
</div>
|
|
624
|
-
))}
|
|
625
|
-
</div>
|
|
626
|
-
)}
|
|
627
|
-
</NavigationOverlay>
|
|
628
|
-
)}
|
|
629
|
-
</div>
|
|
630
|
-
);
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
export default ObjectMap;
|
package/src/index.test.tsx
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
3
|
-
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
4
|
-
// import { ComponentRegistry } from '@object-ui/core';
|
|
5
|
-
// Use relative path to force same instance as verified by alias
|
|
6
|
-
import { ObjectMapRenderer } from './index';
|
|
7
|
-
// import { ComponentRegistry } from '@object-ui/core';
|
|
8
|
-
|
|
9
|
-
// Mock dependencies
|
|
10
|
-
vi.mock('@object-ui/react', () => ({
|
|
11
|
-
useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } })),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
vi.mock('./ObjectMap', () => ({
|
|
15
|
-
ObjectMap: ({ dataSource }: any) => (
|
|
16
|
-
<div data-testid="map-mock">
|
|
17
|
-
{dataSource ? `DataSource: ${dataSource.type}` : 'No DataSource'}
|
|
18
|
-
</div>
|
|
19
|
-
)
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
|
-
describe('Plugin Map Registration', () => {
|
|
23
|
-
it('renderer passes dataSource from context', () => {
|
|
24
|
-
render(<ObjectMapRenderer schema={{}} />);
|
|
25
|
-
expect(screen.getByTestId('map-mock')).toHaveTextContent('DataSource: mock-datasource');
|
|
26
|
-
});
|
|
27
|
-
});
|
package/src/index.tsx
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
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 { useSchemaContext } from '@object-ui/react';
|
|
12
|
-
import { ObjectMap } from './ObjectMap';
|
|
13
|
-
import type { ObjectMapProps } from './ObjectMap';
|
|
14
|
-
|
|
15
|
-
export { ObjectMap };
|
|
16
|
-
export type { ObjectMapProps };
|
|
17
|
-
|
|
18
|
-
// Register component
|
|
19
|
-
export const ObjectMapRenderer: React.FC<any> = ({ schema, ...props }) => {
|
|
20
|
-
const { dataSource } = useSchemaContext() || {};
|
|
21
|
-
return <ObjectMap schema={schema} dataSource={dataSource} {...props} />;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
console.log('Registering object-map...');
|
|
25
|
-
ComponentRegistry.register('object-map', ObjectMapRenderer, {
|
|
26
|
-
namespace: 'plugin-map',
|
|
27
|
-
label: 'Object Map',
|
|
28
|
-
category: 'view',
|
|
29
|
-
inputs: [
|
|
30
|
-
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
31
|
-
{ name: 'map', type: 'object', label: 'Map Config', description: 'latitudeField, longitudeField, titleField' },
|
|
32
|
-
],
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
ComponentRegistry.register('map', ObjectMapRenderer, {
|
|
36
|
-
namespace: 'view',
|
|
37
|
-
label: 'Map View',
|
|
38
|
-
category: 'view',
|
|
39
|
-
inputs: [
|
|
40
|
-
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
41
|
-
{ name: 'map', type: 'object', label: 'Map Config', description: 'latitudeField, longitudeField, titleField' },
|
|
42
|
-
],
|
|
43
|
-
});
|