@object-ui/plugin-map 3.0.3 → 3.1.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.
@@ -8,6 +8,10 @@ export interface ObjectMapProps {
8
8
  onRowClick?: (record: any) => void;
9
9
  onEdit?: (record: any) => void;
10
10
  onDelete?: (record: any) => void;
11
+ /** Enable marker clustering for dense data */
12
+ enableClustering?: boolean;
13
+ /** Cluster radius in pixels (default: 50) */
14
+ clusterRadius?: number;
11
15
  }
12
16
  export declare const ObjectMap: React.FC<ObjectMapProps>;
13
17
  export default ObjectMap;
@@ -1 +1 @@
1
- {"version":3,"file":"ObjectMap.d.ts","sourceRoot":"","sources":["../../src/ObjectMap.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAY,MAAM,kBAAkB,CAAC;AAM/E,OAAO,kCAAkC,CAAC;AAY1C,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,gBAAgB,CAAC;IACzB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IACtC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IAC/B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;CAClC;AAsKD,eAAO,MAAM,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,cAAc,CAsT9C,CAAC;AAEF,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"ObjectMap.d.ts","sourceRoot":"","sources":["../../src/ObjectMap.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAY,MAAM,kBAAkB,CAAC;AAO/E,OAAO,kCAAkC,CAAC;AAY1C,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,gBAAgB,CAAC;IACzB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IACtC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IAC/B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IACjC,8CAA8C;IAC9C,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,6CAA6C;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAwOD,eAAO,MAAM,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,cAAc,CAwV9C,CAAC;AAEF,eAAe,SAAS,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/plugin-map",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Map visualization plugin for Object UI",
@@ -24,22 +24,22 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@objectstack/spec": "^3.0.4",
28
- "lucide-react": "^0.563.0",
29
- "maplibre-gl": "^5.18.0",
27
+ "@objectstack/spec": "^3.2.0",
28
+ "lucide-react": "^0.576.0",
29
+ "maplibre-gl": "^5.19.0",
30
30
  "react-map-gl": "^8.1.0",
31
31
  "zod": "^4.3.6",
32
- "@object-ui/components": "3.0.3",
33
- "@object-ui/core": "3.0.3",
34
- "@object-ui/react": "3.0.3",
35
- "@object-ui/types": "3.0.3"
32
+ "@object-ui/components": "3.1.0",
33
+ "@object-ui/core": "3.1.0",
34
+ "@object-ui/react": "3.1.0",
35
+ "@object-ui/types": "3.1.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "react": "^18.0.0 || ^19.0.0",
39
39
  "react-dom": "^18.0.0 || ^19.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/react": "19.2.13",
42
+ "@types/react": "19.2.14",
43
43
  "@types/react-dom": "19.2.3",
44
44
  "@vitejs/plugin-react": "^5.1.4",
45
45
  "typescript": "^5.9.3",
package/src/ObjectMap.tsx CHANGED
@@ -23,9 +23,10 @@
23
23
  import React, { useEffect, useState, useMemo } from 'react';
24
24
  import type { ObjectGridSchema, DataSource, ViewData } from '@object-ui/types';
25
25
  import { useNavigationOverlay } from '@object-ui/react';
26
- import { NavigationOverlay } from '@object-ui/components';
26
+ import { NavigationOverlay, cn } from '@object-ui/components';
27
+ import { extractRecords, buildExpandFields } from '@object-ui/core';
27
28
  import { z } from 'zod';
28
- import Map, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre';
29
+ import MapGL, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre';
29
30
  import maplibregl from 'maplibre-gl';
30
31
  import 'maplibre-gl/dist/maplibre-gl.css';
31
32
 
@@ -47,6 +48,10 @@ export interface ObjectMapProps {
47
48
  onRowClick?: (record: any) => void;
48
49
  onEdit?: (record: any) => void;
49
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;
50
55
  }
51
56
 
52
57
  interface MapConfig {
@@ -213,6 +218,72 @@ function extractCoordinates(record: any, config: MapConfig): [number, number] |
213
218
  return null;
214
219
  }
215
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
+
216
287
  export const ObjectMap: React.FC<ObjectMapProps> = ({
217
288
  schema,
218
289
  dataSource,
@@ -221,6 +292,8 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
221
292
  onRowClick,
222
293
  onEdit,
223
294
  onDelete,
295
+ enableClustering,
296
+ clusterRadius = 50,
224
297
  ...rest
225
298
  }) => {
226
299
  const [data, setData] = useState<any[]>([]);
@@ -271,27 +344,21 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
271
344
  return;
272
345
  }
273
346
 
274
- if (!dataSource) {
347
+ if (!dataSource || typeof dataSource.find !== 'function') {
275
348
  throw new Error('DataSource required for object/api providers');
276
349
  }
277
350
 
278
351
  if (dataConfig?.provider === 'object') {
279
352
  const objectName = dataConfig.object;
353
+ // Auto-inject $expand for lookup/master_detail fields
354
+ const expand = buildExpandFields(objectSchema?.fields);
280
355
  const result = await dataSource.find(objectName, {
281
356
  $filter: schema.filter,
282
357
  $orderby: convertSortToQueryParams(schema.sort),
358
+ ...(expand.length > 0 ? { $expand: expand } : {}),
283
359
  });
284
360
 
285
- let items: any[] = [];
286
- if (Array.isArray(result)) {
287
- items = result;
288
- } else if (result && typeof result === 'object') {
289
- if (Array.isArray((result as any).data)) {
290
- items = (result as any).data;
291
- } else if (Array.isArray((result as any).records)) {
292
- items = (result as any).records;
293
- }
294
- }
361
+ let items: any[] = extractRecords(result);
295
362
  setData(items);
296
363
  } else if (dataConfig?.provider === 'api') {
297
364
  console.warn('API provider not yet implemented for ObjectMap');
@@ -306,7 +373,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
306
373
  };
307
374
 
308
375
  fetchData();
309
- }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]);
376
+ }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema]);
310
377
 
311
378
  // Fetch object schema for field metadata
312
379
  useEffect(() => {
@@ -370,6 +437,8 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
370
437
  markers.find(m => m.id === selectedMarkerId),
371
438
  [markers, selectedMarkerId]);
372
439
 
440
+ const [currentZoom, setCurrentZoom] = useState(mapConfig.zoom || 3);
441
+
373
442
  const navigation = useNavigationOverlay({
374
443
  navigation: (schema as any).navigation,
375
444
  objectName: schema.objectName,
@@ -385,6 +454,20 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
385
454
  );
386
455
  }, [markers, searchQuery]);
387
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
+
388
471
  // Calculate map bounds
389
472
  const initialViewState = useMemo(() => {
390
473
  if (!filteredMarkers.length) {
@@ -413,7 +496,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
413
496
 
414
497
  if (loading) {
415
498
  return (
416
- <div className={className}>
499
+ <div className={cn("min-w-0 overflow-hidden", className)}>
417
500
  <div className="flex items-center justify-center h-96 bg-muted rounded-lg border">
418
501
  <div className="text-muted-foreground">Loading map...</div>
419
502
  </div>
@@ -423,7 +506,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
423
506
 
424
507
  if (error) {
425
508
  return (
426
- <div className={className}>
509
+ <div className={cn("min-w-0 overflow-hidden", className)}>
427
510
  <div className="flex items-center justify-center h-96 bg-muted rounded-lg border">
428
511
  <div className="text-destructive">Error: {error.message}</div>
429
512
  </div>
@@ -432,7 +515,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
432
515
  }
433
516
 
434
517
  return (
435
- <div className={className}>
518
+ <div className={cn("min-w-0 overflow-hidden", className)}>
436
519
  {invalidCount > 0 && (
437
520
  <div className="mb-2 p-2 text-sm text-yellow-800 bg-yellow-50 border border-yellow-200 rounded">
438
521
  {`${invalidCount} record${invalidCount !== 1 ? 's' : ''} with missing or invalid coordinates excluded from the map.`}
@@ -450,24 +533,45 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
450
533
  </div>
451
534
  )}
452
535
  <div className="relative border rounded-lg overflow-hidden bg-muted h-[300px] sm:h-[400px] md:h-[500px] lg:h-[600px] w-full">
453
- <Map
536
+ <MapGL
454
537
  initialViewState={initialViewState}
455
538
  style={{ width: '100%', height: '100%' }}
456
539
  mapStyle="https://demotiles.maplibre.org/style.json"
457
540
  touchZoomRotate={true}
458
541
  dragRotate={true}
459
542
  touchPitch={true}
543
+ onZoom={(e) => setCurrentZoom(Math.round(e.viewState.zoom))}
460
544
  >
461
545
  <NavigationControl position="top-right" showCompass={true} showZoom={true} />
462
546
 
463
- {filteredMarkers.map(marker => (
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
+ ) : (
464
567
  <Marker
465
- key={marker.id}
466
- longitude={marker.coordinates[0]}
467
- latitude={marker.coordinates[1]}
568
+ key={cluster.id}
569
+ longitude={cluster.coordinates[0]}
570
+ latitude={cluster.coordinates[1]}
468
571
  anchor="bottom"
469
572
  onClick={(e) => {
470
573
  e.originalEvent.stopPropagation();
574
+ const marker = cluster.markers[0];
471
575
  setSelectedMarkerId(marker.id);
472
576
  navigation.handleClick(marker.data);
473
577
  onMarkerClick?.(marker.data);
@@ -477,6 +581,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
477
581
  📍
478
582
  </div>
479
583
  </Marker>
584
+ )
480
585
  ))}
481
586
 
482
587
  {selectedMarker && (
@@ -503,7 +608,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
503
608
  </div>
504
609
  </Popup>
505
610
  )}
506
- </Map>
611
+ </MapGL>
507
612
  </div>
508
613
  {navigation.isOverlay && (
509
614
  <NavigationOverlay {...navigation} title="Location Details">
package/src/index.tsx CHANGED
@@ -17,7 +17,7 @@ export type { ObjectMapProps };
17
17
 
18
18
  // Register component
19
19
  export const ObjectMapRenderer: React.FC<any> = ({ schema, ...props }) => {
20
- const { dataSource } = useSchemaContext();
20
+ const { dataSource } = useSchemaContext() || {};
21
21
  return <ObjectMap schema={schema} dataSource={dataSource} {...props} />;
22
22
  };
23
23