From b3bc26cd08343b4c309530c66efe5285f4b30105 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 17 Aug 2026 15:18:51 +0700 Subject: [PATCH] feat: add MapLibre map primitive and Sheets picker --- apps/blocks/src/app/sitemap.test.ts | 4 +- .../src/components/docs/demos/ui-map.demo.tsx | 141 +++ .../src/components/docs/showcase-ui.tsx | 2 + .../src/components/docs/ui-demos.test.tsx | 1 + .../landing/primitive-showcase-config.ts | 2 + .../source-block-showcase-canvas.tsx | 41 + apps/blocks/src/content/ui/index.ts | 2 + apps/blocks/src/content/ui/map.ts | 100 ++ apps/blocks/src/generated/ui-demo-source.ts | 14 + apps/blocks/src/lib/base-primitives.test.ts | 1 + apps/blocks/src/lib/base-primitives.ts | 6 + apps/registry/scripts/smoke-install.ts | 98 +- packages/sheets/README.md | 37 +- packages/sheets/docs/EMBEDDING.md | 5 +- packages/sheets/package.json | 13 +- packages/sheets/registry.json | 10 +- packages/sheets/scripts/build-registry.ts | 5 +- .../src/cell-model/create-sheets-cell.ts | 2 +- .../src/cell-model/factories/geometry.ts | 2 +- .../sheets/src/cell-model/factories/types.ts | 2 +- .../src/cell-model/views/geometry-view.tsx | 2 +- packages/sheets/src/context/sheets-context.ts | 26 + .../__tests__/geometry-editor.test.tsx | 326 +++-- .../src/grid-dom/editors/geometry-editor.tsx | 27 +- .../src/grid/__golden__/display-cases.ts | 2 +- .../src/grid/__golden__/parity.harness.ts | 2 +- .../src/grid/editors/geometry-editor.tsx | 1006 +++++++++------- .../sheets/src/grid/editors/geometry-types.ts | 26 + packages/sheets/src/index.ts | 4 + packages/sheets/src/utils/map-picker.test.tsx | 236 ++++ packages/sheets/src/utils/map-picker.tsx | 601 +++++----- packages/sheets/tsup.config.ts | 3 +- packages/ui/README.md | 78 +- packages/ui/THIRD_PARTY_NOTICES.md | 21 + packages/ui/package.json | 16 +- packages/ui/registry.json | 1050 +++++------------ packages/ui/scripts/build-registry.ts | 1 + packages/ui/src/components/map.tsx | 48 + packages/ui/src/components/map/context.tsx | 22 + packages/ui/src/components/map/internal.ts | 203 ++++ .../ui/src/components/map/layer-lifecycle.ts | 56 + packages/ui/src/components/map/map-arc.tsx | 299 +++++ .../ui/src/components/map/map-cluster.tsx | 223 ++++ .../ui/src/components/map/map-controls.tsx | 202 ++++ .../ui/src/components/map/map-geojson.tsx | 217 ++++ packages/ui/src/components/map/map-marker.tsx | 389 ++++++ packages/ui/src/components/map/map-popup.tsx | 103 ++ packages/ui/src/components/map/map-root.tsx | 284 +++++ packages/ui/src/components/map/map-route.tsx | 120 ++ packages/ui/src/index.ts | 1 + packages/ui/src/stories/Map.stories.tsx | 160 +++ packages/ui/src/stories/map-preview.tsx | 217 ++++ packages/ui/src/styles/globals.css | 49 + packages/ui/src/theme.ts | 739 ++++++------ packages/ui/test/map.test.tsx | 648 ++++++++++ pnpm-lock.yaml | 286 ++++- scripts/check-packed-packages.ts | 142 ++- 57 files changed, 6235 insertions(+), 2088 deletions(-) create mode 100644 apps/blocks/src/components/docs/demos/ui-map.demo.tsx create mode 100644 apps/blocks/src/content/ui/map.ts create mode 100644 packages/sheets/src/grid/editors/geometry-types.ts create mode 100644 packages/sheets/src/utils/map-picker.test.tsx create mode 100644 packages/ui/THIRD_PARTY_NOTICES.md create mode 100644 packages/ui/src/components/map.tsx create mode 100644 packages/ui/src/components/map/context.tsx create mode 100644 packages/ui/src/components/map/internal.ts create mode 100644 packages/ui/src/components/map/layer-lifecycle.ts create mode 100644 packages/ui/src/components/map/map-arc.tsx create mode 100644 packages/ui/src/components/map/map-cluster.tsx create mode 100644 packages/ui/src/components/map/map-controls.tsx create mode 100644 packages/ui/src/components/map/map-geojson.tsx create mode 100644 packages/ui/src/components/map/map-marker.tsx create mode 100644 packages/ui/src/components/map/map-popup.tsx create mode 100644 packages/ui/src/components/map/map-root.tsx create mode 100644 packages/ui/src/components/map/map-route.tsx create mode 100644 packages/ui/src/stories/Map.stories.tsx create mode 100644 packages/ui/src/stories/map-preview.tsx create mode 100644 packages/ui/test/map.test.tsx diff --git a/apps/blocks/src/app/sitemap.test.ts b/apps/blocks/src/app/sitemap.test.ts index a50f256..eaa7483 100644 --- a/apps/blocks/src/app/sitemap.test.ts +++ b/apps/blocks/src/app/sitemap.test.ts @@ -10,9 +10,9 @@ import { SOURCE_BLOCKS } from '@/lib/source-blocks'; import sitemap from './sitemap'; describe('sitemap', () => { - it('contains foundations, application blocks, seven feature packs, 29 primitives, AI catalog, and the complete billing catalog', () => { + it('contains foundations, application blocks, seven feature packs, 30 primitives, AI catalog, and the complete billing catalog', () => { const entries = sitemap(); - expect(BASE_PRIMITIVES).toHaveLength(29); + expect(BASE_PRIMITIVES).toHaveLength(30); expect(entries).toHaveLength( BASE_PRIMITIVES.length + FEATURE_PACK_DOCS.length + diff --git a/apps/blocks/src/components/docs/demos/ui-map.demo.tsx b/apps/blocks/src/components/docs/demos/ui-map.demo.tsx new file mode 100644 index 0000000..b984353 --- /dev/null +++ b/apps/blocks/src/components/docs/demos/ui-map.demo.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Button } from '@constructive-io/ui/button'; +import { Map, MapArc, MapControls, MapMarker, MapPopup, MarkerContent, MarkerPopup } from '@constructive-io/ui/map'; + +import { Demo } from '@/components/docs/showcase-kit'; + +const NEW_YORK_CENTER: [number, number] = [-73.9857, 40.7484]; + +const NEW_YORK_DESTINATIONS = [ + { id: 'central-park', label: 'Central Park', position: [-73.9654, 40.7829] }, + { id: 'washington-square', label: 'Washington Square Park', position: [-73.9973, 40.7308] }, + { id: 'columbia', label: 'Columbia University', position: [-73.9626, 40.8075] }, +] satisfies Array<{ id: string; label: string; position: [number, number] }>; + +function randomNewYorkPosition(): [number, number] { + const progress = Math.random(); + return [ + -74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012, + 40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01, + ]; +} + +function useRandomNewYorkPosition() { + const [position, setPosition] = useState<[number, number]>(NEW_YORK_CENTER); + useEffect(() => setPosition(randomNewYorkPosition()), []); + return [position, setPosition] as const; +} + +function LocalMapPreview() { + return ( + + ); +} + +export function BasicMapDemo() { + const [position, setPosition] = useRandomNewYorkPosition(); + + return ( + +
+ + + + + + +
+

Random New York position

+

+ {position[1].toFixed(5)}, {position[0].toFixed(5)} +

+
+
+ +
+ +
+
+ ); +} + +export function BlankDataMapDemo() { + const [position, setPosition] = useRandomNewYorkPosition(); + const connections = NEW_YORK_DESTINATIONS.map(({ id, position: destination }) => ({ + id, + from: position, + to: destination, + })); + + return ( + +
+ + + + + + Random New York origin + + {NEW_YORK_DESTINATIONS.map((destination) => ( + + + {destination.label} + + ))} + + + +
+
+ ); +} + +export function BlockDemo() { + return ; +} diff --git a/apps/blocks/src/components/docs/showcase-ui.tsx b/apps/blocks/src/components/docs/showcase-ui.tsx index 80fde7f..fe3108d 100644 --- a/apps/blocks/src/components/docs/showcase-ui.tsx +++ b/apps/blocks/src/components/docs/showcase-ui.tsx @@ -22,6 +22,7 @@ const DEMO_MODULES = { 'dropdown-menu': () => import('./demos/ui-dropdown-menu.demo'), input: () => import('./demos/ui-input.demo'), label: () => import('./demos/ui-label.demo'), + map: () => import('./demos/ui-map.demo'), pagination: () => import('./demos/ui-pagination.demo'), popover: () => import('./demos/ui-popover.demo'), progress: () => import('./demos/ui-progress.demo'), @@ -76,6 +77,7 @@ export const UI_DEMOS = { 'dropdown-menu': getUiDemo('dropdown-menu'), input: getUiDemo('input'), label: getUiDemo('label'), + map: getUiDemo('map'), pagination: getUiDemo('pagination'), popover: getUiDemo('popover'), progress: getUiDemo('progress'), diff --git a/apps/blocks/src/components/docs/ui-demos.test.tsx b/apps/blocks/src/components/docs/ui-demos.test.tsx index 8aaaa64..b305a0a 100644 --- a/apps/blocks/src/components/docs/ui-demos.test.tsx +++ b/apps/blocks/src/components/docs/ui-demos.test.tsx @@ -6,6 +6,7 @@ import { BASE_PRIMITIVES } from '@/lib/base-primitives'; import { UI_DEMOS } from './showcase-ui'; beforeAll(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = function scrollIntoView() {}; if (!Element.prototype.getAnimations) Element.prototype.getAnimations = () => []; if (!window.matchMedia) { diff --git a/apps/blocks/src/components/landing/primitive-showcase-config.ts b/apps/blocks/src/components/landing/primitive-showcase-config.ts index 4ae1469..dee9bb7 100644 --- a/apps/blocks/src/components/landing/primitive-showcase-config.ts +++ b/apps/blocks/src/components/landing/primitive-showcase-config.ts @@ -29,6 +29,7 @@ export const HOME_SHOWCASE_ORDER = [ 'resizable', 'scroll-area', 'table', + 'map', 'label', 'textarea', ] as const satisfies readonly BasePrimitiveName[]; @@ -48,6 +49,7 @@ export const HOME_SHOWCASE_DEMOS = { 'dropdown-menu': getUiDemo('dropdown-menu', 'BasicDropdownMenuDemo'), input: getUiDemo('input', 'ControlledInputDemo'), label: getUiDemo('label', 'RequiredLabelDemo'), + map: getUiDemo('map', 'BasicMapDemo'), pagination: getUiDemo('pagination', 'BasicPaginationDemo'), popover: getUiDemo('popover', 'BasicPopoverDemo'), progress: getUiDemo('progress', 'BlockDemo'), diff --git a/apps/blocks/src/components/source-block-showcase/source-block-showcase-canvas.tsx b/apps/blocks/src/components/source-block-showcase/source-block-showcase-canvas.tsx index 9f4b2a2..2ed77b3 100644 --- a/apps/blocks/src/components/source-block-showcase/source-block-showcase-canvas.tsx +++ b/apps/blocks/src/components/source-block-showcase/source-block-showcase-canvas.tsx @@ -18,6 +18,7 @@ import { createNoopSchemaBuilderAdapter } from '@constructive-io/schema-builder/ import { Sheets, SheetsProvider, + type SheetsGeocodeResult, type SheetsConfig, } from '@constructive-io/sheets'; import { @@ -28,6 +29,13 @@ import { Badge } from '@constructive-io/ui/badge'; import type { SourceBlockDoc } from '@/lib/source-blocks'; +const SHEETS_GEOCODE_RESULTS: readonly SheetsGeocodeResult[] = [ + { label: 'Ho Chi Minh City, Vietnam', longitude: 106.7009, latitude: 10.7769 }, + { label: 'Hanoi, Vietnam', longitude: 105.8342, latitude: 21.0278 }, + { label: 'Da Nang, Vietnam', longitude: 108.2022, latitude: 16.0544 }, + { label: 'Singapore', longitude: 103.8198, latitude: 1.3521 }, +]; + function createSheetsTables(): MockTable[] { return [ { @@ -37,6 +45,12 @@ function createSheetsTables(): MockTable[] { { name: 'name', gqlType: 'String', pgType: 'text' }, { name: 'status', gqlType: 'String', pgType: 'text' }, { name: 'owner', gqlType: 'String', pgType: 'text' }, + { + name: 'location', + gqlType: 'GeoJSON', + pgType: 'geometry', + subtype: 'GeometryPoint', + }, { name: 'updatedAt', gqlType: 'Datetime', pgType: 'timestamptz' }, ], rows: [ @@ -45,6 +59,7 @@ function createSheetsTables(): MockTable[] { name: 'Atlas migration', status: 'In progress', owner: 'Ada Lovelace', + location: { type: 'Point', coordinates: [106.7009, 10.7769] }, updatedAt: '2026-07-28T09:18Z', }, { @@ -52,6 +67,7 @@ function createSheetsTables(): MockTable[] { name: 'Beacon launch', status: 'Review', owner: 'Grace Hopper', + location: { type: 'Point', coordinates: [105.8342, 21.0278] }, updatedAt: '2026-07-27T15:42Z', }, { @@ -59,6 +75,7 @@ function createSheetsTables(): MockTable[] { name: 'Compass research', status: 'Planned', owner: 'Alan Turing', + location: { type: 'Point', coordinates: [108.2022, 16.0544] }, updatedAt: '2026-07-24T11:05Z', }, { @@ -66,6 +83,7 @@ function createSheetsTables(): MockTable[] { name: 'Delta onboarding', status: 'In progress', owner: 'Katherine Johnson', + location: { type: 'Point', coordinates: [103.8198, 1.3521] }, updatedAt: '2026-07-22T08:30Z', }, ], @@ -87,6 +105,29 @@ function SheetsShowcase() { }, execute: mock.execute, executeUpload: mock.executeUpload, + map: { + styles: { + light: { + version: 8, + sources: {}, + layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#f1f5f9' } }], + }, + dark: { + version: 8, + sources: {}, + layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#111827' } }], + }, + }, + geocode: (query, { signal }) => { + signal.throwIfAborted(); + const normalizedQuery = query.trim().toLocaleLowerCase(); + return Promise.resolve( + SHEETS_GEOCODE_RESULTS.filter((result) => + result.label.toLocaleLowerCase().includes(normalizedQuery), + ), + ); + }, + }, }; }, []); diff --git a/apps/blocks/src/content/ui/index.ts b/apps/blocks/src/content/ui/index.ts index ea7ebe5..c2d89e9 100644 --- a/apps/blocks/src/content/ui/index.ts +++ b/apps/blocks/src/content/ui/index.ts @@ -15,6 +15,7 @@ import { drawerDocs } from './drawer'; import { dropdownMenuDocs } from './dropdown-menu'; import { inputDocs } from './input'; import { labelDocs } from './label'; +import { mapDocs } from './map'; import { paginationDocs } from './pagination'; import { popoverDocs } from './popover'; import { progressDocs } from './progress'; @@ -46,6 +47,7 @@ export const PRIMITIVE_DOCS = { 'dropdown-menu': dropdownMenuDocs, input: inputDocs, label: labelDocs, + map: mapDocs, pagination: paginationDocs, popover: popoverDocs, progress: progressDocs, diff --git a/apps/blocks/src/content/ui/map.ts b/apps/blocks/src/content/ui/map.ts new file mode 100644 index 0000000..fbaec03 --- /dev/null +++ b/apps/blocks/src/content/ui/map.ts @@ -0,0 +1,100 @@ +import { definePrimitiveDocs } from '@/lib/primitive-docs'; + +const mapLibreApi = { + href: 'https://maplibre.org/maplibre-gl-js/docs/API/', + label: 'MapLibre GL JS API', +} as const; + +export const mapDocs = definePrimitiveDocs({ + name: 'map', + stateModel: 'host-owned', + whenToUse: [ + 'Use Map for interactive location context, point selection, routes, geographic data layers, or clusters that need MapLibre rendering and Constructive controls.', + 'Use blank mode when the application owns every rendered layer and does not need a street basemap. Use a static image when people do not need to pan, zoom, select, or inspect geographic data.', + 'Supply licensed production styles and tile infrastructure from the host. The built-in demotiles.maplibre.org style is an example default for local previews and documentation.', + ], + usage: { + demo: 'BasicMapDemo', + description: + 'Give Map a bounded height, pass MapLibre camera options directly, and compose markers, popups, controls, routes, arcs, GeoJSON, or clusters as children. The preview randomizes a position within New York while keeping its local basemap tile-free. The style owns its source attribution, so keep the attribution control visible and verify the provider terms before release.', + }, + state: { + title: 'Viewport, styles, and runtime ownership', + description: + 'Map can own its camera or accept viewport with onViewportChange for controlled use. Explicit light and dark styles override blank mode and the demo default; when only one themed style is supplied, both themes reuse it. The npm MapLibre build manages its worker. A normal policy must allow worker-src blob:, while a stricter policy should use MapLibre’s CSP bundle and configure its separate worker URL before this component mounts.', + }, + examples: [ + { + title: 'Tile-free data map', + description: + 'Set blank when routes, arcs, markers, or host layers provide all visual content and the page should not request a basemap. This example connects a randomized New York origin to fixed landmarks.', + demo: 'BlankDataMapDemo', + }, + ], + accessibility: [ + 'Give every custom marker and popup control an accessible name. The built-in control buttons already expose zoom, locate, compass, fullscreen, and close labels.', + 'Treat the map as supporting context when the same coordinates or selected value can be read and edited elsewhere. Map fallback content should preserve that non-visual path when WebGL 2 is unavailable.', + 'Keep provider attribution visible and readable in both themes. Do not cover it with application controls or remove credits required by the style and tile license.', + 'Keyboard and screen-reader users cannot depend on free-form canvas interaction, so expose important features through an adjacent list, form, or table.', + ], + api: [ + { + name: 'Map', + description: + 'MapLibre container with theme-aware styles, controlled camera support, loading state, and initialization fallback.', + props: [ + { + name: 'styles', + type: '{ light?: MapStyleOption; dark?: MapStyleOption }', + description: + 'Supplies host-owned style URLs or specifications. One supplied theme is reused for both themes.', + }, + { + name: 'blank', + type: 'boolean', + default: 'false', + description: 'Uses a transparent style with no tile sources when explicit styles are absent.', + }, + { + name: 'viewport / onViewportChange', + type: 'Partial / (viewport: MapViewport) => void', + description: + 'Controls the camera when both props are supplied, or observes movement through the callback alone.', + }, + { + name: 'fallback', + type: 'ReactNode', + description: 'Replaces the default unavailable state after a WebGL 2 or constructor failure.', + }, + { + name: 'onError', + type: '(error: Error) => void', + description: + 'Reports initialization and later MapLibre errors without removing a usable map for later errors.', + }, + ], + upstream: mapLibreApi, + }, + { + name: 'MapMarker / MarkerContent / MarkerPopup / MarkerTooltip / MarkerLabel', + description: 'Composable marker content with drag callbacks, popup content, hover hints, and labels.', + upstream: mapLibreApi, + }, + { + name: 'MapPopup / MapControls', + description: 'Free-standing popup and Constructive zoom, compass, location, and fullscreen controls.', + upstream: mapLibreApi, + }, + { + name: 'MapRoute / MapArc / MapGeoJSON / MapClusterLayer', + description: 'Declarative line, arc, feature, and clustered-point layers with typed interaction callbacks.', + upstream: mapLibreApi, + }, + { + name: 'useMap', + description: + 'Returns the MapLibre instance, load gate, and resolved light or dark theme for advanced host layers.', + upstream: mapLibreApi, + }, + ], +}); diff --git a/apps/blocks/src/generated/ui-demo-source.ts b/apps/blocks/src/generated/ui-demo-source.ts index c5fd94c..9602381 100644 --- a/apps/blocks/src/generated/ui-demo-source.ts +++ b/apps/blocks/src/generated/ui-demo-source.ts @@ -287,6 +287,20 @@ export const UI_DEMO_SOURCE = { "registry": "'use client';\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nexport function RequiredLabelDemo() {\n return (
\n \n \n

\n Required. Receipts and usage alerts are sent here.\n

\n
);\n}\n" } }, + "map": { + "BasicMapDemo": { + "npm": "'use client';\nimport { useEffect, useState } from 'react';\nimport { Button } from '@constructive-io/ui/button';\nimport { Map, MapControls, MapMarker, MapPopup, MarkerContent } from '@constructive-io/ui/map';\nconst NEW_YORK_CENTER: [\n number,\n number\n] = [-73.9857, 40.7484];\nfunction randomNewYorkPosition(): [\n number,\n number\n] {\n const progress = Math.random();\n return [\n -74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012,\n 40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01,\n ];\n}\nfunction useRandomNewYorkPosition() {\n const [position, setPosition] = useState<[\n number,\n number\n ]>(NEW_YORK_CENTER);\n useEffect(() => setPosition(randomNewYorkPosition()), []);\n return [position, setPosition] as const;\n}\nfunction LocalMapPreview() {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n Manhattan\n \n \n East River\n \n \n );\n}\nexport function BasicMapDemo() {\n const [position, setPosition] = useRandomNewYorkPosition();\n return (
\n \n \n \n \n \n \n
\n

Random New York position

\n

\n {position[1].toFixed(5)}, {position[0].toFixed(5)}\n

\n
\n
\n \n
\n \n
);\n}\n", + "registry": "'use client';\nimport { useEffect, useState } from 'react';\nimport { Button } from \"@/components/ui/button\";\nimport { Map, MapControls, MapMarker, MapPopup, MarkerContent } from \"@/components/ui/map\";\nconst NEW_YORK_CENTER: [\n number,\n number\n] = [-73.9857, 40.7484];\nfunction randomNewYorkPosition(): [\n number,\n number\n] {\n const progress = Math.random();\n return [\n -74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012,\n 40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01,\n ];\n}\nfunction useRandomNewYorkPosition() {\n const [position, setPosition] = useState<[\n number,\n number\n ]>(NEW_YORK_CENTER);\n useEffect(() => setPosition(randomNewYorkPosition()), []);\n return [position, setPosition] as const;\n}\nfunction LocalMapPreview() {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n Manhattan\n \n \n East River\n \n \n );\n}\nexport function BasicMapDemo() {\n const [position, setPosition] = useRandomNewYorkPosition();\n return (
\n \n \n \n \n \n \n
\n

Random New York position

\n

\n {position[1].toFixed(5)}, {position[0].toFixed(5)}\n

\n
\n
\n \n
\n \n
);\n}\n" + }, + "BlankDataMapDemo": { + "npm": "'use client';\nimport { useEffect, useState } from 'react';\nimport { Button } from '@constructive-io/ui/button';\nimport { Map, MapArc, MapControls, MapMarker, MarkerContent, MarkerPopup } from '@constructive-io/ui/map';\nconst NEW_YORK_CENTER: [\n number,\n number\n] = [-73.9857, 40.7484];\nconst NEW_YORK_DESTINATIONS = [\n { id: 'central-park', label: 'Central Park', position: [-73.9654, 40.7829] },\n { id: 'washington-square', label: 'Washington Square Park', position: [-73.9973, 40.7308] },\n { id: 'columbia', label: 'Columbia University', position: [-73.9626, 40.8075] },\n] satisfies Array<{\n id: string;\n label: string;\n position: [\n number,\n number\n ];\n}>;\nfunction randomNewYorkPosition(): [\n number,\n number\n] {\n const progress = Math.random();\n return [\n -74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012,\n 40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01,\n ];\n}\nfunction useRandomNewYorkPosition() {\n const [position, setPosition] = useState<[\n number,\n number\n ]>(NEW_YORK_CENTER);\n useEffect(() => setPosition(randomNewYorkPosition()), []);\n return [position, setPosition] as const;\n}\nfunction LocalMapPreview() {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n Manhattan\n \n \n East River\n \n \n );\n}\nexport function BlankDataMapDemo() {\n const [position, setPosition] = useRandomNewYorkPosition();\n const connections = NEW_YORK_DESTINATIONS.map(({ id, position: destination }) => ({\n id,\n from: position,\n to: destination,\n }));\n return (
\n \n \n \n \n \n Random New York origin\n \n {NEW_YORK_DESTINATIONS.map((destination) => (\n \n {destination.label}\n ))}\n \n \n \n
);\n}\n", + "registry": "'use client';\nimport { useEffect, useState } from 'react';\nimport { Button } from \"@/components/ui/button\";\nimport { Map, MapArc, MapControls, MapMarker, MarkerContent, MarkerPopup } from \"@/components/ui/map\";\nconst NEW_YORK_CENTER: [\n number,\n number\n] = [-73.9857, 40.7484];\nconst NEW_YORK_DESTINATIONS = [\n { id: 'central-park', label: 'Central Park', position: [-73.9654, 40.7829] },\n { id: 'washington-square', label: 'Washington Square Park', position: [-73.9973, 40.7308] },\n { id: 'columbia', label: 'Columbia University', position: [-73.9626, 40.8075] },\n] satisfies Array<{\n id: string;\n label: string;\n position: [\n number,\n number\n ];\n}>;\nfunction randomNewYorkPosition(): [\n number,\n number\n] {\n const progress = Math.random();\n return [\n -74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012,\n 40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01,\n ];\n}\nfunction useRandomNewYorkPosition() {\n const [position, setPosition] = useState<[\n number,\n number\n ]>(NEW_YORK_CENTER);\n useEffect(() => setPosition(randomNewYorkPosition()), []);\n return [position, setPosition] as const;\n}\nfunction LocalMapPreview() {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n Manhattan\n \n \n East River\n \n \n );\n}\nexport function BlankDataMapDemo() {\n const [position, setPosition] = useRandomNewYorkPosition();\n const connections = NEW_YORK_DESTINATIONS.map(({ id, position: destination }) => ({\n id,\n from: position,\n to: destination,\n }));\n return (
\n \n \n \n \n \n Random New York origin\n \n {NEW_YORK_DESTINATIONS.map((destination) => (\n \n {destination.label}\n ))}\n \n \n \n
);\n}\n" + }, + "BlockDemo": { + "npm": "'use client';\nimport { useEffect, useState } from 'react';\nimport { Button } from '@constructive-io/ui/button';\nimport { Map, MapControls, MapMarker, MapPopup, MarkerContent } from '@constructive-io/ui/map';\nconst NEW_YORK_CENTER: [\n number,\n number\n] = [-73.9857, 40.7484];\nfunction randomNewYorkPosition(): [\n number,\n number\n] {\n const progress = Math.random();\n return [\n -74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012,\n 40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01,\n ];\n}\nfunction useRandomNewYorkPosition() {\n const [position, setPosition] = useState<[\n number,\n number\n ]>(NEW_YORK_CENTER);\n useEffect(() => setPosition(randomNewYorkPosition()), []);\n return [position, setPosition] as const;\n}\nfunction LocalMapPreview() {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n Manhattan\n \n \n East River\n \n \n );\n}\nexport function BasicMapDemo() {\n const [position, setPosition] = useRandomNewYorkPosition();\n return (
\n \n \n \n \n \n \n
\n

Random New York position

\n

\n {position[1].toFixed(5)}, {position[0].toFixed(5)}\n

\n
\n
\n \n
\n \n
);\n}\nexport function BlockDemo() {\n return ;\n}\n", + "registry": "'use client';\nimport { useEffect, useState } from 'react';\nimport { Button } from \"@/components/ui/button\";\nimport { Map, MapControls, MapMarker, MapPopup, MarkerContent } from \"@/components/ui/map\";\nconst NEW_YORK_CENTER: [\n number,\n number\n] = [-73.9857, 40.7484];\nfunction randomNewYorkPosition(): [\n number,\n number\n] {\n const progress = Math.random();\n return [\n -74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012,\n 40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01,\n ];\n}\nfunction useRandomNewYorkPosition() {\n const [position, setPosition] = useState<[\n number,\n number\n ]>(NEW_YORK_CENTER);\n useEffect(() => setPosition(randomNewYorkPosition()), []);\n return [position, setPosition] as const;\n}\nfunction LocalMapPreview() {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n Manhattan\n \n \n East River\n \n \n );\n}\nexport function BasicMapDemo() {\n const [position, setPosition] = useRandomNewYorkPosition();\n return (
\n \n \n \n \n \n \n
\n

Random New York position

\n

\n {position[1].toFixed(5)}, {position[0].toFixed(5)}\n

\n
\n
\n \n
\n \n
);\n}\nexport function BlockDemo() {\n return ;\n}\n" + } + }, "pagination": { "BasicPaginationDemo": { "npm": "'use client';\nimport { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '@constructive-io/ui/pagination';\nexport function BasicPaginationDemo() {\n return (\n \n \n \n \n \n 1\n \n \n \n 2\n \n \n \n 3\n \n \n \n \n \n );\n}\n", diff --git a/apps/blocks/src/lib/base-primitives.test.ts b/apps/blocks/src/lib/base-primitives.test.ts index 2003591..21fa3fb 100644 --- a/apps/blocks/src/lib/base-primitives.test.ts +++ b/apps/blocks/src/lib/base-primitives.test.ts @@ -21,6 +21,7 @@ const expectedNames = [ 'dropdown-menu', 'input', 'label', + 'map', 'pagination', 'popover', 'progress', diff --git a/apps/blocks/src/lib/base-primitives.ts b/apps/blocks/src/lib/base-primitives.ts index e64eefd..7d34c03 100644 --- a/apps/blocks/src/lib/base-primitives.ts +++ b/apps/blocks/src/lib/base-primitives.ts @@ -83,6 +83,12 @@ export const BASE_PRIMITIVES = [ exportName: 'Label', description: 'An accessible label for form controls.', }, + { + name: 'map', + title: 'Map', + exportName: 'Map', + description: 'A MapLibre surface for markers, popups, routes, GeoJSON, clusters, and viewport controls.', + }, { name: 'pagination', title: 'Pagination', diff --git a/apps/registry/scripts/smoke-install.ts b/apps/registry/scripts/smoke-install.ts index 3f2a7dd..d5ced9d 100644 --- a/apps/registry/scripts/smoke-install.ts +++ b/apps/registry/scripts/smoke-install.ts @@ -24,6 +24,7 @@ type SmokeCase = { expectedServerFiles?: string[]; forbidden?: string[]; forbiddenPackages?: string[]; + forbiddenSource?: string[]; items?: string[]; strictNullChecks?: boolean; }; @@ -198,7 +199,10 @@ const presetManifest = (id: string): string => `.constructive/feature-packs/${id}.json`; function featurePackClosure(ids: readonly FeaturePackId[]): string[] { - return ids.flatMap((id) => [...featurePackViewFiles[id], featurePackManifest(id)]); + return [ + ...ids.flatMap((id) => [...featurePackViewFiles[id], featurePackManifest(id)]), + ...(ids.includes('data') ? ['src/components/ui/map.tsx'] : []), + ]; } function consoleModuleClosure(ids: readonly FeaturePackId[]): string[] { @@ -209,6 +213,7 @@ function consoleModuleClosure(ids: readonly FeaturePackId[]): string[] { ...consoleModuleFiles[id], featurePackManifest(id), ]), + ...(ids.includes('data') ? ['src/components/ui/map.tsx'] : []), ]; } @@ -222,12 +227,13 @@ function forbiddenFeaturePacks(ids: readonly FeaturePackId[]): string[] { const standaloneFeaturePackCases: SmokeCase[] = featurePackIds.map((id) => ({ name: `feature-pack-${id}`, expectedPackages: id === 'data' - ? ['@constructive-io/data', 'zustand'] + ? ['@constructive-io/data', 'maplibre-gl', 'zustand'] : [], forbiddenPackages: [ ...(id === 'data' ? [] : ['zustand']), '@constructive-io/sheets', ...(id === 'data' ? [] : ['@constructive-io/data']), + ...(id === 'data' ? [] : ['maplibre-gl']), ], expected: featurePackClosure([id]), forbidden: [ @@ -245,9 +251,13 @@ const consoleModuleCases: SmokeCase[] = featurePackIds.map((id) => ({ name: `console-module-${id}`, expectedPackages: [ '@constructive-io/data', + ...(id === 'data' ? ['maplibre-gl'] : []), 'zustand', ], - forbiddenPackages: ['@constructive-io/sheets'], + forbiddenPackages: [ + '@constructive-io/sheets', + ...(id === 'data' ? [] : ['maplibre-gl']), + ], expected: consoleModuleClosure([id]), forbidden: [ ...featurePackIds @@ -276,7 +286,7 @@ const presetCases: SmokeCase[] = [ }, ].map(({ id, packs }) => ({ name: `preset-${id}`, - expectedPackages: ['@constructive-io/data', 'zustand'], + expectedPackages: ['@constructive-io/data', 'maplibre-gl', 'zustand'], forbiddenPackages: ['@constructive-io/sheets'], expected: [ ...consoleModuleClosure(packs as readonly FeaturePackId[]), @@ -301,6 +311,77 @@ const cases: SmokeCase[] = [ name: 'resizable', expected: ['src/components/ui/resizable.tsx'], }, + { + name: 'map', + consumer: `'use client'; + +import { + Map, + MapControls, + MapMarker, + MarkerContent, + type MapStyleOption, +} from '@/components/ui/map'; + +const previewStyle: MapStyleOption = 'https://demotiles.maplibre.org/style.json'; + +export function MapRegistrySmokeConsumer() { + return ( +
+ + + + + + +
+ ); +} +`, + expected: [ + 'src/components/ui/map.tsx', + 'src/components/ui/alert.tsx', + 'src/components/ui/button.tsx', + 'src/components/ui/skeleton.tsx', + 'src/lib/utils.ts', + ], + expectedCss: ['.maplibregl-popup-content', '.maplibregl-ctrl-attrib'], + expectedPackages: ['@types/geojson', 'lucide-react', 'maplibre-gl'], + forbiddenSource: ['unpkg.com', 'basemaps.cartocdn.com', 'nominatim.openstreetmap.org'], + strictNullChecks: true, + }, + { + name: 'map-custom', + items: ['map'], + customAliases: true, + consumer: `'use client'; + +import { Map, MapGeoJSON, type MapStyleOption } from '~/design-system/primitives/map'; + +const blankStyle: MapStyleOption = { version: 8, sources: {}, layers: [] }; + +export function CustomAliasMapRegistrySmokeConsumer() { + return ( +
+ + + +
+ ); +} +`, + expected: [ + 'src/design-system/primitives/map.tsx', + 'src/design-system/primitives/alert.tsx', + 'src/design-system/primitives/button.tsx', + 'src/design-system/primitives/skeleton.tsx', + 'src/shared/utils.ts', + ], + expectedCss: ['.maplibregl-popup-content', '.maplibregl-ctrl-attrib'], + expectedPackages: ['@types/geojson', 'lucide-react', 'maplibre-gl'], + forbiddenSource: ['@constructive-io/', 'unpkg.com', 'basemaps.cartocdn.com'], + strictNullChecks: true, + }, { name: 'overlays-default', items: [...overlayItems], @@ -414,12 +495,13 @@ export function AiRegistrySmokeConsumer() { }, { name: 'sheets', - expectedPackages: ['@constructive-io/data'], + expectedPackages: ['@constructive-io/data', 'maplibre-gl'], forbiddenPackages: ['@constructive-io/sheets'], expected: [ 'src/components/ui/sheets/index.ts', 'src/components/ui/sheets/context/sheets-provider.tsx', 'src/components/ui/sheets/grid/sheets.tsx', + 'src/components/ui/map.tsx', ], }, { @@ -473,6 +555,7 @@ export function AiRegistrySmokeConsumer() { name: 'console-kit-nextjs', expectedPackages: [ '@constructive-io/data', + 'maplibre-gl', 'zustand', ], forbiddenPackages: ['@constructive-io/sheets'], @@ -1069,6 +1152,11 @@ function assertInstalled(root: string, testCase: SmokeCase): void { if (/['"]@schema-builder\//.test(source)) { throw new Error(`@constructive/${testCase.name} left an unshipped @schema-builder alias in ${file}.`); } + for (const fragment of testCase.forbiddenSource ?? []) { + if (source.includes(fragment)) { + throw new Error(`@constructive/${testCase.name} left forbidden source ${fragment} in ${file}.`); + } + } } const css = fs.readFileSync(path.join(root, 'src/app/globals.css'), 'utf8'); diff --git a/packages/sheets/README.md b/packages/sheets/README.md index bfbe523..e61e2d6 100644 --- a/packages/sheets/README.md +++ b/packages/sheets/README.md @@ -32,7 +32,7 @@ UI or Sheets packages. | Package | For | |---------|-----| -| `leaflet` + `react-leaflet` | Geometry/map editors | +| `maplibre-gl` | Point geometry map editor | | `react-aria-components` + `@internationalized/date` | Date picker editors | The registry command installs these so every built-in editor typechecks out of @@ -112,6 +112,41 @@ function MySpreadsheet() { } ``` +## Map configuration + +Point geometry fields use the reusable Constructive MapLibre component. With no map configuration, the editor uses +`https://demotiles.maplibre.org/style.json`; that endpoint is suitable for examples and local previews, not production +tile traffic. Production hosts should provide licensed light and dark styles: + +```tsx +import type { SheetsConfig, SheetsGeocodeResult } from '@constructive-io/sheets'; + +const config: SheetsConfig = { + endpoint: '/graphql', + auth: { mode: 'embedded', getToken: () => session.token }, + map: { + styles: { + light: process.env.NEXT_PUBLIC_MAP_STYLE_LIGHT!, + dark: process.env.NEXT_PUBLIC_MAP_STYLE_DARK!, + }, + geocode: async (query, { signal, locale }) => { + const response = await fetch( + `/api/geocode?q=${encodeURIComponent(query)}&locale=${encodeURIComponent(locale)}`, + { signal }, + ); + if (!response.ok) throw new Error('Geocoder request failed'); + return response.json() as Promise; + }, + }, +}; +``` + +Address search is hidden when `map.geocode` is omitted, while clicking and dragging the marker still work. Keep +provider keys and rate-limit policy in the host endpoint; do not call the public Nominatim autocomplete service from +the browser. MapLibre normally uses a blob worker, so strict Content Security Policies must follow MapLibre's CSP +worker setup. Keep the style's required attribution visible and see the [MapLibre CSP +directives](https://maplibre.org/maplibre-gl-js/docs/#csp-directives) when the host cannot allow `worker-src blob:`. + ## CSS Setup Sheets requires Tailwind CSS v4. diff --git a/packages/sheets/docs/EMBEDDING.md b/packages/sheets/docs/EMBEDDING.md index 6987931..51f35b4 100644 --- a/packages/sheets/docs/EMBEDDING.md +++ b/packages/sheets/docs/EMBEDDING.md @@ -15,10 +15,7 @@ This guide walks through integrating `@constructive-io/sheets` into a Next.js ap npm install @constructive-io/sheets @constructive-io/data @constructive-io/ui # Required peer dependencies -npm install @glideapps/glide-data-grid @tanstack/react-query @tanstack/react-form - -# Optional — geometry editors -npm install leaflet react-leaflet @types/leaflet +npm install @tanstack/react-query @tanstack/react-form maplibre-gl # Optional — date picker editors npm install react-aria-components @internationalized/date diff --git a/packages/sheets/package.json b/packages/sheets/package.json index fa7e61c..0ff4058 100644 --- a/packages/sheets/package.json +++ b/packages/sheets/package.json @@ -85,11 +85,10 @@ "@internationalized/date": "^3", "@tanstack/react-form": ">=0.40", "@tanstack/react-query": "^5", - "leaflet": "^1", + "maplibre-gl": "^6.0.0", "react": ">=18", "react-aria-components": ">=1", - "react-dom": ">=18", - "react-leaflet": "^5" + "react-dom": ">=18" }, "peerDependenciesMeta": { "@internationalized/date": { @@ -97,12 +96,6 @@ }, "react-aria-components": { "optional": true - }, - "leaflet": { - "optional": true - }, - "react-leaflet": { - "optional": true } }, "dependencies": { @@ -126,11 +119,11 @@ "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-form": ">=0.40", "@tanstack/react-query": "^5", - "@types/leaflet": "^1.9.21", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4.7.0", "jsdom": "^26.1.0", + "maplibre-gl": "^6.0.0", "react": "^19", "react-dom": "^19", "storybook": "^10.1.11", diff --git a/packages/sheets/registry.json b/packages/sheets/registry.json index 64d6c1d..65a8287 100644 --- a/packages/sheets/registry.json +++ b/packages/sheets/registry.json @@ -8,7 +8,7 @@ "type": "registry:block", "title": "Sheets", "description": "A metadata-driven, spreadsheet-grade CRUD grid with accessible DOM cells and adapter-owned data access.", - "docs": "Install the complete source-owned grid with `pnpm dlx shadcn@latest add @constructive/sheets`, then import `Sheets`, `SheetsProvider`, and the adapter contracts from `@/components/ui/sheets`. The copied source depends on `@constructive-io/data` for the current `_meta` contract and runtime GraphQL operations; your host remains responsible for its endpoint, session, and RLS-aware execution boundary.", + "docs": "Install the complete source-owned grid with `pnpm dlx shadcn@latest add @constructive/sheets`, then import `Sheets`, `SheetsProvider`, and the adapter contracts from `@/components/ui/sheets`. The copied source includes `@constructive/map` for Point geometry editing and depends on `@constructive-io/data` for the current `_meta` contract; your host remains responsible for endpoints, sessions, production map styles, geocoding, and RLS-aware execution.", "categories": [ "blocks", "data" @@ -21,15 +21,12 @@ "@tanstack/react-query", "@tanstack/react-table@9.0.0-beta.58", "@tanstack/react-virtual", - "@types/leaflet", "clsx", "graphql", "inflekt", - "leaflet", "lucide-react", "motion", "react-aria-components", - "react-leaflet", "tailwind-merge", "zod", "zustand" @@ -581,6 +578,11 @@ "target": "@ui/sheets/grid/editors/geometry-editor.tsx", "type": "registry:component" }, + { + "path": "registry/constructive/blocks/sheets/grid/editors/geometry-types.ts", + "target": "@ui/sheets/grid/editors/geometry-types.ts", + "type": "registry:lib" + }, { "path": "registry/constructive/blocks/sheets/grid/editors/image-editor.tsx", "target": "@ui/sheets/grid/editors/image-editor.tsx", diff --git a/packages/sheets/scripts/build-registry.ts b/packages/sheets/scripts/build-registry.ts index 4baf8f2..bfb8cc7 100644 --- a/packages/sheets/scripts/build-registry.ts +++ b/packages/sheets/scripts/build-registry.ts @@ -16,7 +16,7 @@ buildSourceRegistry({ title: 'Sheets', description: 'A metadata-driven, spreadsheet-grade CRUD grid with accessible DOM cells and adapter-owned data access.', categories: ['blocks', 'data'], - docs: "Install the complete source-owned grid with `pnpm dlx shadcn@latest add @constructive/sheets`, then import `Sheets`, `SheetsProvider`, and the adapter contracts from `@/components/ui/sheets`. The copied source depends on `@constructive-io/data` for the current `_meta` contract and runtime GraphQL operations; your host remains responsible for its endpoint, session, and RLS-aware execution boundary.", + docs: "Install the complete source-owned grid with `pnpm dlx shadcn@latest add @constructive/sheets`, then import `Sheets`, `SheetsProvider`, and the adapter contracts from `@/components/ui/sheets`. The copied source includes `@constructive/map` for Point geometry editing and depends on `@constructive-io/data` for the current `_meta` contract; your host remains responsible for endpoints, sessions, production map styles, geocoding, and RLS-aware execution.", }, registrySubdirectory: 'blocks/sheets', targetPrefix: '@ui/sheets', @@ -28,15 +28,12 @@ buildSourceRegistry({ '@tanstack/react-query', '@tanstack/react-table@9.0.0-beta.58', '@tanstack/react-virtual', - '@types/leaflet', 'clsx', 'graphql', 'inflekt', - 'leaflet', 'lucide-react', 'motion', 'react-aria-components', - 'react-leaflet', 'tailwind-merge', 'zod', 'zustand', diff --git a/packages/sheets/src/cell-model/create-sheets-cell.ts b/packages/sheets/src/cell-model/create-sheets-cell.ts index c1b3843..150ca11 100644 --- a/packages/sheets/src/cell-model/create-sheets-cell.ts +++ b/packages/sheets/src/cell-model/create-sheets-cell.ts @@ -4,7 +4,7 @@ // neutral {@link SheetsCell} render payload. Geometry stays out-of-band: the // optional `deriveGeometry` hook is threaded straight through to the geometry // factory (v1's `createGeometryCell` mirror) so this module never imports the -// leaflet-backed geometry view. +// map-backed geometry view. // // Precedence MIRRORS v1 CELL_FACTORIES exactly (image, uri, badges, dateTime, // interval, geometry, relation, number, boolean, text) — `text` is the universal diff --git a/packages/sheets/src/cell-model/factories/geometry.ts b/packages/sheets/src/cell-model/factories/geometry.ts index fbf8209..81a1c38 100644 --- a/packages/sheets/src/cell-model/factories/geometry.ts +++ b/packages/sheets/src/cell-model/factories/geometry.ts @@ -3,7 +3,7 @@ // Owns GEOMETRY_TYPES (geometry, geometry-point, geometry-collection). Geometry // stays out-of-band: when the caller supplies a `deriveGeometry` builder (the // native mirror of v1's `createGeometryCell`), this factory delegates to it so -// this module never imports the leaflet-backed geometry view. Without a builder, +// this module never imports the map-backed geometry view. Without a builder, // it falls back to a compact JSON preview as both the copy value and the display // string — identical to v1's Text fallback. // diff --git a/packages/sheets/src/cell-model/factories/types.ts b/packages/sheets/src/cell-model/factories/types.ts index 98dfce5..206b4aa 100644 --- a/packages/sheets/src/cell-model/factories/types.ts +++ b/packages/sheets/src/cell-model/factories/types.ts @@ -2,7 +2,7 @@ // family. Each factory claims a set of `cellType`s (and/or value shapes) and emits // the neutral {@link SheetsCell} render payload. Geometry stays out-of-band: the // optional `deriveGeometry` hook lets a factory delegate to a geometry builder -// without this module importing leaflet peers (mirrors v1 createGeometryCell). +// without this module importing map peers (mirrors v1 createGeometryCell). import type { CellCreationMetadata } from '../../grid/grid-cell-types'; import type { SheetsCell } from '../sheets-cell'; diff --git a/packages/sheets/src/cell-model/views/geometry-view.tsx b/packages/sheets/src/cell-model/views/geometry-view.tsx index 2a05ff3..980c9fe 100644 --- a/packages/sheets/src/cell-model/views/geometry-view.tsx +++ b/packages/sheets/src/cell-model/views/geometry-view.tsx @@ -3,7 +3,7 @@ // preview it paints a geo ICON tinted by geometry category (point/line/polygon/ // collection), plus an ABBREVIATED type label, mirroring the canvas `draw`. // -// The geometry TYPE is derived without a leaflet/geojson dependency: prefer an +// The geometry TYPE is derived without a map-runtime/geojson dependency: prefer an // explicit hint (`cell.meta.geometryType`, or a `{ geometryType }` object set by a // `deriveGeometry` builder — v1 `createGeometryCell`), else sniff the GeoJSON // `"type":"…"` token out of the compact preview string the factory put in diff --git a/packages/sheets/src/context/sheets-context.ts b/packages/sheets/src/context/sheets-context.ts index c76fdc7..bab7b49 100644 --- a/packages/sheets/src/context/sheets-context.ts +++ b/packages/sheets/src/context/sheets-context.ts @@ -1,5 +1,6 @@ import { createContext, useContext } from 'react'; import type { QueryClient } from '@tanstack/react-query'; +import type { MapStyleOption } from '@constructive-io/ui/map'; import type { SheetsExecuteFn, SheetsUploadFn } from './sheets-execute'; import type { SheetsLogger } from '../utils/sheets-logger'; @@ -21,6 +22,29 @@ export interface SheetsAuthStandalone { mode: 'standalone'; } +export interface SheetsMapStyles { + light?: MapStyleOption; + dark?: MapStyleOption; +} + +export interface SheetsGeocodeResult { + label: string; + longitude: number; + latitude: number; +} + +export type SheetsGeocodeFn = ( + query: string, + context: { signal: AbortSignal; locale: string }, +) => Promise; + +export interface SheetsMapConfig { + /** Optional production map styles. The MapLibre demo style is used when omitted. */ + styles?: SheetsMapStyles; + /** Optional host geocoder. Search is hidden when this callback is omitted. */ + geocode?: SheetsGeocodeFn; +} + export interface SheetsConfig { /** Data endpoint (app-public GraphQL) */ endpoint: string; @@ -34,6 +58,8 @@ export interface SheetsConfig { fieldTypeOverrides?: Record; /** Optional: BCP-47 locale for locale-aware date/number formatting (default 'en-US'). */ locale?: string; + /** Optional map styles and address-search adapter for Point geometry editing. */ + map?: SheetsMapConfig; /** Optional: inject custom execute function */ execute?: SheetsExecuteFn; /** Optional: inject custom upload function */ diff --git a/packages/sheets/src/grid-dom/editors/__tests__/geometry-editor.test.tsx b/packages/sheets/src/grid-dom/editors/__tests__/geometry-editor.test.tsx index a4ae716..b88efa1 100644 --- a/packages/sheets/src/grid-dom/editors/__tests__/geometry-editor.test.tsx +++ b/packages/sheets/src/grid-dom/editors/__tests__/geometry-editor.test.tsx @@ -5,133 +5,237 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SheetsCell } from '../../../cell-model/sheets-cell'; +import { SheetsContext, type SheetsContextValue } from '../../../context/sheets-context'; +import { resolveExpectedGeometryType } from '../../../grid/editors/geometry-types'; import type { EditorProps } from '../editor-props'; import { GeometryEditorDom } from '../geometry-editor'; +const mapPickerMock = vi.hoisted(() => ({ shouldThrow: false })); + +vi.mock('../../../utils/map-picker', async () => { + const React = await import('react'); + return { + MapPicker: () => { + if (mapPickerMock.shouldThrow) throw new Error('MapLibre could not initialize'); + return React.createElement('div', { 'data-map-picker': true }); + }, + }; +}); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; // A non-Point geometry (with no Point subtype) lands the editor on its JSON-only -// layout: no map tab, so the lazy Leaflet MapPicker never loads and the textarea is +// layout: no map tab, so the lazy MapLibre MapPicker never loads and the textarea is // the single editable surface (no Tabs portal / inactive-tab realm quirks). -const LINE = { type: 'LineString', coordinates: [[0, 0], [1, 1], [2, 0]] } as const; +const LINE = { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1], + [2, 0], + ], +} as const; function makeProps(value: unknown, over: Partial = {}): EditorProps { - const cell: SheetsCell = { kind: 'custom', data: value, displayData: '', readonly: false } as SheetsCell; - return { - value, - cell, - colKey: 'location', - rowId: 'row-1', - rowIndex: 0, - // No subtype -> a non-Point value lands on the JSON-only layout (no map tab, - // no lazy Leaflet load), so the textarea is the editable surface. - fieldMeta: undefined, - onCommit: vi.fn(), - onCommitPatch: vi.fn(), - onCancel: vi.fn(), - overlay: { maxHeight: 400, flipped: false }, - ...over, - }; + const cell: SheetsCell = { kind: 'custom', data: value, displayData: '', readonly: false } as SheetsCell; + return { + value, + cell, + colKey: 'location', + rowId: 'row-1', + rowIndex: 0, + // No subtype -> a non-Point value lands on the JSON-only layout (no map tab, + // no lazy MapLibre load), so the textarea is the editable surface. + fieldMeta: undefined, + onCommit: vi.fn(), + onCommitPatch: vi.fn(), + onCancel: vi.fn(), + overlay: { maxHeight: 400, flipped: false }, + ...over, + }; } function setTextarea(ta: HTMLTextAreaElement, text: string) { - const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!; - setter.call(ta, text); - ta.dispatchEvent(new Event('input', { bubbles: true })); + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!; + setter.call(ta, text); + ta.dispatchEvent(new Event('input', { bubbles: true })); } function findSave(container: HTMLElement) { - return Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Save'); + return Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Save'); } describe('GeometryEditorDom (native EditorProps adapter)', () => { - let root: Root; - let container: HTMLDivElement; - - beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(async () => { - await act(async () => { - root.unmount(); - }); - container.remove(); - vi.clearAllMocks(); - }); - - it('renders with data-slot and seeds the textarea from the raw geojson value', async () => { - await act(async () => { - root.render(); - }); - - expect(container.querySelector('[data-slot="geometry-editor"]')).toBeTruthy(); - const ta = container.querySelector('textarea') as HTMLTextAreaElement; - expect(ta).toBeTruthy(); - expect(JSON.parse(ta.value)).toEqual(LINE); - }); - - it('edit + Save commits the raw geojson value (onCommit only)', async () => { - const onCommit = vi.fn(); - const onCommitPatch = vi.fn(); - const onCancel = vi.fn(); - await act(async () => { - root.render(); - }); - - const ta = container.querySelector('textarea') as HTMLTextAreaElement; - const next = { type: 'Point', coordinates: [3, 4] }; - await act(async () => { - setTextarea(ta, JSON.stringify(next, null, 2)); - }); - - const saveBtn = findSave(container); - expect(saveBtn).toBeTruthy(); - await act(async () => { - saveBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(onCommit).toHaveBeenCalledTimes(1); - // Value editor: commits the raw geojson string (compact, top-level geometry). - expect(JSON.parse(onCommit.mock.calls[0][0] as string)).toEqual(next); - expect(onCommitPatch).not.toHaveBeenCalled(); - expect(onCancel).not.toHaveBeenCalled(); - }); - - it('does not commit invalid JSON (Save disabled)', async () => { - const onCommit = vi.fn(); - await act(async () => { - root.render(); - }); - - const ta = container.querySelector('textarea') as HTMLTextAreaElement; - await act(async () => { - setTextarea(ta, '{ not valid'); - }); - await act(async () => { - findSave(container)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(onCommit).not.toHaveBeenCalled(); - }); - - it('Escape cancels via onFinishedEditing(undefined) and never commits', async () => { - const onCommit = vi.fn(); - const onCancel = vi.fn(); - await act(async () => { - root.render(); - }); - - // The reused GeometryEditor's EditorFocusTrap binds Escape -> handleCancel -> - // onFinishedEditing(undefined) -> onCancel. - const ta = container.querySelector('textarea') as HTMLTextAreaElement; - await act(async () => { - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - }); - - expect(onCancel).toHaveBeenCalledTimes(1); - expect(onCommit).not.toHaveBeenCalled(); - }); + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mapPickerMock.shouldThrow = false; + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + }); + + it('renders with data-slot and seeds the textarea from the raw geojson value', async () => { + await act(async () => { + root.render(); + }); + + expect(container.querySelector('[data-slot="geometry-editor"]')).toBeTruthy(); + expect( + Array.from(container.querySelectorAll('button')).some((button) => button.textContent?.trim() === 'Map'), + ).toBe(false); + const ta = container.querySelector('textarea') as HTMLTextAreaElement; + expect(ta).toBeTruthy(); + expect(JSON.parse(ta.value)).toEqual(LINE); + }); + + it('preserves canonical geometry names while unwrapping metadata names', () => { + expect(resolveExpectedGeometryType('GeometryCollection')).toBe('GeometryCollection'); + expect(resolveExpectedGeometryType('GeometryGeometryCollection')).toBe('GeometryCollection'); + expect(resolveExpectedGeometryType('GeometryPoint')).toBe('Point'); + expect(resolveExpectedGeometryType('GeometryLine')).toBeUndefined(); + }); + + it('accepts and saves a valid GeometryCollection column value', async () => { + const onCommit = vi.fn(); + const collection = { + type: 'GeometryCollection', + geometries: [{ type: 'Point', coordinates: [106.7, 10.8] }], + } as const; + await act(async () => { + root.render( + , + ); + }); + + const save = findSave(container); + expect(save?.disabled).toBe(false); + await act(async () => save?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + expect(JSON.parse(onCommit.mock.calls[0][0] as string)).toEqual(collection); + }); + + it('keeps JSON editing usable and reports a Point map initialization failure', async () => { + mapPickerMock.shouldThrow = true; + const onError = vi.fn(); + const point = { type: 'Point', coordinates: [106.7, 10.8] } as const; + const props = makeProps(point, { + fieldMeta: { + name: 'location', + type: { gqlType: 'GeoJSON', subtype: 'GeometryPoint' }, + }, + }); + const contextValue: SheetsContextValue = { + config: { + endpoint: 'mock://geometry-editor', + auth: { mode: 'embedded', getToken: () => null }, + onError, + }, + execute: vi.fn(), + executeUpload: vi.fn(), + scopeKey: { + databaseId: null, + endpoint: 'mock://geometry-editor', + identityKey: null, + }, + }; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await act(async () => { + root.render( + + + , + ); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'MapLibre could not initialize' }), { + source: 'editor', + }); + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + expect(textarea).toBeTruthy(); + expect(JSON.parse(textarea.value)).toEqual(point); + consoleError.mockRestore(); + }); + + it('edit + Save commits the raw geojson value (onCommit only)', async () => { + const onCommit = vi.fn(); + const onCommitPatch = vi.fn(); + const onCancel = vi.fn(); + await act(async () => { + root.render(); + }); + + const ta = container.querySelector('textarea') as HTMLTextAreaElement; + const next = { type: 'Point', coordinates: [3, 4] }; + await act(async () => { + setTextarea(ta, JSON.stringify(next, null, 2)); + }); + + const saveBtn = findSave(container); + expect(saveBtn).toBeTruthy(); + await act(async () => { + saveBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onCommit).toHaveBeenCalledTimes(1); + // Value editor: commits the raw geojson string (compact, top-level geometry). + expect(JSON.parse(onCommit.mock.calls[0][0] as string)).toEqual(next); + expect(onCommitPatch).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('does not commit invalid JSON (Save disabled)', async () => { + const onCommit = vi.fn(); + await act(async () => { + root.render(); + }); + + const ta = container.querySelector('textarea') as HTMLTextAreaElement; + await act(async () => { + setTextarea(ta, '{ not valid'); + }); + await act(async () => { + findSave(container)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('Escape cancels via onFinishedEditing(undefined) and never commits', async () => { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + await act(async () => { + root.render(); + }); + + // The reused GeometryEditor's EditorFocusTrap binds Escape -> handleCancel -> + // onFinishedEditing(undefined) -> onCancel. + const ta = container.querySelector('textarea') as HTMLTextAreaElement; + await act(async () => { + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCommit).not.toHaveBeenCalled(); + }); }); diff --git a/packages/sheets/src/grid-dom/editors/geometry-editor.tsx b/packages/sheets/src/grid-dom/editors/geometry-editor.tsx index 4162e44..230f7b1 100644 --- a/packages/sheets/src/grid-dom/editors/geometry-editor.tsx +++ b/packages/sheets/src/grid-dom/editors/geometry-editor.tsx @@ -8,26 +8,27 @@ // • expectedType <- EditorProps.fieldMeta?.type?.subtype (column geometry subtype). // • onFinishedEditing(next) -> onCommit(next) (raw geojson string; host builds the // cell), onFinishedEditing(undefined) -> onCancel(). -// The Suspense wrapper preserves the source's lazy-loaded Leaflet MapPicker. Escape is +// The Suspense wrapper preserves the source's lazy-loaded MapLibre MapPicker. Escape is // handled by the reused editor's EditorFocusTrap -> handleCancel -> // onFinishedEditing(undefined). import { Suspense } from 'react'; import { GeometryEditor } from '../../grid/editors/geometry-editor'; +import { resolveExpectedGeometryType } from '../../grid/editors/geometry-types'; import type { EditorProps } from './editor-props'; export function GeometryEditorDom({ value, fieldMeta, onCommit, onCancel }: EditorProps) { - const expectedType = fieldMeta?.type?.subtype || undefined; - return ( -
- }> - (next === undefined ? onCancel() : onCommit(next))} - /> - -
- ); + const expectedType = resolveExpectedGeometryType(fieldMeta?.type?.subtype); + return ( +
+ }> + (next === undefined ? onCancel() : onCommit(next))} + /> + +
+ ); } diff --git a/packages/sheets/src/grid/__golden__/display-cases.ts b/packages/sheets/src/grid/__golden__/display-cases.ts index 5145104..7931627 100644 --- a/packages/sheets/src/grid/__golden__/display-cases.ts +++ b/packages/sheets/src/grid/__golden__/display-cases.ts @@ -11,7 +11,7 @@ import type { CellCreationMetadata } from '../grid-cell-types'; import type { RelationInfo } from '../../store/relation-info-slice'; // Deterministic geometry SheetsCell — the native render payload a geometry factory -// emits. We do NOT import the real createGeometryCell (it pulls optional leaflet +// emits. We do NOT import the real createGeometryCell (it pulls the lazy map // peers); a stable `geo:` displayText keeps the golden reproducible while // exercising projectSheetsCell's geometry branch (display === copy) faithfully. export function fakeGeometrySheetsCell(value: unknown): SheetsCell { diff --git a/packages/sheets/src/grid/__golden__/parity.harness.ts b/packages/sheets/src/grid/__golden__/parity.harness.ts index 8ede63d..8472c39 100644 --- a/packages/sheets/src/grid/__golden__/parity.harness.ts +++ b/packages/sheets/src/grid/__golden__/parity.harness.ts @@ -17,7 +17,7 @@ * 3. A golden assert/update helper — {@link assertOrUpdateGolden} — colocated * JSON snapshots with stable key order, switched by `UPDATE_GOLDEN`. * - * Nothing here imports the real `createGeometryCell` (it pulls optional leaflet + * Nothing here imports the real `createGeometryCell` (it pulls the lazy map * peers); the geometry custom-cell is projected from its `displayData`. */ diff --git a/packages/sheets/src/grid/editors/geometry-editor.tsx b/packages/sheets/src/grid/editors/geometry-editor.tsx index 5e50c4d..7bb6ed9 100644 --- a/packages/sheets/src/grid/editors/geometry-editor.tsx +++ b/packages/sheets/src/grid/editors/geometry-editor.tsx @@ -1,230 +1,329 @@ -import React, { lazy, Suspense, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { AlertCircle, FileText, Loader2, Map, MapPin, Shuffle } from 'lucide-react'; +import React, { + Component, + lazy, + Suspense, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { AlertCircle, FileText, Map, MapPin, Shuffle } from 'lucide-react'; import { cn } from '../../utils/cn'; import { Button } from '@constructive-io/ui/button'; +import { Skeleton } from '@constructive-io/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui/tabs'; import { Textarea } from '@constructive-io/ui/textarea'; +import { SheetsContext } from '../../context/sheets-context'; +import type { MapPickerValue } from '../../utils/map-picker'; import { EditorFocusTrap } from './editor-focus-trap'; +import { GEOJSON_GEOMETRY_TYPES } from './geometry-types'; import { OVERLAY } from './overlay-presets'; import { OverlayMeasureContext } from './overlay-viewport-guard'; -// Lazy load MapPicker to avoid bundling ~270KB Leaflet in main chunk -const MapPicker = lazy(() => - import('../../utils/map-picker').then((m) => ({ default: m.MapPicker })) -); +// Lazy-load MapPicker so MapLibre stays out of the main Sheets chunk. +const MapPicker = lazy(() => import('../../utils/map-picker').then((m) => ({ default: m.MapPicker }))); function MapPickerLoading() { - return ( -
- -
- ); + return ( +
+ + Loading map +
+ ); } -// GeoJSON validation according to RFC 7946 -const VALID_GEOJSON_TYPES = [ - 'Point', - 'LineString', - 'Polygon', - 'MultiPoint', - 'MultiLineString', - 'MultiPolygon', - 'GeometryCollection', -]; +interface MapPickerErrorBoundaryProps { + children: ReactNode; + onError: (error: unknown) => void; + onFallback: () => void; +} -interface GeometryType { - geojson: any; // GeoJSON object - srid: number; // Integer - x: number; // Float - y: number; // Float +class MapPickerErrorBoundary extends Component { + state = { error: null as unknown }; + + static getDerivedStateFromError(error: unknown) { + return { error }; + } + + componentDidCatch(error: unknown): void { + this.props.onError(error); + this.props.onFallback(); + } + + render(): ReactNode { + if (this.state.error == null) return this.props.children; + return ( +
+ The interactive map could not load. Continue in the JSON tab. +
+ ); + } } -interface MapPickerValue { - geojson: { - type: 'Point'; - coordinates: [number, number]; - }; - srid: number; - x: number; - y: number; +// GeoJSON validation according to RFC 7946 +interface GeometryType { + geojson: any; // GeoJSON object + srid: number; // Integer + x: number; // Float + y: number; // Float } function isValidGeoJSON(obj: any): boolean { - if (!obj || typeof obj !== 'object') return false; - - // Must have a type property - if (!obj.type || typeof obj.type !== 'string') return false; - - // Feature/FeatureCollection are valid GeoJSON per RFC 7946 but PostGIS - // ST_GeomFromGeoJSON only accepts geometry types — reject them here. - if (obj.type === 'Feature' || obj.type === 'FeatureCollection') return false; - - // Type must be one of the valid GeoJSON geometry types - if (!VALID_GEOJSON_TYPES.includes(obj.type)) return false; - - // Structural validation for types with well-defined coordinate shapes. - // Multi* and GeometryCollection are accepted with a type-name check only. - switch (obj.type) { - case 'Point': - return Array.isArray(obj.coordinates) && obj.coordinates.length >= 2; - case 'LineString': - return ( - Array.isArray(obj.coordinates) && - obj.coordinates.length >= 2 && - obj.coordinates.every((coord: any) => Array.isArray(coord) && coord.length >= 2) - ); - case 'Polygon': - return ( - Array.isArray(obj.coordinates) && - obj.coordinates.length >= 1 && - obj.coordinates.every((ring: any) => Array.isArray(ring) && ring.length >= 4) - ); - default: - return true; - } + if (!obj || typeof obj !== 'object') return false; + + // Must have a type property + if (!obj.type || typeof obj.type !== 'string') return false; + + // Feature/FeatureCollection are valid GeoJSON per RFC 7946 but PostGIS + // ST_GeomFromGeoJSON only accepts geometry types — reject them here. + if (obj.type === 'Feature' || obj.type === 'FeatureCollection') return false; + + // Type must be one of the valid GeoJSON geometry types + if (!(GEOJSON_GEOMETRY_TYPES as readonly string[]).includes(obj.type)) return false; + + // Structural validation for types with well-defined coordinate shapes. + // Multi* and GeometryCollection are accepted with a type-name check only. + switch (obj.type) { + case 'Point': + return Array.isArray(obj.coordinates) && obj.coordinates.length >= 2; + case 'LineString': + return ( + Array.isArray(obj.coordinates) && + obj.coordinates.length >= 2 && + obj.coordinates.every((coord: any) => Array.isArray(coord) && coord.length >= 2) + ); + case 'Polygon': + return ( + Array.isArray(obj.coordinates) && + obj.coordinates.length >= 1 && + obj.coordinates.every((ring: any) => Array.isArray(ring) && ring.length >= 4) + ); + default: + return true; + } } /** Returns a helpful hint when the GeoJSON type is recognized but unsupported by PostGIS. */ function getGeoJSONHint(obj: any): string | undefined { - if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') return undefined; - if (obj.type === 'Feature') return 'Feature type is not supported by PostGIS. Extract the geometry property first.'; - if (obj.type === 'FeatureCollection') return 'FeatureCollection type is not supported by PostGIS. Extract individual geometries first.'; - return undefined; + if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') return undefined; + if (obj.type === 'Feature') return 'Feature type is not supported by PostGIS. Extract the geometry property first.'; + if (obj.type === 'FeatureCollection') + return 'FeatureCollection type is not supported by PostGIS. Extract individual geometries first.'; + return undefined; } function isValidGeometry(obj: any): boolean { - if (!obj || typeof obj !== 'object') return false; + if (!obj || typeof obj !== 'object') return false; - // Must have all required properties - if (!('geojson' in obj) || !('srid' in obj) || !('x' in obj) || !('y' in obj)) { - return false; - } + // Must have all required properties + if (!('geojson' in obj) || !('srid' in obj) || !('x' in obj) || !('y' in obj)) { + return false; + } - // Validate geojson - if (!isValidGeoJSON(obj.geojson)) return false; + // Validate geojson + if (!isValidGeoJSON(obj.geojson)) return false; - // Validate srid (must be integer) - if (!Number.isInteger(obj.srid)) return false; + // Validate srid (must be integer) + if (!Number.isInteger(obj.srid)) return false; - // Validate x and y (must be finite numbers) - if (!Number.isFinite(obj.x) || !Number.isFinite(obj.y)) return false; + // Validate x and y (must be finite numbers) + if (!Number.isFinite(obj.x) || !Number.isFinite(obj.y)) return false; - return true; + return true; } // Accept wrapped objects which only contain a valid `geojson` property (no srid/x/y) function isWrappedGeoJSON(obj: any): boolean { - if (!obj || typeof obj !== 'object') return false; - if (!('geojson' in obj)) return false; - return isValidGeoJSON(obj.geojson); + if (!obj || typeof obj !== 'object') return false; + if (!('geojson' in obj)) return false; + return isValidGeoJSON(obj.geojson); } // Detect bare {x, y} point format (e.g., from GeometryPoint query returning { x, y } subfields) function isBarePoint(obj: any): obj is { x: number; y: number } { - return ( - obj && typeof obj === 'object' && - 'x' in obj && 'y' in obj && - typeof obj.x === 'number' && typeof obj.y === 'number' && - !('type' in obj) && !('geojson' in obj) - ); + return ( + obj && + typeof obj === 'object' && + 'x' in obj && + 'y' in obj && + typeof obj.x === 'number' && + typeof obj.y === 'number' && + !('type' in obj) && + !('geojson' in obj) + ); } /** Extract raw GeoJSON geometry from any accepted format, or null if unrecognized. */ function extractRawGeoJSON(obj: any): any | null { - if (isValidGeoJSON(obj)) return obj; - if (obj?.geojson && isValidGeoJSON(obj.geojson)) return obj.geojson; - if (isBarePoint(obj)) return { type: 'Point', coordinates: [obj.x, obj.y] }; - return null; + if (isValidGeoJSON(obj)) return obj; + if (obj?.geojson && isValidGeoJSON(obj.geojson)) return obj.geojson; + if (isBarePoint(obj)) return { type: 'Point', coordinates: [obj.x, obj.y] }; + return null; } /** Extract Point coordinates from a GeoJSON geometry, defaulting to (0,0) for non-Point types. */ function extractPointCoords(geojson: any): { x: number; y: number } { - if (geojson?.type === 'Point' && Array.isArray(geojson.coordinates)) { - return { x: Number(geojson.coordinates[0]), y: Number(geojson.coordinates[1]) }; - } - return { x: 0, y: 0 }; + if (geojson?.type === 'Point' && Array.isArray(geojson.coordinates)) { + return { x: Number(geojson.coordinates[0]), y: Number(geojson.coordinates[1]) }; + } + return { x: 0, y: 0 }; } function validateGeometry(jsonString: string, expectedType?: string): true | string { - if (!jsonString.trim()) return true; // Allow empty - - try { - const parsed = JSON.parse(jsonString); - - if (!isValidGeometry(parsed) && !isValidGeoJSON(parsed) && !isWrappedGeoJSON(parsed) && !isBarePoint(parsed)) { - const hint = getGeoJSONHint(parsed?.geojson ?? parsed); - if (hint) return hint; - return 'Invalid geometry. Expected GeoJSON (e.g., {"type": "Point", "coordinates": [0, 0]}).'; - } - - // Validate geometry type matches column constraint - if (expectedType) { - const geojson = extractRawGeoJSON(parsed) ?? parsed; - const actualType = geojson?.type as string | undefined; - if (actualType && actualType !== expectedType) { - return `Column expects ${expectedType} but got ${actualType}.`; - } - } - - return true; - } catch { - return 'Invalid JSON format'; - } + if (!jsonString.trim()) return true; // Allow empty + + try { + const parsed = JSON.parse(jsonString); + + if (!isValidGeometry(parsed) && !isValidGeoJSON(parsed) && !isWrappedGeoJSON(parsed) && !isBarePoint(parsed)) { + const hint = getGeoJSONHint(parsed?.geojson ?? parsed); + if (hint) return hint; + return 'Invalid geometry. Expected GeoJSON (e.g., {"type": "Point", "coordinates": [0, 0]}).'; + } + + // Validate geometry type matches column constraint + if (expectedType) { + const geojson = extractRawGeoJSON(parsed) ?? parsed; + const actualType = geojson?.type as string | undefined; + if (actualType && actualType !== expectedType) { + return `Column expects ${expectedType} but got ${actualType}.`; + } + } + + return true; + } catch { + return 'Invalid JSON format'; + } } // Helper functions for map integration function isPointGeometry(geometry: GeometryType | null | undefined): boolean { - return !!(geometry?.geojson?.type === 'Point' && Array.isArray(geometry.geojson.coordinates)); + return !!(geometry?.geojson?.type === 'Point' && Array.isArray(geometry.geojson.coordinates)); } function formatGeometry(val: any): string { - if (val == null) return ''; - try { - const obj = typeof val === 'string' ? JSON.parse(val) : val; - return JSON.stringify(obj, null, 2); - } catch { - return String(val); - } + if (val == null) return ''; + try { + const obj = typeof val === 'string' ? JSON.parse(val) : val; + return JSON.stringify(obj, null, 2); + } catch { + return String(val); + } } function parseGeometryValue(value: any): GeometryType | null { - if (!value) return null; + if (!value) return null; - try { - const parsed = typeof value === 'string' ? JSON.parse(value) : value; + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; - if (isValidGeometry(parsed)) return parsed; + if (isValidGeometry(parsed)) return parsed; - if (isValidGeoJSON(parsed)) { - return { geojson: parsed, srid: 4326, ...extractPointCoords(parsed) }; - } + if (isValidGeoJSON(parsed)) { + return { geojson: parsed, srid: 4326, ...extractPointCoords(parsed) }; + } - if (isWrappedGeoJSON(parsed)) { - const gj = parsed.geojson; - return { geojson: gj, srid: Number(parsed.srid) || 4326, ...extractPointCoords(gj) }; - } + if (isWrappedGeoJSON(parsed)) { + const gj = parsed.geojson; + return { geojson: gj, srid: Number(parsed.srid) || 4326, ...extractPointCoords(gj) }; + } - if (isBarePoint(parsed)) { - const geojson = { type: 'Point' as const, coordinates: [parsed.x, parsed.y] }; - return { geojson, srid: 4326, x: parsed.x, y: parsed.y }; - } + if (isBarePoint(parsed)) { + const geojson = { type: 'Point' as const, coordinates: [parsed.x, parsed.y] }; + return { geojson, srid: 4326, x: parsed.x, y: parsed.y }; + } - return null; - } catch { - return null; - } + return null; + } catch { + return null; + } } // Geometry placeholder examples by type const GEOMETRY_PLACEHOLDERS: Record = { - Point: JSON.stringify({ type: 'Point', coordinates: [0, 0] }, null, 2), - Polygon: JSON.stringify({ type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] }, null, 2), - LineString: JSON.stringify({ type: 'LineString', coordinates: [[0, 0], [1, 1], [2, 0]] }, null, 2), - MultiPoint: JSON.stringify({ type: 'MultiPoint', coordinates: [[0, 0], [1, 1]] }, null, 2), - MultiPolygon: JSON.stringify({ type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]] }, null, 2), - MultiLineString: JSON.stringify({ type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[2, 2], [3, 3]]] }, null, 2), + Point: JSON.stringify({ type: 'Point', coordinates: [0, 0] }, null, 2), + Polygon: JSON.stringify( + { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + [0, 0], + ], + ], + }, + null, + 2, + ), + LineString: JSON.stringify( + { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1], + [2, 0], + ], + }, + null, + 2, + ), + MultiPoint: JSON.stringify( + { + type: 'MultiPoint', + coordinates: [ + [0, 0], + [1, 1], + ], + }, + null, + 2, + ), + MultiPolygon: JSON.stringify( + { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + [0, 0], + ], + ], + ], + }, + null, + 2, + ), + MultiLineString: JSON.stringify( + { + type: 'MultiLineString', + coordinates: [ + [ + [0, 0], + [1, 1], + ], + [ + [2, 2], + [3, 3], + ], + ], + }, + null, + 2, + ), }; const DEFAULT_GEOMETRY_PLACEHOLDER = GEOMETRY_PLACEHOLDERS.Point; @@ -235,293 +334,322 @@ const RANDOMIZABLE_TYPES = ['Point', 'LineString', 'Polygon']; // Module-level random geometry generator — no component state deps function generateRandomGeometry(constrainedType?: string): GeometryType { - const sampleLocations = [ - { name: 'Times Square, NYC', lng: -73.985, lat: 40.758 }, - { name: 'Eiffel Tower, Paris', lng: 2.294, lat: 48.858 }, - { name: 'Big Ben, London', lng: -0.124, lat: 51.499 }, - { name: 'Golden Gate Bridge, SF', lng: -122.478, lat: 37.819 }, - { name: 'Sydney Opera House', lng: 151.215, lat: -33.857 }, - ]; - - const randomType = constrainedType && RANDOMIZABLE_TYPES.includes(constrainedType) - ? constrainedType - : RANDOMIZABLE_TYPES[Math.floor(Math.random() * RANDOMIZABLE_TYPES.length)]; - const baseLocation = sampleLocations[Math.floor(Math.random() * sampleLocations.length)]; - const baseLng = baseLocation.lng; - const baseLat = baseLocation.lat; - - let geojson: any; - - switch (randomType) { - case 'Point': - geojson = { - type: 'Point', - coordinates: [baseLng, baseLat], - }; - break; - - case 'LineString': { - const pathPoints = []; - for (let i = 0; i < 4; i++) { - pathPoints.push([ - baseLng + (Math.random() - 0.5) * 0.01, - baseLat + (Math.random() - 0.5) * 0.01, - ]); - } - geojson = { - type: 'LineString', - coordinates: pathPoints, - }; - break; - } - - case 'Polygon': { - const offset = 0.005; - geojson = { - type: 'Polygon', - coordinates: [ - [ - [baseLng - offset, baseLat - offset], - [baseLng + offset, baseLat - offset], - [baseLng + offset, baseLat + offset], - [baseLng - offset, baseLat + offset], - [baseLng - offset, baseLat - offset], - ], - ], - }; - break; - } - - } - - let x = baseLng, - y = baseLat; - if (geojson.type === 'Point') { - x = geojson.coordinates[0]; - y = geojson.coordinates[1]; - } - - return { geojson, srid: 4326, x, y }; + const sampleLocations = [ + { name: 'Times Square, NYC', lng: -73.985, lat: 40.758 }, + { name: 'Eiffel Tower, Paris', lng: 2.294, lat: 48.858 }, + { name: 'Big Ben, London', lng: -0.124, lat: 51.499 }, + { name: 'Golden Gate Bridge, SF', lng: -122.478, lat: 37.819 }, + { name: 'Sydney Opera House', lng: 151.215, lat: -33.857 }, + ]; + + const randomType = + constrainedType && RANDOMIZABLE_TYPES.includes(constrainedType) + ? constrainedType + : RANDOMIZABLE_TYPES[Math.floor(Math.random() * RANDOMIZABLE_TYPES.length)]; + const baseLocation = sampleLocations[Math.floor(Math.random() * sampleLocations.length)]; + const baseLng = baseLocation.lng; + const baseLat = baseLocation.lat; + + let geojson: any; + + switch (randomType) { + case 'Point': + geojson = { + type: 'Point', + coordinates: [baseLng, baseLat], + }; + break; + + case 'LineString': { + const pathPoints = []; + for (let i = 0; i < 4; i++) { + pathPoints.push([baseLng + (Math.random() - 0.5) * 0.01, baseLat + (Math.random() - 0.5) * 0.01]); + } + geojson = { + type: 'LineString', + coordinates: pathPoints, + }; + break; + } + + case 'Polygon': { + const offset = 0.005; + geojson = { + type: 'Polygon', + coordinates: [ + [ + [baseLng - offset, baseLat - offset], + [baseLng + offset, baseLat - offset], + [baseLng + offset, baseLat + offset], + [baseLng - offset, baseLat + offset], + [baseLng - offset, baseLat - offset], + ], + ], + }; + break; + } + } + + let x = baseLng, + y = baseLat; + if (geojson.type === 'Point') { + x = geojson.coordinates[0]; + y = geojson.coordinates[1]; + } + + return { geojson, srid: 4326, x, y }; } interface GeometryEditorProps { - value: unknown; - onFinishedEditing: (next?: unknown) => void; - /** Expected geometry type from column definition (e.g., "Point", "Polygon", "LineString") */ - expectedType?: string; + value: unknown; + onFinishedEditing: (next?: unknown) => void; + /** Expected geometry type from column definition (e.g., "Point", "Polygon", "LineString") */ + expectedType?: string; } export const GeometryEditor: React.FC = ({ value, onFinishedEditing, expectedType }) => { - const currentGeometryData = value; - const geometryValue = useMemo(() => parseGeometryValue(currentGeometryData), [currentGeometryData]); - - const [editingValue, setEditingValue] = useState(() => { - if (!currentGeometryData) return ''; - // Normalize to raw GeoJSON — the backend GeoJSON scalar only accepts { type, coordinates } - const geo = parseGeometryValue(currentGeometryData); - if (geo?.geojson) return JSON.stringify(geo.geojson, null, 2); - return formatGeometry(currentGeometryData); - }); - - // Only show map when we KNOW it's a Point: either expectedType says so, or existing value is a Point. - // For empty cells without expectedType, default to JSON tab (prevents Point-on-Polygon errors). - const canShowMap = expectedType === 'Point' || (!expectedType && geometryValue != null && isPointGeometry(geometryValue)); - - const validationResult = useMemo(() => validateGeometry(editingValue, expectedType), [editingValue, expectedType]); - const isValid = validationResult === true; - const validationError = typeof validationResult === 'string' ? validationResult : undefined; - const [activeTab, setActiveTab] = useState<'map' | 'json'>(() => (canShowMap ? 'map' : 'json')); - - const handleMapChange = useCallback((mapValue: MapPickerValue | undefined) => { - if (mapValue?.geojson?.type) { - setEditingValue(JSON.stringify(mapValue.geojson, null, 2)); - } else { - setEditingValue(''); - } - }, []); - - const handleRandomize = useCallback(() => { - setEditingValue(JSON.stringify(generateRandomGeometry(expectedType).geojson, null, 2)); - }, [expectedType]); - - const handleSave = useCallback(() => { - if (!isValid) return; - - let finalValue: string; - - if (!editingValue.trim()) { - finalValue = ''; - } else { - try { - const parsed = JSON.parse(editingValue); - // Backend GeoJSON scalar requires { type: "Point", ... } at the top level - finalValue = JSON.stringify(extractRawGeoJSON(parsed) ?? parsed); - } catch { - finalValue = editingValue; - } - } - - onFinishedEditing(finalValue); - }, [editingValue, isValid, onFinishedEditing]); - - const handleCancel = useCallback(() => { - onFinishedEditing(); - }, [onFinishedEditing]); - - const handleEditorKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - if (isValid) handleSave(); - } - }, - [isValid, handleSave], - ); - - const mapPickerValue = useMemo(() => { - try { - if (editingValue) { - const raw = extractRawGeoJSON(JSON.parse(editingValue)); - if (raw) return raw; - } - } catch { - // fall back to initial value on parse error - } - if (geometryValue && isPointGeometry(geometryValue)) { - return geometryValue.geojson; - } - return undefined; - }, [editingValue, geometryValue]); - - // Viewport-aware scroll budget (same pattern as relation-editor) - const { maxHeight: overlayMaxHeight } = useContext(OverlayMeasureContext); - const editorRef = useRef(null); - const contentRef = useRef(null); - const [scrollBudget, setScrollBudget] = useState(); - - useLayoutEffect(() => { - const editor = editorRef.current; - const content = contentRef.current; - if (!editor || !content || overlayMaxHeight <= 0) return; - const fixedUI = editor.scrollHeight - content.scrollHeight; - setScrollBudget(Math.max(0, overlayMaxHeight - fixedUI)); - }, [overlayMaxHeight]); - - // Shared header - const header = ( -
-
- - Geometry - {expectedType && ( - {expectedType} - )} -
- {canShowMap && ( - - - - Map - - - - JSON - - - )} -
- ); - - // Shared JSON content - const jsonContent = ( -
-