diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 5178c6282..6926581af 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -23,6 +23,13 @@ import { setBasemapControlLabels, setGraticuleLabels, setH3Labels, + setS2Labels, + setA5Labels, + setDggridLabels, + setDggalLabels, + setOlcLabels, + setGeohashLabels, + setTilecodeLabels, setMapillaryLabels, setEarthdataGisLabels, setOpenAerialMapLabels, @@ -596,6 +603,7 @@ export function TopToolbar({ title: t("h3Plugin.title"), getTitle: () => i18n.t("h3Plugin.title"), controlTitle: t("h3Plugin.controlTitle"), + autoResolution: t("h3Plugin.autoResolution"), resolution: t("h3Plugin.resolution"), cellCount: (count) => t("h3Plugin.cellCount", { count }), tooManyCells: (limit) => t("h3Plugin.tooManyCells", { limit }), @@ -622,6 +630,212 @@ export function TopToolbar({ exportGeoJson: t("h3Plugin.exportGeoJson"), exportCsv: t("h3Plugin.exportCsv"), includeNeighbors: t("h3Plugin.includeNeighbors"), + includeParents: t("h3Plugin.includeParents"), + showIcosahedron: t("h3Plugin.showIcosahedron"), + }); + setS2Labels({ + title: t("s2Plugin.title"), + getTitle: () => i18n.t("s2Plugin.title"), + controlTitle: t("s2Plugin.controlTitle"), + autoResolution: t("s2Plugin.autoResolution"), + resolution: t("s2Plugin.resolution"), + cellCount: (count) => t("s2Plugin.cellCount", { count }), + tooManyCells: (limit) => t("s2Plugin.tooManyCells", { limit }), + fillColor: t("s2Plugin.fillColor"), + fillOpacity: t("s2Plugin.fillOpacity"), + lineColor: t("s2Plugin.lineColor"), + lineWidth: t("s2Plugin.lineWidth"), + showLabels: t("s2Plugin.showLabels"), + identifyHint: t("s2Plugin.identifyHint"), + selectedCell: t("s2Plugin.selectedCell"), + noSelection: t("s2Plugin.noSelection"), + copyId: t("s2Plugin.copyId"), + parent: t("s2Plugin.parent"), + children: t("s2Plugin.children"), + neighbors: t("s2Plugin.neighbors"), + center: t("s2Plugin.center"), + zoomToCell: t("s2Plugin.zoomToCell"), + addAsLayer: t("s2Plugin.addAsLayer"), + exportGeoJson: t("s2Plugin.exportGeoJson"), + exportCsv: t("s2Plugin.exportCsv"), + includeNeighbors: t("s2Plugin.includeNeighbors"), + includeParents: t("s2Plugin.includeParents"), + }); + setA5Labels({ + title: t("a5Plugin.title"), + getTitle: () => i18n.t("a5Plugin.title"), + controlTitle: t("a5Plugin.controlTitle"), + autoResolution: t("a5Plugin.autoResolution"), + resolution: t("a5Plugin.resolution"), + cellCount: (count) => t("a5Plugin.cellCount", { count }), + tooManyCells: (limit) => t("a5Plugin.tooManyCells", { limit }), + fillColor: t("a5Plugin.fillColor"), + fillOpacity: t("a5Plugin.fillOpacity"), + lineColor: t("a5Plugin.lineColor"), + lineWidth: t("a5Plugin.lineWidth"), + showLabels: t("a5Plugin.showLabels"), + identifyHint: t("a5Plugin.identifyHint"), + selectedCell: t("a5Plugin.selectedCell"), + noSelection: t("a5Plugin.noSelection"), + copyId: t("a5Plugin.copyId"), + parent: t("a5Plugin.parent"), + children: t("a5Plugin.children"), + neighbors: t("a5Plugin.neighbors"), + center: t("a5Plugin.center"), + zoomToCell: t("a5Plugin.zoomToCell"), + addAsLayer: t("a5Plugin.addAsLayer"), + exportGeoJson: t("a5Plugin.exportGeoJson"), + exportCsv: t("a5Plugin.exportCsv"), + includeNeighbors: t("a5Plugin.includeNeighbors"), + includeParents: t("a5Plugin.includeParents"), + }); + setDggridLabels({ + title: t("dggridPlugin.title"), + getTitle: () => i18n.t("dggridPlugin.title"), + controlTitle: t("dggridPlugin.controlTitle"), + cellType: t("dggridPlugin.cellType"), + topologyHexagon: t("dggridPlugin.topologyHexagon"), + topologyDiamond: t("dggridPlugin.topologyDiamond"), + topologyTriangle: t("dggridPlugin.topologyTriangle"), + projection: t("dggridPlugin.projection"), + aperture: t("dggridPlugin.aperture"), + autoResolution: t("dggridPlugin.autoResolution"), + resolution: t("dggridPlugin.resolution"), + cellCount: (count) => t("dggridPlugin.cellCount", { count }), + tooManyCells: (limit) => t("dggridPlugin.tooManyCells", { limit }), + fillColor: t("dggridPlugin.fillColor"), + fillOpacity: t("dggridPlugin.fillOpacity"), + lineColor: t("dggridPlugin.lineColor"), + lineWidth: t("dggridPlugin.lineWidth"), + showLabels: t("dggridPlugin.showLabels"), + identifyHint: t("dggridPlugin.identifyHint"), + selectedCell: t("dggridPlugin.selectedCell"), + noSelection: t("dggridPlugin.noSelection"), + copyId: t("dggridPlugin.copyId"), + parent: t("dggridPlugin.parent"), + children: t("dggridPlugin.children"), + neighbors: t("dggridPlugin.neighbors"), + center: t("dggridPlugin.center"), + zoomToCell: t("dggridPlugin.zoomToCell"), + addAsLayer: t("dggridPlugin.addAsLayer"), + exportGeoJson: t("dggridPlugin.exportGeoJson"), + exportCsv: t("dggridPlugin.exportCsv"), + includeNeighbors: t("dggridPlugin.includeNeighbors"), + includeParents: t("dggridPlugin.includeParents"), + }); + setDggalLabels({ + title: t("dggalPlugin.title"), + getTitle: () => i18n.t("dggalPlugin.title"), + controlTitle: t("dggalPlugin.controlTitle"), + gridType: t("dggalPlugin.gridType"), + autoResolution: t("dggalPlugin.autoResolution"), + resolution: t("dggalPlugin.resolution"), + cellCount: (count) => t("dggalPlugin.cellCount", { count }), + tooManyCells: (limit) => t("dggalPlugin.tooManyCells", { limit }), + fillColor: t("dggalPlugin.fillColor"), + fillOpacity: t("dggalPlugin.fillOpacity"), + lineColor: t("dggalPlugin.lineColor"), + lineWidth: t("dggalPlugin.lineWidth"), + showLabels: t("dggalPlugin.showLabels"), + identifyHint: t("dggalPlugin.identifyHint"), + selectedCell: t("dggalPlugin.selectedCell"), + noSelection: t("dggalPlugin.noSelection"), + copyId: t("dggalPlugin.copyId"), + parent: t("dggalPlugin.parent"), + children: t("dggalPlugin.children"), + neighbors: t("dggalPlugin.neighbors"), + center: t("dggalPlugin.center"), + zoomToCell: t("dggalPlugin.zoomToCell"), + addAsLayer: t("dggalPlugin.addAsLayer"), + exportGeoJson: t("dggalPlugin.exportGeoJson"), + exportCsv: t("dggalPlugin.exportCsv"), + includeNeighbors: t("dggalPlugin.includeNeighbors"), + includeParents: t("dggalPlugin.includeParents"), + }); + setOlcLabels({ + title: t("olcPlugin.title"), + getTitle: () => i18n.t("olcPlugin.title"), + controlTitle: t("olcPlugin.controlTitle"), + autoResolution: t("olcPlugin.autoResolution"), + resolution: t("olcPlugin.resolution"), + cellCount: (count) => t("olcPlugin.cellCount", { count }), + tooManyCells: (limit) => t("olcPlugin.tooManyCells", { limit }), + fillColor: t("olcPlugin.fillColor"), + fillOpacity: t("olcPlugin.fillOpacity"), + lineColor: t("olcPlugin.lineColor"), + lineWidth: t("olcPlugin.lineWidth"), + showLabels: t("olcPlugin.showLabels"), + identifyHint: t("olcPlugin.identifyHint"), + selectedCell: t("olcPlugin.selectedCell"), + noSelection: t("olcPlugin.noSelection"), + copyId: t("olcPlugin.copyId"), + parent: t("olcPlugin.parent"), + children: t("olcPlugin.children"), + neighbors: t("olcPlugin.neighbors"), + center: t("olcPlugin.center"), + zoomToCell: t("olcPlugin.zoomToCell"), + addAsLayer: t("olcPlugin.addAsLayer"), + exportGeoJson: t("olcPlugin.exportGeoJson"), + exportCsv: t("olcPlugin.exportCsv"), + includeNeighbors: t("olcPlugin.includeNeighbors"), + includeParent: t("olcPlugin.includeParent"), + }); + setGeohashLabels({ + title: t("geohashPlugin.title"), + getTitle: () => i18n.t("geohashPlugin.title"), + controlTitle: t("geohashPlugin.controlTitle"), + autoResolution: t("geohashPlugin.autoResolution"), + resolution: t("geohashPlugin.resolution"), + cellCount: (count) => t("geohashPlugin.cellCount", { count }), + tooManyCells: (limit) => t("geohashPlugin.tooManyCells", { limit }), + fillColor: t("geohashPlugin.fillColor"), + fillOpacity: t("geohashPlugin.fillOpacity"), + lineColor: t("geohashPlugin.lineColor"), + lineWidth: t("geohashPlugin.lineWidth"), + showLabels: t("geohashPlugin.showLabels"), + identifyHint: t("geohashPlugin.identifyHint"), + selectedCell: t("geohashPlugin.selectedCell"), + noSelection: t("geohashPlugin.noSelection"), + copyId: t("geohashPlugin.copyId"), + parent: t("geohashPlugin.parent"), + children: t("geohashPlugin.children"), + neighbors: t("geohashPlugin.neighbors"), + center: t("geohashPlugin.center"), + zoomToCell: t("geohashPlugin.zoomToCell"), + addAsLayer: t("geohashPlugin.addAsLayer"), + exportGeoJson: t("geohashPlugin.exportGeoJson"), + exportCsv: t("geohashPlugin.exportCsv"), + includeNeighbors: t("geohashPlugin.includeNeighbors"), + includeParent: t("geohashPlugin.includeParent"), + }); + setTilecodeLabels({ + title: t("tilecodePlugin.title"), + getTitle: () => i18n.t("tilecodePlugin.title"), + controlTitle: t("tilecodePlugin.controlTitle"), + autoResolution: t("tilecodePlugin.autoResolution"), + resolution: t("tilecodePlugin.resolution"), + cellCount: (count) => t("tilecodePlugin.cellCount", { count }), + tooManyCells: (limit) => t("tilecodePlugin.tooManyCells", { limit }), + fillColor: t("tilecodePlugin.fillColor"), + fillOpacity: t("tilecodePlugin.fillOpacity"), + lineColor: t("tilecodePlugin.lineColor"), + lineWidth: t("tilecodePlugin.lineWidth"), + showLabels: t("tilecodePlugin.showLabels"), + identifyHint: t("tilecodePlugin.identifyHint"), + selectedCell: t("tilecodePlugin.selectedCell"), + noSelection: t("tilecodePlugin.noSelection"), + copyId: t("tilecodePlugin.copyId"), + quadkey: t("tilecodePlugin.quadkey"), + parent: t("tilecodePlugin.parent"), + children: t("tilecodePlugin.children"), + neighbors: t("tilecodePlugin.neighbors"), + center: t("tilecodePlugin.center"), + zoomToCell: t("tilecodePlugin.zoomToCell"), + addAsLayer: t("tilecodePlugin.addAsLayer"), + exportGeoJson: t("tilecodePlugin.exportGeoJson"), + exportCsv: t("tilecodePlugin.exportCsv"), + includeNeighbors: t("tilecodePlugin.includeNeighbors"), + includeParent: t("tilecodePlugin.includeParent"), }); setTimelapseLabels({ title: t("timelapse.title"), @@ -831,6 +1045,8 @@ export function TopToolbar({ // openSettingsSection. This toolbar owns the dialog + its kind state. useEffect(() => { const onOpenAddData = (event: Event) => { + // Read-only embeds must not open Add Data via the Browser panel event. + if (viewer) return; const detail = (event as CustomEvent).detail; // Reject kinds the Mac App Store build hides so a stray event cannot // open a dialog whose backing service is compiled out. @@ -845,7 +1061,7 @@ export function TopToolbar({ }; window.addEventListener(OPEN_ADD_DATA_EVENT, onOpenAddData); return () => window.removeEventListener(OPEN_ADD_DATA_EVENT, onOpenAddData); - }, []); + }, [viewer]); // Deck.gl Layer kind the Add Data dialog opens on (e.g. the 3D-model entry // jumps straight to the scenegraph layer type). const [addDataDeckVizKind, setAddDataDeckVizKind] = useState(undefined); @@ -1365,7 +1581,7 @@ export function TopToolbar({ }, { id: "view.comments", - title: "View Comments", + title: t("toolbar.command.viewComments"), group: t("toolbar.commandGroup.view"), keywords: "comments review threads notes annotations pins", icon: MessageSquare, @@ -1879,7 +2095,11 @@ export function TopToolbar({ aria-label={t("toolbar.item.projectName")} className="hidden h-7 w-44 border-transparent px-2 text-xs shadow-none focus-visible:border-input md:block" value={projectName} - onChange={(event) => setProjectName(event.target.value)} + readOnly={viewer} + onChange={(event) => { + if (viewer) return; + setProjectName(event.target.value); + }} onKeyDown={(event) => { if ( event.key === "Enter" && @@ -1896,6 +2116,7 @@ export function TopToolbar({ projectNameComposingRef.current = false; }} onBlur={(event) => { + if (viewer) return; const nextName = event.target.value.trim(); // Persist the canonical, locale-independent default name; a // translated string would otherwise be written into the saved diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/PluginsMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/PluginsMenu.tsx index db7d53309..16817608e 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/PluginsMenu.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/PluginsMenu.tsx @@ -1,6 +1,7 @@ import { COMPONENTS_PLUGIN_ID, DECK_VIZ_PLUGIN_ID, + DGGS_PLUGIN_IDS, DIRECTIONS_PLUGIN_ID, type GeoLibreMapControlPosition, GRATICULE_PLUGIN_ID, @@ -37,6 +38,9 @@ type RegisteredPlugin = PluginRegistry["plugins"][number]; // Plugins grouped under the "Web Services" submenu of the Plugins menu. const WEB_SERVICE_PLUGIN_ID_SET = new Set(WEB_SERVICE_PLUGIN_IDS); +// The discrete global grids (H3, S2, A5) grouped under the "DGGS" submenu. +const DGGS_PLUGIN_ID_SET = new Set(DGGS_PLUGIN_IDS); + interface PluginsMenuProps { chrome: ToolbarChrome; appApi: AppApi; @@ -117,6 +121,13 @@ export function PluginsMenu({ // first of them appears in registration order (just above Historical Imagery). let webServicesRendered = false; + const dggsPlugins = plugins.filter( + (p) => DGGS_PLUGIN_ID_SET.has(p.id) && !hiddenPluginIds.has(p.id), + ); + // The DGGS grid plugins (H3, S2, A5) render as one grouped submenu, placed + // where the first of them appears in registration order. + let dggsRendered = false; + return ( @@ -161,6 +172,24 @@ export function PluginsMenu({ if (hiddenPluginIds.has(p.id)) { return null; } + if (DGGS_PLUGIN_ID_SET.has(p.id)) { + // Same one-shot pattern as the Web Services submenu below: the + // submenu renders at the first visible DGGS plugin's position and + // later ones are skipped. + if (dggsRendered) return null; + dggsRendered = true; + return ( + + + {t("toolbar.item.dggs")} + {dggsPlugins.some((plugin) => isActive(plugin.id)) ? " ✓" : ""} + + + {dggsPlugins.map(renderPluginMenuItem)} + + + ); + } if (!WEB_SERVICE_PLUGIN_ID_SET.has(p.id)) { return renderPluginMenuItem(p); } diff --git a/apps/geolibre-desktop/src/hooks/usePlugins.ts b/apps/geolibre-desktop/src/hooks/usePlugins.ts index d61ff75bf..f5a36463f 100644 --- a/apps/geolibre-desktop/src/hooks/usePlugins.ts +++ b/apps/geolibre-desktop/src/hooks/usePlugins.ts @@ -48,6 +48,13 @@ import { queryOvertureFeatures, maplibreGraticulePlugin, maplibreH3Plugin, + maplibreS2Plugin, + maplibreA5Plugin, + maplibreDggridPlugin, + maplibreDggalPlugin, + maplibreOlcPlugin, + maplibreGeohashPlugin, + maplibreTilecodePlugin, maplibreCloudsPlugin, maplibrePrecipitationPlugin, maplibreMapillaryPlugin, @@ -195,7 +202,16 @@ manager.registerAll([ maplibreElevationProfilePlugin, maplibreSwipePlugin, maplibreGraticulePlugin, + // The DGGS grid plugins (grouped into the Plugins menu's "DGGS" submenu, + // rendered where the first of them appears in this order). maplibreH3Plugin, + maplibreS2Plugin, + maplibreA5Plugin, + maplibreDggridPlugin, + maplibreDggalPlugin, + maplibreOlcPlugin, + maplibreGeohashPlugin, + maplibreTilecodePlugin, maplibreCloudsPlugin, maplibrePrecipitationPlugin, maplibreEffectsPlugin, @@ -1327,6 +1343,11 @@ function githubRawVectorProxyUrl(value: string): string | null { if ( url.protocol !== "https:" || url.hostname !== "github.com" || + url.username !== "" || + url.password !== "" || + (url.port !== "" && url.port !== "443") || + url.search !== "" || + url.hash !== "" || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/raw\/.+$/.test(url.pathname) ) { return null; diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 3aadd012d..00d8c4cb7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -2095,6 +2095,7 @@ "resetPitch": "Reset Pitch", "resetPitchBearing": "Reset Pitch & Bearing", "setView": "Set View", + "viewComments": "View Comments", "zoomIn": "Zoom In", "zoomOut": "Zoom Out", "switchToLight": "Switch to Light Mode", @@ -2468,6 +2469,7 @@ "activate": "Activate", "position": "Position", "webServices": "Web Services", + "dggs": "DGGS", "commandPalette": "Command Palette", "openProjectFromUrl": "Open project from URL", "importQgisProjectEllipsis": "Import QGIS Project…", @@ -2590,7 +2592,14 @@ "maplibre-gl-overture-maps": "Overture Maps", "maplibre-gl-precipitation": "Precipitation", "maplibre-gl-usgs-lidar": "USGS LiDAR", - "maplibre-h3-grid": "H3 Grid", + "maplibre-h3-grid": "H3", + "maplibre-s2-grid": "S2", + "maplibre-a5-grid": "A5", + "maplibre-dggrid": "DGGRID", + "maplibre-dggal": "DGGAL", + "maplibre-olc": "OLC", + "maplibre-geohash": "Geohash", + "maplibre-tilecode": "Tilecode", "maplibre-reverse-geocode": "Reverse Geocode", "osm-basemap": "OpenStreetMap Basemap" }, @@ -2837,6 +2846,7 @@ "h3Plugin": { "title": "H3 Grid", "controlTitle": "H3 grid settings", + "autoResolution": "Automatic resolution", "resolution": "Resolution", "cellCount": "{{count}} cells in view", "tooManyCells": "This view exceeds the {{limit}} cell limit. Zoom in or lower the resolution.", @@ -2850,7 +2860,7 @@ "noSelection": "No cell selected", "copyId": "Copy ID", "copied": "Copied", - "parent": "Parent", + "parent": "Parent(s)", "children": "Children", "neighbors": "Neighbors", "baseCell": "Base cell", @@ -2862,7 +2872,206 @@ "addAsLayer": "Add grid as layer", "exportGeoJson": "Export GeoJSON", "exportCsv": "Export CSV", - "includeNeighbors": "Include selected cell neighbors" + "includeNeighbors": "Include selected cell neighbors", + "includeParents": "Include selected cell parent(s)", + "showIcosahedron": "Show icosahedron" + }, + "s2Plugin": { + "title": "S2 Grid", + "controlTitle": "S2 grid settings", + "autoResolution": "Automatic resolution", + "resolution": "Resolution", + "cellCount": "{{count}} cells in view", + "tooManyCells": "This view exceeds the {{limit}} cell limit. Zoom in or lower the resolution.", + "fillColor": "Fill color", + "fillOpacity": "Fill opacity", + "lineColor": "Outline color", + "lineWidth": "Outline width", + "showLabels": "Show cell IDs", + "identifyHint": "Click the map to identify an S2 cell.", + "selectedCell": "Selected cell", + "noSelection": "No cell selected", + "copyId": "Copy ID", + "parent": "Parent(s)", + "children": "Children", + "neighbors": "Neighbors", + "center": "Center", + "zoomToCell": "Zoom to cell", + "addAsLayer": "Add grid as layer", + "exportGeoJson": "Export GeoJSON", + "exportCsv": "Export CSV", + "includeNeighbors": "Include selected cell neighbors", + "includeParents": "Include selected cell parent(s)" + }, + "a5Plugin": { + "title": "A5 Grid", + "controlTitle": "A5 grid settings", + "autoResolution": "Automatic resolution", + "resolution": "Resolution", + "cellCount": "{{count}} cells in view", + "tooManyCells": "This view exceeds the {{limit}} cell limit. Zoom in or lower the resolution.", + "fillColor": "Fill color", + "fillOpacity": "Fill opacity", + "lineColor": "Outline color", + "lineWidth": "Outline width", + "showLabels": "Show cell IDs", + "identifyHint": "Click the map to identify an A5 cell.", + "selectedCell": "Selected cell", + "noSelection": "No cell selected", + "copyId": "Copy ID", + "parent": "Parent(s)", + "children": "Children", + "neighbors": "Neighbors", + "center": "Center", + "zoomToCell": "Zoom to cell", + "addAsLayer": "Add grid as layer", + "exportGeoJson": "Export GeoJSON", + "exportCsv": "Export CSV", + "includeNeighbors": "Include selected cell neighbors", + "includeParents": "Include selected cell parent(s)" + }, + "dggridPlugin": { + "title": "DGGRID", + "controlTitle": "DGGRID settings", + "cellType": "Cell type", + "topologyHexagon": "Hexagon", + "topologyDiamond": "Diamond", + "topologyTriangle": "Triangle", + "projection": "Projection", + "aperture": "Aperture", + "autoResolution": "Automatic resolution", + "resolution": "Resolution", + "cellCount": "{{count}} cells in view", + "tooManyCells": "This view exceeds the {{limit}} cell limit. Zoom in or lower the resolution.", + "fillColor": "Fill color", + "fillOpacity": "Fill opacity", + "lineColor": "Outline color", + "lineWidth": "Outline width", + "showLabels": "Show cell IDs", + "identifyHint": "Click the map to identify a DGGRID cell.", + "selectedCell": "Selected cell", + "noSelection": "No cell selected", + "copyId": "Copy ID", + "parent": "Parent(s)", + "children": "Children", + "neighbors": "Neighbors", + "center": "Center", + "zoomToCell": "Zoom to cell", + "addAsLayer": "Add grid as layer", + "exportGeoJson": "Export GeoJSON", + "exportCsv": "Export CSV", + "includeNeighbors": "Include selected cell neighbors", + "includeParents": "Include selected cell parent(s)" + }, + "dggalPlugin": { + "title": "DGGAL", + "controlTitle": "DGGAL settings", + "gridType": "DGGS type", + "autoResolution": "Automatic resolution", + "resolution": "Resolution", + "cellCount": "{{count}} cells in view", + "tooManyCells": "This view exceeds the {{limit}} cell limit. Zoom in or lower the resolution.", + "fillColor": "Fill color", + "fillOpacity": "Fill opacity", + "lineColor": "Outline color", + "lineWidth": "Outline width", + "showLabels": "Show cell IDs", + "identifyHint": "Click the map to identify a DGGAL cell.", + "selectedCell": "Selected cell", + "noSelection": "No cell selected", + "copyId": "Copy ID", + "parent": "Parent(s)", + "children": "Children", + "neighbors": "Neighbors", + "center": "Center", + "zoomToCell": "Zoom to cell", + "addAsLayer": "Add grid as layer", + "exportGeoJson": "Export GeoJSON", + "exportCsv": "Export CSV", + "includeNeighbors": "Include selected cell neighbors", + "includeParents": "Include selected cell parent(s)" + }, + "olcPlugin": { + "title": "OLC", + "controlTitle": "OLC settings", + "autoResolution": "Automatic resolution", + "resolution": "Code length", + "cellCount": "{{count}} cells in view", + "tooManyCells": "This view exceeds the {{limit}} cell limit. Zoom in or lower the code length.", + "fillColor": "Fill color", + "fillOpacity": "Fill opacity", + "lineColor": "Outline color", + "lineWidth": "Outline width", + "showLabels": "Show cell IDs", + "identifyHint": "Click the map to identify an OLC cell.", + "selectedCell": "Selected cell", + "noSelection": "No cell selected", + "copyId": "Copy ID", + "parent": "Parent", + "children": "Children", + "neighbors": "Neighbors", + "center": "Center", + "zoomToCell": "Zoom to cell", + "addAsLayer": "Add grid as layer", + "exportGeoJson": "Export GeoJSON", + "exportCsv": "Export CSV", + "includeNeighbors": "Include selected cell neighbors", + "includeParent": "Include selected cell parent" + }, + "geohashPlugin": { + "title": "Geohash", + "controlTitle": "Geohash settings", + "autoResolution": "Automatic resolution", + "resolution": "Precision", + "cellCount": "{{count}} cells in view", + "tooManyCells": "This view exceeds the {{limit}} cell limit. Zoom in or lower the precision.", + "fillColor": "Fill color", + "fillOpacity": "Fill opacity", + "lineColor": "Outline color", + "lineWidth": "Outline width", + "showLabels": "Show cell IDs", + "identifyHint": "Click the map to identify a Geohash cell.", + "selectedCell": "Selected cell", + "noSelection": "No cell selected", + "copyId": "Copy ID", + "parent": "Parent", + "children": "Children", + "neighbors": "Neighbors", + "center": "Center", + "zoomToCell": "Zoom to cell", + "addAsLayer": "Add grid as layer", + "exportGeoJson": "Export GeoJSON", + "exportCsv": "Export CSV", + "includeNeighbors": "Include selected cell neighbors", + "includeParent": "Include selected cell parent" + }, + "tilecodePlugin": { + "title": "Tilecode", + "controlTitle": "Tilecode settings", + "autoResolution": "Automatic resolution", + "resolution": "Zoom level", + "cellCount": "{{count}} tiles in view", + "tooManyCells": "This view exceeds the {{limit}} tile limit. Zoom in or lower the zoom level.", + "fillColor": "Fill color", + "fillOpacity": "Fill opacity", + "lineColor": "Outline color", + "lineWidth": "Outline width", + "showLabels": "Show tile IDs", + "identifyHint": "Click the map to identify a tile.", + "selectedCell": "Selected tile", + "noSelection": "No tile selected", + "copyId": "Copy ID", + "quadkey": "Quadkey", + "parent": "Parent", + "children": "Children", + "neighbors": "Neighbors", + "center": "Center", + "zoomToCell": "Zoom to tile", + "addAsLayer": "Add grid as layer", + "exportGeoJson": "Export GeoJSON", + "exportCsv": "Export CSV", + "includeNeighbors": "Include selected tile neighbors", + "includeParent": "Include selected tile parent" }, "timelapse": { "title": "Timelapse", diff --git a/package-lock.json b/package-lock.json index 28c991229..98425076a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -415,6 +415,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.63.tgz", "integrity": "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", @@ -661,6 +662,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2796,6 +2798,7 @@ "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.3.7.tgz", "integrity": "sha512-eRzddMlHuBBFeSfhBig5V/32psav5JLvRYjuMqHgcwWXKfanAcxsKBfDSA4tzwWpnDU4id9sfdGW1RDzbVgY5Q==", "license": "MIT", + "peer": true, "dependencies": { "@luma.gl/shadertools": "^9.3.3", "@math.gl/core": "^4.1.0", @@ -3035,6 +3038,7 @@ "resolved": "https://registry.npmjs.org/@developmentseed/morecantile/-/morecantile-0.7.0.tgz", "integrity": "sha512-m9tWDase9COlP/EZ2KK/ydraRU/5yfwvmF/y/2g/iFMFW1vYHQTW/8JYjmo84SiJXUifDDY2xoA3ErYlcWJctg==", "license": "MIT", + "peer": true, "dependencies": { "@developmentseed/affine": "^0.7.0" } @@ -3163,7 +3167,8 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.4.tgz", "integrity": "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-postgis": { "version": "0.2.4", @@ -3779,6 +3784,7 @@ "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-portal/-/arcgis-rest-portal-4.10.3.tgz", "integrity": "sha512-o5iwxSDS2M8lwLkNtaJTKqyN3NHv2Ne5bL+7tfdcmmHukcGMuuSroRG/+IT3CXOS7gk9sYVdR0cMEPHs1lTWNQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -3794,6 +3800,7 @@ "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-request/-/arcgis-rest-request-4.10.3.tgz", "integrity": "sha512-gBKSRC7L3cD1KX4fIleRiEKtzlvKjuDW5cYpDcDyQCn/Bw7bJzlGrlaMw87FQ+ReTZp9vlTYFXyrGMuUtR0dKw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@esri/arcgis-rest-fetch": "^4.10.3", "@esri/arcgis-rest-form-data": "^4.10.3", @@ -3926,6 +3933,7 @@ "resolved": "https://registry.npmjs.org/@geoman-io/maplibre-geoman-free/-/maplibre-geoman-free-0.8.4.tgz", "integrity": "sha512-nOLEpMtORSR0XceXfOHc0RGqrkCQo3719o1d1Bp64xzijxn37KvkxB7S3dwOFV+i3Z3MleaSGAcIjvszil2D3A==", "license": "MIT", + "peer": true, "dependencies": { "@turf/area": "^7.3.5", "@turf/bbox": "^7.3.5", @@ -5538,6 +5546,7 @@ "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", "license": "MIT", + "peer": true, "dependencies": { "@math.gl/core": "4.1.0" } @@ -5568,6 +5577,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", @@ -5696,6 +5706,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -7636,6 +7647,7 @@ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz", "integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", @@ -10594,6 +10606,13 @@ "integrity": "sha512-tdJz7jaWFu4nR+8b2B+CdPZ6811ighYylWsu2hpsivapzW058yP0KdfZuNY89IiRe5jbKvBGXN3LQdN2KPXVdQ==", "license": "MIT" }, + "node_modules/@types/ngeohash": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@types/ngeohash/-/ngeohash-0.6.8.tgz", + "integrity": "sha512-A90x3HMwE1yXbWCnd0ztHzv8rAQPjwTzX2diYI/6OrWm/3oairDaehw5WPWJFgZ+8+J/OuF99IbipmMa2le6tQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", @@ -10755,6 +10774,7 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -11436,6 +11456,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11568,6 +11589,7 @@ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.2.0.tgz", "integrity": "sha512-Hxe6Agq26gQOM954qpzYSllJBPJl+e16U5CkfuMUhLrNba+5nKkttIVlflaovN6oaTratqMGAO8H5u/aNhmHWQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/node": "^25.2.0", "flatbuffers": "^25.1.24", @@ -11856,6 +11878,12 @@ "node": ">=4" } }, + "node_modules/bigfloat": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/bigfloat/-/bigfloat-0.1.1.tgz", + "integrity": "sha512-kKEqZxc86qgQ6xe8+sN1qQ4XzgksND5QKn6CPrFyQcq/oih+Xk7CaqOD0PKjqar8oO7v4ont5bOHox+LYxD6sQ==", + "license": "MIT" + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -11996,6 +12024,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -12273,6 +12302,7 @@ "resolved": "https://registry.npmjs.org/cog-tiler-wasm/-/cog-tiler-wasm-0.3.1.tgz", "integrity": "sha512-5mH36bBRrArqeCeCW3Gl2IhlWcU5g64eQRIfIsyo+XQwaBYfmjzuHJ36nbS7fCLUNk9khNUdgVWTU9C1zS33iw==", "license": "MIT", + "peer": true, "peerDependencies": { "geotiff": "^2.1.0 || ^3.0.0", "geotiff-geokeys-to-proj4": "^2024.4.13", @@ -12851,6 +12881,11 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dggal": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/dggal/-/dggal-0.0.6.tgz", + "integrity": "sha512-NukfkIF4xz/rZpAbwS3/DidoFZAo/D1Q624/HQVTlygAi9lNj+Oo2Huk3Sjru29H0ISiCdVxCC58rS4cNBZgnA==" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -13255,6 +13290,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -13325,6 +13361,7 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -13595,6 +13632,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -14323,6 +14361,7 @@ "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", "license": "MIT", + "peer": true, "dependencies": { "@petamoriken/float16": "^3.9.3", "lerc": "^3.0.0", @@ -14341,7 +14380,8 @@ "version": "2024.4.13", "resolved": "https://registry.npmjs.org/geotiff-geokeys-to-proj4/-/geotiff-geokeys-to-proj4-2024.4.13.tgz", "integrity": "sha512-Jgtm/lcPkgB44wCqQHaVQx5/fyhmiVDRUKQcI/vMolsED8/GRWBnn5qkQo/CgutQg9xkGzig21DY9Px9mFRdvg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/geotiff/node_modules/lerc": { "version": "3.0.0", @@ -15124,6 +15164,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -15344,6 +15385,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, @@ -17846,6 +17888,15 @@ "integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==", "license": "MIT" }, + "node_modules/ngeohash": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.4.tgz", + "integrity": "sha512-+dEKu+Be7xkK6gVQ2wZSGLbr+iWO+0ZGaLi98iE7F0/1WCSqQDRmS8LAlZBNLD2z/3zFKE9v9BvUfCrhyDhXbg==", + "license": "MIT", + "engines": { + "node": ">=v0.2.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -18096,11 +18147,17 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/open-location-code-typescript": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/open-location-code-typescript/-/open-location-code-typescript-1.5.0.tgz", + "integrity": "sha512-nGlOwLrmexCqCxEx7Vy6hsHGA9NmrUf/1bZNj0XzP5WrXYj+Oz1MJwUWFv3N012P/Y15bwC+ViVuCV6inoI1KQ==" + }, "node_modules/openai": { "version": "6.48.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.48.0.tgz", "integrity": "sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", @@ -18489,6 +18546,7 @@ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -18667,6 +18725,7 @@ "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.21.0.tgz", "integrity": "sha512-33HfDftqw8kY+Cl1dcL16SJuqTSzYxmz4re7Nmhk+fs1/N1fFrAkkF579msQTR/4fLeJVSNne8/gpoCz0fk1uw==", "license": "MIT", + "peer": true, "dependencies": { "mgrs": "1.0.0", "wkt-parser": "^1.5.5" @@ -18878,6 +18937,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -18887,6 +18947,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19287,6 +19348,7 @@ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -19383,6 +19445,15 @@ "long": "^3.2.0" } }, + "node_modules/s2js": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/s2js/-/s2js-1.44.0.tgz", + "integrity": "sha512-EbdfCD1hu1zPOWpS5PMq5tA5kJnreRoggkahXyBfJHJyOvOQtath60tuBLkyooOa+V0dtkPz8WQkQ1THriIXyQ==", + "license": "Apache-2.0", + "dependencies": { + "bigfloat": "^0.1.1" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -20281,6 +20352,7 @@ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -20470,6 +20542,7 @@ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -20740,6 +20813,7 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -21012,6 +21086,7 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -21145,6 +21220,15 @@ "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, + "node_modules/webdggrid": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/webdggrid/-/webdggrid-1.9.0.tgz", + "integrity": "sha512-ZPrPpsI02wyk5csR0NFdO6BBkreFVkJeaFUtSBq113MUDj+lVuIBwo3tWuxm2jfOZVcuRsJJwciB5Wm+nC+qsw==", + "license": "ISC", + "dependencies": { + "fzstd": "^0.1.1" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -21406,6 +21490,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21620,6 +21705,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -21703,6 +21789,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -21792,6 +21879,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -21869,6 +21957,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -21931,6 +22020,7 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, @@ -22172,7 +22262,9 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-opener": "^2.5.4", "@turf/union": "^7.3.5", + "a5-js": "^0.9.0", "cog-tiler-wasm": "^0.3.1", + "dggal": "^0.0.6", "geotiff": "^3.0.5", "geotiff-geokeys-to-proj4": "^2024.4.13", "h3-js": "^4.5.0", @@ -22201,17 +22293,22 @@ "maplibre-gl-swipe": "^0.11.1", "maplibre-gl-time-slider": "^1.8.4", "maplibre-gl-usgs-lidar": "^0.11.1", - "maplibre-gl-vector": "^0.10.7", + "maplibre-gl-vector": "^0.10.5", "netcdfjs": "^4.0.0", + "ngeohash": "^0.6.4", + "open-location-code-typescript": "^1.5.0", "pbf": "^5.1.2", "pmtiles": "^4.4.1", "proj4": "^2.21.0", + "s2js": "^1.44.0", "shpjs": "^6.2.0", "three": "^0.185.1", + "webdggrid": "^1.9.0", "whitebox-wasm": "^0.5.1" }, "devDependencies": { "@types/geojson": "^7946.0.16", + "@types/ngeohash": "^0.6.8", "typescript": "^7.0.2" } }, @@ -22232,6 +22329,15 @@ "pbf": "^5.0.0" } }, + "packages/plugins/node_modules/a5-js": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.9.0.tgz", + "integrity": "sha512-nHL2db4wxkxPM2RZjyL4bzQRxRbY0pyTsPVphR6KUKe4Yzfo5WHS31vvzZ+Upel+tO7cdoF5LzFHbKF5JZZRFg==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, "packages/plugins/node_modules/pbf": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", diff --git a/packages/plugins/package.json b/packages/plugins/package.json index ff7f64948..90acec81f 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -31,7 +31,9 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-opener": "^2.5.4", "@turf/union": "^7.3.5", + "a5-js": "^0.9.0", "cog-tiler-wasm": "^0.3.1", + "dggal": "^0.0.6", "geotiff": "^3.0.5", "geotiff-geokeys-to-proj4": "^2024.4.13", "h3-js": "^4.5.0", @@ -60,17 +62,22 @@ "maplibre-gl-swipe": "^0.11.1", "maplibre-gl-time-slider": "^1.8.4", "maplibre-gl-usgs-lidar": "^0.11.1", - "maplibre-gl-vector": "^0.10.7", + "maplibre-gl-vector": "^0.10.5", "netcdfjs": "^4.0.0", + "ngeohash": "^0.6.4", + "open-location-code-typescript": "^1.5.0", "pbf": "^5.1.2", "pmtiles": "^4.4.1", "proj4": "^2.21.0", + "s2js": "^1.44.0", "shpjs": "^6.2.0", "three": "^0.185.1", + "webdggrid": "^1.9.0", "whitebox-wasm": "^0.5.1" }, "devDependencies": { "@types/geojson": "^7946.0.16", + "@types/ngeohash": "^0.6.8", "typescript": "^7.0.2" } } diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 2622a8239..932bf8b22 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -557,13 +557,171 @@ export { setH3Labels, normalizeH3GridSettings, h3LabelMinZoom, + h3ResolutionForZoom, h3CellFeature, h3GridForBounds, - h3BoundaryGeometry, - unwrapH3Boundary, + h3FixTransmeridianBoundary, type H3GridSettings, type H3Labels, } from "./plugins/maplibre-h3"; +export { + maplibreS2Plugin, + S2_PLUGIN_ID, + MAX_S2_LEVEL, + S2_VIEWPORT_CELL_LIMIT, + DEFAULT_S2_GRID_SETTINGS, + DEFAULT_S2_LABELS, + getS2GridSettings, + setS2GridSettings, + setS2Labels, + normalizeS2GridSettings, + s2LabelMinZoom, + s2LevelForZoom, + s2CellFeature, + s2GridForBounds, + type S2GridSettings, + type S2Labels, +} from "./plugins/maplibre-s2"; +export { DGGS_PLUGIN_IDS } from "./plugins/dggs-group"; +export { + maplibreA5Plugin, + A5_PLUGIN_ID, + A5_VIEWPORT_CELL_LIMIT, + DEFAULT_A5_GRID_SETTINGS, + DEFAULT_A5_LABELS, + getA5GridSettings, + setA5GridSettings, + setA5Labels, + normalizeA5GridSettings, + a5LabelMinZoom, + a5ResolutionForZoom, + a5CellFeature, + a5GridForBounds, + type A5GridSettings, + type A5Labels, +} from "./plugins/maplibre-a5"; +export { + maplibreDggridPlugin, + DGGRID_PLUGIN_ID, + DGGRID_CONFIG, + DGGRID_TOPOLOGIES, + DGGRID_PROJECTIONS, + DGGRID_APERTURES, + MAX_DGGRID_RESOLUTION, + DGGRID_VIEWPORT_CELL_LIMIT, + DEFAULT_DGGRID_GRID_SETTINGS, + DEFAULT_DGGRID_LABELS, + getDggridGridSettings, + setDggridGridSettings, + setDggridLabels, + normalizeDggridGridSettings, + dggridLabelMinZoom, + dggridResolutionForZoom, + dggridCellFeature, + dggridGridForBounds, + loadDggrid, + type DggridGridSettings, + type DggridLabels, + type DggridTopology, + type DggridProjection, + type DggridAperture, +} from "./plugins/maplibre-dggrid"; +export { + maplibreDggalPlugin, + DGGAL_PLUGIN_ID, + DGGAL_TYPES, + DGGAL_TYPE_NAMES, + DGGAL_VIEWPORT_CELL_LIMIT, + DEFAULT_DGGAL_GRID_SETTINGS, + DEFAULT_DGGAL_LABELS, + getDggalGridSettings, + setDggalGridSettings, + setDggalLabels, + normalizeDggalGridSettings, + dggalLabelMinZoom, + dggalResolutionForZoom, + dggalZoneFeature, + dggalParentZones, + dggalGridForBounds, + loadDggal, + type DggalGridSettings, + type DggalLabels, + type DggalType, + type DggalEngine, + type DggalDggrs, +} from "./plugins/maplibre-dggal"; +export { + maplibreOlcPlugin, + OLC_PLUGIN_ID, + OLC_CODE_LENGTHS, + OLC_VIEWPORT_CELL_LIMIT, + MAX_OLC_CODE_LENGTH, + DEFAULT_OLC_GRID_SETTINGS, + DEFAULT_OLC_LABELS, + getOlcGridSettings, + setOlcGridSettings, + setOlcLabels, + normalizeOlcGridSettings, + olcLabelMinZoom, + olcResolutionForZoom, + olcCellFeature, + olcGridForBounds, + olcParentCell, + olcChildCount, + olcNeighborCells, + type OlcGridSettings, + type OlcLabels, + type OlcCodeLength, +} from "./plugins/maplibre-olc"; +export { + maplibreGeohashPlugin, + GEOHASH_PLUGIN_ID, + GEOHASH_VIEWPORT_CELL_LIMIT, + GEOHASH_CHILDREN_PER_CELL, + MIN_GEOHASH_PRECISION, + MAX_GEOHASH_PRECISION, + DEFAULT_GEOHASH_GRID_SETTINGS, + DEFAULT_GEOHASH_LABELS, + getGeohashGridSettings, + setGeohashGridSettings, + setGeohashLabels, + normalizeGeohashGridSettings, + geohashLabelMinZoom, + geohashResolutionForZoom, + geohashCellFeature, + geohashGridForBounds, + geohashParentCell, + geohashNeighborCells, + type GeohashGridSettings, + type GeohashLabels, +} from "./plugins/maplibre-geohash"; +export { + maplibreTilecodePlugin, + TILECODE_PLUGIN_ID, + TILECODE_VIEWPORT_CELL_LIMIT, + TILECODE_CHILDREN_PER_CELL, + MIN_TILECODE_ZOOM, + MAX_TILECODE_ZOOM, + DEFAULT_TILECODE_GRID_SETTINGS, + DEFAULT_TILECODE_LABELS, + getTilecodeGridSettings, + setTilecodeGridSettings, + setTilecodeLabels, + normalizeTilecodeGridSettings, + tilecodeLabelMinZoom, + tilecodeResolutionForZoom, + tilecodeCellFeature, + tilecodeGridForBounds, + tilecodeParentCell, + tilecodeNeighborCells, + tileToTilecode, + tilecodeToTile, + tileToQuadkey, + pointToTile, + type Tile, + type TilecodeGridSettings, + type TilecodeLabels, +} from "./plugins/maplibre-tilecode"; export type { WeatherAnimationState, WeatherLayerController } from "./plugins/weather-layer"; export { maplibreCloudsPlugin, diff --git a/packages/plugins/src/plugins/dggs-group.ts b/packages/plugins/src/plugins/dggs-group.ts new file mode 100644 index 000000000..5f65bf1fb --- /dev/null +++ b/packages/plugins/src/plugins/dggs-group.ts @@ -0,0 +1,25 @@ +import { A5_PLUGIN_ID } from "./maplibre-a5"; +import { DGGAL_PLUGIN_ID } from "./maplibre-dggal"; +import { DGGRID_PLUGIN_ID } from "./maplibre-dggrid"; +import { GEOHASH_PLUGIN_ID } from "./maplibre-geohash"; +import { H3_PLUGIN_ID } from "./maplibre-h3"; +import { OLC_PLUGIN_ID } from "./maplibre-olc"; +import { S2_PLUGIN_ID } from "./maplibre-s2"; +import { TILECODE_PLUGIN_ID } from "./maplibre-tilecode"; + +/** + * Plugin ids grouped under the Plugins menu's "DGGS" submenu: the discrete + * global grid systems (H3, S2, A5, DGGRID, DGGAL, OLC, Geohash, Tilecode) + * users can toggle independently. Mirrors WEB_SERVICE_PLUGIN_IDS in + * web-service-sync.ts. + */ +export const DGGS_PLUGIN_IDS = [ + H3_PLUGIN_ID, + S2_PLUGIN_ID, + A5_PLUGIN_ID, + DGGRID_PLUGIN_ID, + DGGAL_PLUGIN_ID, + OLC_PLUGIN_ID, + GEOHASH_PLUGIN_ID, + TILECODE_PLUGIN_ID, +] as const; diff --git a/packages/plugins/src/plugins/maplibre-a5.ts b/packages/plugins/src/plugins/maplibre-a5.ts new file mode 100644 index 000000000..d1ecc02d1 --- /dev/null +++ b/packages/plugins/src/plugins/maplibre-a5.ts @@ -0,0 +1,894 @@ +import type { Feature, FeatureCollection, Polygon } from "geojson"; +import { + MAX_RESOLUTION, + cellArea, + cellToBoundary, + cellToChildren, + cellToLonLat, + cellToParent, + getNumCells, + getRes0Cells, + getResolution, + gridDisk, + hexToU64, + lonLatToCell, + polygonToCells, + u64ToHex, + uncompact, +} from "a5-js"; +import type { GeoJSONSource, Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; +import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; + +export const A5_PLUGIN_ID = "maplibre-a5-grid"; + +const PANEL_ID = "geolibre-a5-panel"; +const SOURCE_ID = "geolibre-a5-grid-source"; +const FILL_LAYER_ID = "geolibre-a5-grid-fill"; +const LINE_LAYER_ID = "geolibre-a5-grid-line"; +const LABEL_LAYER_ID = "geolibre-a5-grid-label"; +const SELECTED_SOURCE_ID = "geolibre-a5-selected-source"; +const SELECTED_FILL_LAYER_ID = "geolibre-a5-selected-fill"; +const SELECTED_LINE_LAYER_ID = "geolibre-a5-selected-line"; +const PARENTS_SOURCE_ID = "geolibre-a5-parents-source"; +const PARENTS_LINE_LAYER_ID = "geolibre-a5-parents-line"; + +const SELECTED_LINE_WIDTH = 3; + +/** Prevent a fine resolution over a large viewport from freezing the browser. */ +export const A5_VIEWPORT_CELL_LIMIT = 20_000; + +// a5-js brands its coordinate tuples; the brand is not exported, so it is +// recovered from the function signatures at the two casting boundaries below. +type A5LonLat = Parameters[0]; + +export interface A5GridSettings { + /** Derive the resolution from the map zoom instead of the manual slider. */ + autoResolution: boolean; + resolution: number; + fillColor: string; + fillOpacity: number; + lineColor: string; + lineWidth: number; + showLabels: boolean; + includeNeighbors: boolean; + includeParents: boolean; +} + +export const DEFAULT_A5_GRID_SETTINGS: A5GridSettings = { + autoResolution: true, + // Useful immediately at GeoLibre's default world view: resolution 4 fills it + // with 3,840 pentagons (resolution 6's 61,440 would already exceed the + // viewport safety cap). + resolution: 4, + fillColor: "#16a34a", + fillOpacity: 0.08, + lineColor: "#16a34a", + lineWidth: 1, + showLabels: true, + includeNeighbors: false, + includeParents: false, +}; + +export interface A5Labels { + title: string; + getTitle?: () => string; + controlTitle: string; + autoResolution: string; + resolution: string; + cellCount: (count: number) => string; + tooManyCells: (limit: number) => string; + fillColor: string; + fillOpacity: string; + lineColor: string; + lineWidth: string; + showLabels: string; + identifyHint: string; + selectedCell: string; + noSelection: string; + copyId: string; + parent: string; + children: string; + neighbors: string; + center: string; + zoomToCell: string; + addAsLayer: string; + exportGeoJson: string; + exportCsv: string; + includeNeighbors: string; + includeParents: string; +} + +export const DEFAULT_A5_LABELS: A5Labels = { + title: "A5 Grid", + controlTitle: "A5 grid settings", + autoResolution: "Automatic resolution", + resolution: "Resolution", + cellCount: (count) => `${count.toLocaleString()} cells in view`, + tooManyCells: (limit) => + `This view exceeds the ${limit.toLocaleString()} cell limit. Zoom in or lower the resolution.`, + fillColor: "Fill color", + fillOpacity: "Fill opacity", + lineColor: "Outline color", + lineWidth: "Outline width", + showLabels: "Show cell IDs", + identifyHint: "Click the map to identify an A5 cell.", + selectedCell: "Selected cell", + noSelection: "No cell selected", + copyId: "Copy ID", + parent: "Parent(s)", + children: "Children", + neighbors: "Neighbors", + center: "Center", + zoomToCell: "Zoom to cell", + addAsLayer: "Add grid as layer", + exportGeoJson: "Export GeoJSON", + exportCsv: "Export CSV", + includeNeighbors: "Include selected cell neighbors", + includeParents: "Include selected cell parent(s)", +}; + +let labels: A5Labels = { ...DEFAULT_A5_LABELS }; +let settings: A5GridSettings = { ...DEFAULT_A5_GRID_SETTINGS }; +let map: MapLibreMap | null = null; +let appRef: GeoLibreAppAPI | null = null; +let unregisterPanel: (() => void) | null = null; +let moveHandler: (() => void) | null = null; +let clickHandler: ((event: MapMouseEvent) => void) | null = null; +let unsubscribeBasemap: (() => void) | null = null; +let panelContainer: HTMLElement | null = null; +let selectedCell: string | null = null; + +let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; +let currentError: string | null = null; +let cachedTextFont: string[] | null = null; +let pendingRefresh: number | null = null; + +/** + * Coalesce viewport-driven rebuilds. Inertial pans emit `moveend` in bursts, + * and each rebuild walks up to A5_VIEWPORT_CELL_LIMIT cells on the main thread. + */ +function scheduleRefresh(): void { + if (pendingRefresh !== null) return; + pendingRefresh = requestAnimationFrame(() => { + pendingRefresh = null; + refresh(); + }); +} + +function cancelScheduledRefresh(): void { + if (pendingRefresh === null) return; + cancelAnimationFrame(pendingRefresh); + pendingRefresh = null; +} + +/** Reuse a font already present in the active basemap to avoid glyph 404s. */ +function pickTextFont(activeMap: MapLibreMap): string[] { + if (cachedTextFont) return cachedTextFont; + let fallback: string[] | null = null; + for (const layer of activeMap.getStyle()?.layers ?? []) { + if (layer.id === LABEL_LAYER_ID || layer.type !== "symbol") continue; + const font = (layer.layout as { "text-font"?: string[] } | undefined)?.["text-font"]; + if (!Array.isArray(font) || font.length === 0) continue; + if (font.every((name) => !/italic|bold/i.test(name))) return (cachedTextFont = font); + fallback ??= font; + } + return (cachedTextFont = fallback ?? ["Open Sans Regular", "Arial Unicode MS Regular"]); +} + +export function setA5Labels(next: Partial): void { + labels = { ...labels, ...next }; + if (panelContainer) renderPanel(panelContainer); +} + +export function getA5GridSettings(): A5GridSettings { + return { ...settings }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function color(value: unknown, fallback: string): string { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) + ? value.toLowerCase() + : fallback; +} + +/** + * The automatic zoom→resolution rule, mirroring vgrid-maplibre's A5Grid + * (https://www.npmjs.com/package/vgrid-maplibre): one A5 resolution per zoom + * level, clamped to the valid range. + */ +export function a5ResolutionForZoom(zoom: number): number { + return Math.min(MAX_RESOLUTION, Math.max(0, Math.floor(zoom))); +} + +/** The resolution actually rendered: zoom-derived when automatic, else manual. */ +function effectiveResolution(): number { + return settings.autoResolution && map ? a5ResolutionForZoom(map.getZoom()) : settings.resolution; +} + +export function normalizeA5GridSettings(value: unknown): A5GridSettings { + const candidate = (value ?? {}) as Partial; + return { + autoResolution: + typeof candidate.autoResolution === "boolean" + ? candidate.autoResolution + : DEFAULT_A5_GRID_SETTINGS.autoResolution, + resolution: Math.round( + clampNumber(candidate.resolution, 0, MAX_RESOLUTION, DEFAULT_A5_GRID_SETTINGS.resolution), + ), + fillColor: color(candidate.fillColor, DEFAULT_A5_GRID_SETTINGS.fillColor), + fillOpacity: clampNumber(candidate.fillOpacity, 0, 1, DEFAULT_A5_GRID_SETTINGS.fillOpacity), + lineColor: color(candidate.lineColor, DEFAULT_A5_GRID_SETTINGS.lineColor), + lineWidth: clampNumber(candidate.lineWidth, 0.1, 8, DEFAULT_A5_GRID_SETTINGS.lineWidth), + showLabels: + typeof candidate.showLabels === "boolean" + ? candidate.showLabels + : DEFAULT_A5_GRID_SETTINGS.showLabels, + includeNeighbors: + typeof candidate.includeNeighbors === "boolean" + ? candidate.includeNeighbors + : DEFAULT_A5_GRID_SETTINGS.includeNeighbors, + includeParents: + typeof candidate.includeParents === "boolean" + ? candidate.includeParents + : DEFAULT_A5_GRID_SETTINGS.includeParents, + }; +} + +/** + * Avoid thousands of overlapping IDs when the grid is viewed globally. A5 cell + * area shrinks 4x per resolution step (2x linearly), so one zoom level per + * resolution keeps the on-screen label density roughly constant. + */ +export function a5LabelMinZoom(resolution: number): number { + return Math.min(18, Math.max(2, Math.round(resolution) + 1)); +} + +export function setA5GridSettings(patch: Partial): void { + const previousResolution = effectiveResolution(); + // Leaving automatic mode adopts the current zoom-derived resolution as the + // fixed one, so the grid stays put instead of jumping to the stale slider. + if (settings.autoResolution && patch.autoResolution === false && patch.resolution === undefined) { + patch = { ...patch, resolution: previousResolution }; + } + settings = normalizeA5GridSettings({ ...settings, ...patch }); + const resolution = effectiveResolution(); + // Re-derive the selection only for an explicit slider change; toggling + // automatic resolution (like zooming in automatic mode) keeps the clicked + // cell and its neighbors/parents as they are. + if (selectedCell && patch.resolution !== undefined && resolution !== previousResolution) { + const center = cellToLonLat(hexToU64(selectedCell)); + selectedCell = u64ToHex(lonLatToCell(center, resolution)); + } + // Only the rendered resolution changes the geometry, so a paint/layout-only + // edit skips rebuilding up to A5_VIEWPORT_CELL_LIMIT features. + if (resolution !== previousResolution) { + refresh(); + } else { + applyStyle(); + updateSelectedSource(); + } + if (panelContainer) renderPanel(panelContainer); +} + +/** + * Unwrap antimeridian-crossing A5 rings so longitudes stay contiguous. + * a5-js returns raw ±180 jumps; MapLibre needs the adjacent world copy. + */ +export function a5UnwrapBoundary(ring: [number, number][]): [number, number][] { + if (ring.length === 0) return ring; + const out: [number, number][] = []; + for (const [lng, lat] of ring) { + let lon = lng; + if (out.length > 0) { + const reference = out[0][0]; + if (lon - reference > 180) lon -= 360; + if (lon - reference < -180) lon += 360; + } + out.push([lon, lat]); + } + return out; +} + +/** Convert an A5 cell (hex identifier) to a GeoJSON polygon with export attributes. */ +export function a5CellFeature(cell: string): Feature { + const id = hexToU64(cell); + const [lng, lat] = cellToLonLat(id); + const boundary = a5UnwrapBoundary(cellToBoundary(id) as [number, number][]); + return { + type: "Feature", + id: cell, + properties: { + a5: cell, + resolution: getResolution(id), + center_lat: lat, + center_lng: lng, + }, + geometry: { type: "Polygon", coordinates: [boundary] }, + }; +} + +const EARTH_AREA_M2 = 4 * Math.PI * 6371008.8 ** 2; + +/** + * polygonToCells is reliable for viewport-sized polygons, but once a polygon + * approaches hemisphere scale it starts missing interior cells (observed above + * roughly 20% of the sphere), and a ring spanning the full 360° of longitude is + * degenerate. Views larger than this fraction switch to enumerating every cell + * at the resolution and filtering by center — exact, and only reachable at + * coarse resolutions (the cell-limit guard rejects large views at fine ones), + * where the enumeration is cheap. + */ +const POLYGON_FILL_MAX_EARTH_FRACTION = 0.15; + +/** Fill a WGS84 bounding box with A5 cells. */ +export function a5GridForBounds( + bounds: [number, number, number, number], + resolution: number, + limit = A5_VIEWPORT_CELL_LIMIT, +): FeatureCollection { + const [west, southRaw, east, northRaw] = bounds; + const south = Math.max(-90, Math.min(90, southRaw)); + const north = Math.max(-90, Math.min(90, northRaw)); + const span = Math.min(360, east >= west ? east - west : east + 360 - west); + // Reject obviously oversized requests before materializing the full result. + // This spherical rectangle estimate is deliberately a little conservative; + // the exact hard cap below remains the final guard. A5 cells are exactly + // equal-area, so cellArea is not an average but the true size. + const radians = Math.PI / 180; + const areaM2 = + 6371008.8 ** 2 * + span * + radians * + Math.abs(Math.sin(north * radians) - Math.sin(south * radians)); + if (areaM2 / cellArea(resolution) > limit * 1.2) { + throw new RangeError(`A5 cell limit exceeded: ${limit}`); + } + + const cells: bigint[] = []; + const push = (cell: bigint): void => { + cells.push(cell); + if (cells.length > limit) { + throw new RangeError(`A5 cell limit exceeded: ${limit}`); + } + }; + const enumerable = getNumCells(resolution) <= limit * 4; + if (enumerable && (span >= 359.999 || areaM2 > EARTH_AREA_M2 * POLYGON_FILL_MAX_EARTH_FRACTION)) { + for (const cell of uncompact(getRes0Cells(), resolution)) { + const [lng, lat] = cellToLonLat(cell); + if (lat < south || lat > north) continue; + // Modulo keeps antimeridian-crossing and unwrapped west values working. + const offset = (((lng - west) % 360) + 360) % 360; + if (offset <= span || span >= 360) push(cell); + } + } else { + // A5 works on the sphere, so the ring may cross the antimeridian or carry + // unwrapped longitudes as-is — no splitting needed. polygonToCells compacts + // its result; uncompact back to one resolution, as mixed-resolution + // pentagons do not nest and would render gaps/overlaps. + // Known tradeoff (accepted): on the sphere the top/bottom edges of this + // plain rectangle are great-circle arcs, not parallels, so high-latitude + // views (e.g. Svalbard, Antarctica) omit some cells near the poleward + // edge. Densifying those edges would restore them. + const eastEdge = west + Math.min(span, 359.999); + const ring = [ + [west, south], + [eastEdge, south], + [eastEdge, north], + [west, north], + [west, south], + ] as A5LonLat[]; + for (const cell of uncompact(polygonToCells(ring, resolution), resolution)) { + push(cell); + } + } + return { + type: "FeatureCollection", + features: cells.map((cell) => a5CellFeature(u64ToHex(cell))), + }; +} + +function removeLayers(activeMap: MapLibreMap): void { + for (const id of [ + SELECTED_LINE_LAYER_ID, + SELECTED_FILL_LAYER_ID, + PARENTS_LINE_LAYER_ID, + LABEL_LAYER_ID, + LINE_LAYER_ID, + FILL_LAYER_ID, + ]) { + if (activeMap.getLayer(id)) activeMap.removeLayer(id); + } + for (const id of [SELECTED_SOURCE_ID, PARENTS_SOURCE_ID, SOURCE_ID]) { + if (activeMap.getSource(id)) activeMap.removeSource(id); + } +} + +function ensureLayers(): void { + if (!map) return; + if (!map.getSource(SOURCE_ID)) { + map.addSource(SOURCE_ID, { type: "geojson", data: currentGrid }); + map.addLayer({ + id: FILL_LAYER_ID, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": settings.fillColor, "fill-opacity": settings.fillOpacity }, + }); + map.addLayer({ + id: LINE_LAYER_ID, + type: "line", + source: SOURCE_ID, + paint: { "line-color": settings.lineColor, "line-width": settings.lineWidth }, + }); + map.addLayer({ + id: LABEL_LAYER_ID, + type: "symbol", + source: SOURCE_ID, + minzoom: a5LabelMinZoom(effectiveResolution()), + layout: { + "text-field": ["get", "a5"], + "text-font": pickTextFont(map), + "text-size": 10, + visibility: settings.showLabels ? "visible" : "none", + }, + paint: { + "text-color": settings.lineColor, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }, + }); + } + // Added before the selected layers so the selected cell stays on top of its + // (larger, overlapping) ancestors. + if (!map.getSource(PARENTS_SOURCE_ID)) { + map.addSource(PARENTS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: PARENTS_LINE_LAYER_ID, + type: "line", + source: PARENTS_SOURCE_ID, + paint: { + "line-color": "#f59e0b", + "line-width": SELECTED_LINE_WIDTH * 2, + "line-dasharray": [2, 2], + }, + }); + } + if (!map.getSource(SELECTED_SOURCE_ID)) { + map.addSource(SELECTED_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: SELECTED_FILL_LAYER_ID, + type: "fill", + source: SELECTED_SOURCE_ID, + paint: { "fill-color": "#f59e0b", "fill-opacity": 0.25 }, + }); + map.addLayer({ + id: SELECTED_LINE_LAYER_ID, + type: "line", + source: SELECTED_SOURCE_ID, + paint: { "line-color": "#f59e0b", "line-width": SELECTED_LINE_WIDTH }, + }); + } +} + +function applyStyle(): void { + if (!map) return; + ensureLayers(); + map.setPaintProperty(FILL_LAYER_ID, "fill-color", settings.fillColor); + map.setPaintProperty(FILL_LAYER_ID, "fill-opacity", settings.fillOpacity); + map.setPaintProperty(LINE_LAYER_ID, "line-color", settings.lineColor); + map.setPaintProperty(LINE_LAYER_ID, "line-width", settings.lineWidth); + map.setPaintProperty(LABEL_LAYER_ID, "text-color", settings.lineColor); + map.setLayoutProperty(LABEL_LAYER_ID, "visibility", settings.showLabels ? "visible" : "none"); + map.setLayerZoomRange(LABEL_LAYER_ID, a5LabelMinZoom(effectiveResolution()), 24); +} + +function refresh(): void { + if (!map) return; + const resolution = effectiveResolution(); + // The selected cell (and its neighbors/parents) deliberately stays at the + // resolution it was clicked at: in automatic mode a zoom or pan changes the + // rendered grid, but re-deriving the selection would silently replace the + // cell the user identified. Only an explicit settings change re-indexes it + // (see setA5GridSettings). + try { + const bounds = map.getBounds(); + currentGrid = a5GridForBounds( + [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], + resolution, + ); + currentError = null; + } catch (error) { + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = + error instanceof RangeError ? labels.tooManyCells(A5_VIEWPORT_CELL_LIMIT) : String(error); + } + applyStyle(); + (map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); +} + +function neighborCells(cell: string): string[] { + const id = hexToU64(cell); + // gridDisk compacts its result, so expand back to the cell's resolution. + return [...uncompact(gridDisk(id, 1), getResolution(id))].map(u64ToHex); +} + +function selectedCells(): string[] { + if (!selectedCell) return []; + return settings.includeNeighbors ? neighborCells(selectedCell) : [selectedCell]; +} + +/** + * Every resolution r-1 cell the selected cell overlaps. A5 pentagons do not + * nest, so a cell can spill across several coarser cells beyond its canonical + * cellToParent (sampled across the sphere, ~half of all cells overlap 2–3). + * Any overlapping coarser cell is larger than the cell itself, so it must + * cover part of the boundary: sampling a densified boundary, nudged slightly + * toward the center so parents that merely touch the edge are excluded, finds + * them all. + */ +function parentCells(cell: string): string[] { + const id = hexToU64(cell); + const resolution = getResolution(id); + if (resolution <= 0) return []; + const [centerLng, centerLat] = cellToLonLat(id); + const parents = new Set([u64ToHex(cellToParent(id))]); + for (const [lng, lat] of cellToBoundary(id, { closedRing: false, segments: 12 })) { + const inset = [ + centerLng + (lng - centerLng) * 0.999, + centerLat + (lat - centerLat) * 0.999, + ] as A5LonLat; + parents.add(u64ToHex(lonLatToCell(inset, resolution - 1))); + } + return [...parents]; +} + +function updateSelectedSource(): void { + const source = map?.getSource(SELECTED_SOURCE_ID) as GeoJSONSource | undefined; + source?.setData({ + type: "FeatureCollection", + features: selectedCells().map(a5CellFeature), + }); + const parentsSource = map?.getSource(PARENTS_SOURCE_ID) as GeoJSONSource | undefined; + parentsSource?.setData({ + type: "FeatureCollection", + features: + settings.includeParents && selectedCell ? parentCells(selectedCell).map(a5CellFeature) : [], + }); +} + +function gridCsv(grid: FeatureCollection): string { + const header = "a5,resolution,center_lat,center_lng"; + const rows = grid.features.map((feature) => { + const p = feature.properties!; + return [p.a5, p.resolution, p.center_lat, p.center_lng].join(","); + }); + return [header, ...rows].join("\n"); +} + +function fitSelected(): void { + if (!selectedCell || !appRef) return; + // The ring is unwrapped to stay contiguous across the antimeridian, so + // min/max longitudes never span the world. + const ring = a5UnwrapBoundary(cellToBoundary(hexToU64(selectedCell)) as [number, number][]); + const lons = ring.map(([lng]) => lng); + const lats = ring.map(([, lat]) => lat); + appRef.fitBounds?.([Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)]); +} + +function renderPanel(container: HTMLElement): void { + panelContainer = container; + container.replaceChildren(); + container.style.font = "13px/1.4 system-ui, sans-serif"; + + const section = document.createElement("div"); + section.style.display = "grid"; + section.style.gap = "10px"; + section.style.padding = "12px"; + container.appendChild(section); + + const row = (text: string, input: HTMLElement): void => { + const label = document.createElement("label"); + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.justifyContent = "space-between"; + label.style.gap = "12px"; + const span = document.createElement("span"); + span.textContent = text; + label.append(span, input); + section.appendChild(label); + }; + const button = (text: string, action: () => void, disabled = false): HTMLButtonElement => { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = text; + element.disabled = disabled; + element.style.padding = "6px 8px"; + element.style.border = "1px solid hsl(var(--border))"; + element.style.borderRadius = "6px"; + element.style.background = "hsl(var(--background))"; + element.style.color = "inherit"; + element.style.cursor = disabled ? "not-allowed" : "pointer"; + element.style.opacity = disabled ? "0.5" : "1"; + element.style.transition = "background-color 120ms ease, border-color 120ms ease"; + element.addEventListener("mouseenter", () => { + if (!element.disabled) element.style.background = "hsl(var(--muted))"; + }); + element.addEventListener("mouseleave", () => { + element.style.background = "hsl(var(--background))"; + }); + element.addEventListener("click", action); + return element; + }; + + const autoResolution = document.createElement("input"); + autoResolution.type = "checkbox"; + autoResolution.checked = settings.autoResolution; + autoResolution.addEventListener("change", () => + setA5GridSettings({ autoResolution: autoResolution.checked }), + ); + row(labels.autoResolution, autoResolution); + + // In automatic mode the slider becomes a read-only indicator of the + // zoom-derived resolution; refresh() re-renders the panel on every moveend, + // so it tracks zoom gestures. + const shownResolution = effectiveResolution(); + const resolution = document.createElement("input"); + resolution.type = "range"; + resolution.min = "0"; + resolution.max = String(MAX_RESOLUTION); + resolution.value = String(shownResolution); + resolution.title = String(shownResolution); + resolution.disabled = settings.autoResolution; + resolution.addEventListener("input", () => { + resolution.title = resolution.value; + }); + resolution.addEventListener("change", () => + setA5GridSettings({ resolution: Number(resolution.value) }), + ); + const resolutionWrap = document.createElement("span"); + resolutionWrap.style.display = "flex"; + resolutionWrap.style.alignItems = "center"; + resolutionWrap.style.gap = "6px"; + resolutionWrap.style.opacity = settings.autoResolution ? "0.6" : "1"; + const resolutionValue = document.createElement("strong"); + resolutionValue.textContent = String(shownResolution); + resolution.addEventListener("input", () => { + resolutionValue.textContent = resolution.value; + }); + resolutionWrap.append(resolution, resolutionValue); + row(labels.resolution, resolutionWrap); + + for (const [text, key] of [ + [labels.fillColor, "fillColor"], + [labels.lineColor, "lineColor"], + ] as const) { + const input = document.createElement("input"); + input.type = "color"; + input.value = settings[key]; + // `change` (not `input`): setA5GridSettings re-renders the panel, which + // would destroy the picker mid-drag. + input.addEventListener("change", () => setA5GridSettings({ [key]: input.value })); + row(text, input); + } + for (const [text, key, min, max, step] of [ + [labels.fillOpacity, "fillOpacity", 0, 1, 0.05], + [labels.lineWidth, "lineWidth", 0.1, 8, 0.1], + ] as const) { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.value = String(settings[key]); + input.style.width = "72px"; + input.addEventListener("change", () => setA5GridSettings({ [key]: Number(input.value) })); + row(text, input); + } + for (const [text, key] of [ + [labels.showLabels, "showLabels"], + [labels.includeNeighbors, "includeNeighbors"], + [labels.includeParents, "includeParents"], + ] as const) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = settings[key]; + input.addEventListener("change", () => setA5GridSettings({ [key]: input.checked })); + row(text, input); + } + + const status = document.createElement("div"); + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + section.appendChild(status); + + const hint = document.createElement("div"); + hint.textContent = labels.identifyHint; + hint.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(hint); + + const selectedHeading = document.createElement("strong"); + selectedHeading.textContent = labels.selectedCell; + section.appendChild(selectedHeading); + + if (selectedCell) { + const id = hexToU64(selectedCell); + const cellResolution = getResolution(id); + const [lng, lat] = cellToLonLat(id); + const details = document.createElement("dl"); + details.style.margin = "0"; + details.style.display = "grid"; + details.style.gridTemplateColumns = "auto 1fr"; + details.style.gap = "4px 10px"; + const addDetail = (term: string, value: string): void => { + const dt = document.createElement("dt"); + dt.textContent = term; + dt.style.color = "var(--muted-foreground, #6b7280)"; + const dd = document.createElement("dd"); + dd.textContent = value; + dd.style.margin = "0"; + dd.style.overflowWrap = "anywhere"; + // Multi-line values (one overlapping parent per line) keep their breaks. + dd.style.whiteSpace = "pre-line"; + details.append(dt, dd); + }; + addDetail("ID", selectedCell); + addDetail(labels.resolution, String(cellResolution)); + addDetail(labels.center, `${lat.toFixed(6)}, ${lng.toFixed(6)}`); + if (cellResolution > 0) { + // Every overlapping r-1 cell (canonical cellToParent first), matching + // the dashed parent outlines on the map. + addDetail(labels.parent, parentCells(selectedCell).join("\n")); + } + if (cellResolution < MAX_RESOLUTION) { + addDetail(labels.children, String(cellToChildren(id).length)); + } + addDetail(labels.neighbors, String(neighborCells(selectedCell).length - 1)); + section.appendChild(details); + } else { + const empty = document.createElement("div"); + empty.textContent = labels.noSelection; + empty.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(empty); + } + + const actions = document.createElement("div"); + actions.style.display = "grid"; + actions.style.gridTemplateColumns = "1fr 1fr"; + actions.style.gap = "6px"; + actions.append( + button( + labels.copyId, + () => { + if (selectedCell) void navigator.clipboard?.writeText(selectedCell); + }, + !selectedCell, + ), + button(labels.zoomToCell, fitSelected, !selectedCell), + button( + labels.addAsLayer, + () => { + if (currentGrid.features.length) { + appRef?.addGeoJsonLayer(`A5 grid (resolution ${effectiveResolution()})`, currentGrid); + } + }, + currentGrid.features.length === 0, + ), + button( + labels.exportGeoJson, + () => { + appRef?.exportTextFile?.( + `a5-grid-r${effectiveResolution()}.geojson`, + JSON.stringify(currentGrid, null, 2), + { + description: "GeoJSON", + extensions: ["geojson"], + mimeType: "application/geo+json", + promptName: true, + }, + ); + }, + currentGrid.features.length === 0, + ), + button( + labels.exportCsv, + () => { + appRef?.exportTextFile?.(`a5-grid-r${effectiveResolution()}.csv`, gridCsv(currentGrid), { + description: "CSV", + extensions: ["csv"], + mimeType: "text/csv", + promptName: true, + }); + }, + currentGrid.features.length === 0, + ), + ); + section.appendChild(actions); +} + +function settingsEqual(a: A5GridSettings, b: A5GridSettings): boolean { + return Object.keys(a).every( + (key) => a[key as keyof A5GridSettings] === b[key as keyof A5GridSettings], + ); +} + +export const maplibreA5Plugin: GeoLibrePlugin = { + id: A5_PLUGIN_ID, + name: "A5 Grid", + version: "1.0.0", + activate: (app) => { + const activeMap = app.getMap?.(); + if (!activeMap) return false; + map = activeMap; + appRef = app; + moveHandler = () => scheduleRefresh(); + clickHandler = (event) => { + selectedCell = u64ToHex( + lonLatToCell([event.lngLat.lng, event.lngLat.lat] as A5LonLat, effectiveResolution()), + ); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }; + activeMap.on("moveend", moveHandler); + activeMap.on("click", clickHandler); + unsubscribeBasemap = app.onBasemapChange(() => { + cachedTextFont = null; + activeMap.once("idle", refresh); + }); + unregisterPanel = + app.registerRightPanel?.({ + id: PANEL_ID, + title: () => labels.getTitle?.() ?? labels.title, + dock: "right-of-style", + defaultWidth: 340, + render: (container) => renderPanel(container), + // Closing the panel ends the identify session: drop the clicked cell + // and, with it, the neighbor/parent overlays derived from it. + onClose: () => { + selectedCell = null; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }, + }) ?? null; + refresh(); + app.openRightPanel?.(PANEL_ID); + }, + deactivate: (app) => { + cancelScheduledRefresh(); + if (map && moveHandler) map.off("moveend", moveHandler); + if (map && clickHandler) map.off("click", clickHandler); + unsubscribeBasemap?.(); + unregisterPanel?.(); + if (map) removeLayers(map); + moveHandler = null; + clickHandler = null; + unsubscribeBasemap = null; + unregisterPanel = null; + panelContainer = null; + selectedCell = null; + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = null; + cachedTextFont = null; + map = null; + appRef = null; + app.closeRightPanel?.(PANEL_ID); + }, + getProjectState: () => + settingsEqual(settings, DEFAULT_A5_GRID_SETTINGS) ? undefined : { ...settings }, + applyProjectState: (_app, state) => { + const next = normalizeA5GridSettings(state); + if (settingsEqual(settings, next)) return false; + settings = next; + refresh(); + }, +}; diff --git a/packages/plugins/src/plugins/maplibre-dggal.ts b/packages/plugins/src/plugins/maplibre-dggal.ts new file mode 100644 index 000000000..0d5794584 --- /dev/null +++ b/packages/plugins/src/plugins/maplibre-dggal.ts @@ -0,0 +1,1145 @@ +import type { Feature, FeatureCollection, Polygon } from "geojson"; +import type { GeoJSONSource, Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; +import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; + +export const DGGAL_PLUGIN_ID = "maplibre-dggal"; + +const PANEL_ID = "geolibre-dggal-panel"; +const SOURCE_ID = "geolibre-dggal-grid-source"; +const FILL_LAYER_ID = "geolibre-dggal-grid-fill"; +const LINE_LAYER_ID = "geolibre-dggal-grid-line"; +const LABEL_LAYER_ID = "geolibre-dggal-grid-label"; +const SELECTED_SOURCE_ID = "geolibre-dggal-selected-source"; +const SELECTED_FILL_LAYER_ID = "geolibre-dggal-selected-fill"; +const SELECTED_LINE_LAYER_ID = "geolibre-dggal-selected-line"; +const PARENTS_SOURCE_ID = "geolibre-dggal-parents-source"; +const PARENTS_LINE_LAYER_ID = "geolibre-dggal-parents-line"; + +const SELECTED_LINE_WIDTH = 3; + +/** Prevent a fine resolution over a large viewport from freezing the browser. */ +export const DGGAL_VIEWPORT_CELL_LIMIT = 20_000; + +/** + * The DGGRSs vgrid-maplibre's DGGALGrid exposes, with its per-type maximum + * resolutions (each at or under the engine's own zone-level limit). + */ +export const DGGAL_TYPES = { + GNOSISGlobalGrid: 28, + ISEA4R: 20, + ISEA9R: 16, + ISEA3H: 33, + ISEA7H: 19, + ISEA7H_Z7: 19, + IVEA4R: 20, + IVEA9R: 16, + IVEA3H: 33, + IVEA7H: 19, + IVEA7H_Z7: 19, + RTEA4R: 20, + RTEA9R: 16, + RTEA3H: 33, + RTEA7H: 19, + RTEA7H_Z7: 19, + HEALPix: 26, + rHEALPix: 16, +} as const; + +export type DggalType = keyof typeof DGGAL_TYPES; + +export const DGGAL_TYPE_NAMES = Object.keys(DGGAL_TYPES) as DggalType[]; + +/** A geographic point in radians, DGGAL's native unit. */ +interface GeoPoint { + lat: number; + lon: number; +} + +/** + * The subset of a DGGAL `DGGRS` instance this plugin uses (dggal ships full + * TypeScript declarations; this mirror just keeps the type-only surface in + * one place next to the quirks documented on the helpers below). + */ +export interface DggalDggrs { + getZoneFromTextID(zoneId: string): bigint; + getZoneTextID(zone: bigint): string; + getZoneLevel(zone: bigint): number; + getZoneParents(zone: bigint): bigint[]; + getZoneChildren(zone: bigint): bigint[]; + getZoneNeighbors(zone: bigint): Array<{ zone: bigint; type: number }>; + getZoneWGS84Centroid(zone: bigint): GeoPoint; + getZoneRefinedWGS84Vertices(zone: bigint, edgeRefinement: number): GeoPoint[]; + listZones(level: number, bbox: { ll: GeoPoint; ur: GeoPoint }): bigint[]; + getZoneFromWGS84Centroid(level: number, geoPoint: GeoPoint): bigint; + countZones(level: number): bigint; + getMaxDGGRSZoneLevel(): number; + delete(): void; +} + +/** The DGGAL API handle returned by `DGGAL.init()`. */ +export interface DggalEngine { + createDGGRS(name: string): DggalDggrs; + listDGGRS(): string[]; +} + +export interface DggalGridSettings { + /** Which DGGRS to render ("DGGS type" in the panel). */ + dggrsType: DggalType; + /** Derive the resolution from the map zoom instead of the manual slider. */ + autoResolution: boolean; + resolution: number; + fillColor: string; + fillOpacity: number; + lineColor: string; + lineWidth: number; + showLabels: boolean; + includeNeighbors: boolean; + includeParents: boolean; +} + +export const DEFAULT_DGGAL_GRID_SETTINGS: DggalGridSettings = { + dggrsType: "ISEA3H", + autoResolution: true, + // Useful immediately at GeoLibre's default world view: ISEA3H resolution 2 + // tiles the globe with 92 hexagons/pentagons. + resolution: 2, + fillColor: "#0d9488", + fillOpacity: 0.08, + lineColor: "#0d9488", + lineWidth: 1, + showLabels: true, + includeNeighbors: false, + includeParents: false, +}; + +export interface DggalLabels { + title: string; + getTitle?: () => string; + controlTitle: string; + gridType: string; + autoResolution: string; + resolution: string; + cellCount: (count: number) => string; + tooManyCells: (limit: number) => string; + fillColor: string; + fillOpacity: string; + lineColor: string; + lineWidth: string; + showLabels: string; + identifyHint: string; + selectedCell: string; + noSelection: string; + copyId: string; + parent: string; + children: string; + neighbors: string; + center: string; + zoomToCell: string; + addAsLayer: string; + exportGeoJson: string; + exportCsv: string; + includeNeighbors: string; + includeParents: string; +} + +export const DEFAULT_DGGAL_LABELS: DggalLabels = { + title: "DGGAL", + controlTitle: "DGGAL settings", + gridType: "DGGS type", + autoResolution: "Automatic resolution", + resolution: "Resolution", + cellCount: (count) => `${count.toLocaleString()} cells in view`, + tooManyCells: (limit) => + `This view exceeds the ${limit.toLocaleString()} cell limit. Zoom in or lower the resolution.`, + fillColor: "Fill color", + fillOpacity: "Fill opacity", + lineColor: "Outline color", + lineWidth: "Outline width", + showLabels: "Show cell IDs", + identifyHint: "Click the map to identify a DGGAL cell.", + selectedCell: "Selected cell", + noSelection: "No cell selected", + copyId: "Copy ID", + parent: "Parent(s)", + children: "Children", + neighbors: "Neighbors", + center: "Center", + zoomToCell: "Zoom to cell", + addAsLayer: "Add grid as layer", + exportGeoJson: "Export GeoJSON", + exportCsv: "Export CSV", + includeNeighbors: "Include selected cell neighbors", + includeParents: "Include selected cell parent(s)", +}; + +const DEG_PER_RAD = 180 / Math.PI; +const RAD_PER_DEG = Math.PI / 180; + +let labels: DggalLabels = { ...DEFAULT_DGGAL_LABELS }; +let settings: DggalGridSettings = { ...DEFAULT_DGGAL_GRID_SETTINGS }; +let map: MapLibreMap | null = null; +let appRef: GeoLibreAppAPI | null = null; +let dggal: DggalEngine | null = null; +let dggrs: DggalDggrs | null = null; +let dggrsType: DggalType | null = null; +let unregisterPanel: (() => void) | null = null; +let moveHandler: (() => void) | null = null; +let clickHandler: ((event: MapMouseEvent) => void) | null = null; +let unsubscribeBasemap: (() => void) | null = null; +let panelContainer: HTMLElement | null = null; +/** The selected zone's text ID (it encodes the zone's level, unlike DGGRID). */ +let selectedCell: string | null = null; + +let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; +let currentError: string | null = null; +let cachedTextFont: string[] | null = null; +let pendingRefresh: number | null = null; +/** Bumped on every activate/deactivate so an in-flight WASM load cannot attach after teardown. */ +let activationGeneration = 0; + +let dggalPromise: Promise | null = null; + +/** + * Load the DGGAL WASM module once and reuse the handle. Imported dynamically + * so the ~1 MB module stays out of the main bundle until the plugin is + * activated. A failed load clears the cache so the next activate can retry. + */ +export function loadDggal(): Promise { + dggalPromise ??= import("dggal") + .then((module) => module.DGGAL.init() as unknown as Promise) + .catch((error) => { + dggalPromise = null; + throw error; + }); + return dggalPromise; +} + +/** The DGGRS instance for the active grid type, recreated when it changes. */ +function activeDggrs(): DggalDggrs | null { + if (!dggal) return null; + if (!dggrs || dggrsType !== settings.dggrsType) { + dggrs?.delete(); + dggrs = dggal.createDGGRS(settings.dggrsType); + dggrsType = settings.dggrsType; + } + return dggrs; +} + +/** + * Coalesce viewport-driven rebuilds. Inertial pans emit `moveend` in bursts, + * and each rebuild materializes up to DGGAL_VIEWPORT_CELL_LIMIT zones on the + * main thread. + */ +function scheduleRefresh(): void { + if (pendingRefresh !== null) return; + pendingRefresh = requestAnimationFrame(() => { + pendingRefresh = null; + refresh(); + }); +} + +function cancelScheduledRefresh(): void { + if (pendingRefresh === null) return; + cancelAnimationFrame(pendingRefresh); + pendingRefresh = null; +} + +/** Reuse a font already present in the active basemap to avoid glyph 404s. */ +function pickTextFont(activeMap: MapLibreMap): string[] { + if (cachedTextFont) return cachedTextFont; + let fallback: string[] | null = null; + for (const layer of activeMap.getStyle()?.layers ?? []) { + if (layer.id === LABEL_LAYER_ID || layer.type !== "symbol") continue; + const font = (layer.layout as { "text-font"?: string[] } | undefined)?.["text-font"]; + if (!Array.isArray(font) || font.length === 0) continue; + if (font.every((name) => !/italic|bold/i.test(name))) return (cachedTextFont = font); + fallback ??= font; + } + return (cachedTextFont = fallback ?? ["Open Sans Regular", "Arial Unicode MS Regular"]); +} + +export function setDggalLabels(next: Partial): void { + labels = { ...labels, ...next }; + if (panelContainer) renderPanel(panelContainer); +} + +export function getDggalGridSettings(): DggalGridSettings { + return { ...settings }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function color(value: unknown, fallback: string): string { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) + ? value.toLowerCase() + : fallback; +} + +/** + * The automatic zoom→resolution rule, mirroring vgrid-maplibre's DGGALGrid + * (https://www.npmjs.com/package/vgrid-maplibre): the factor depends on the + * DGGRS refinement — aperture-3 hexagons subdivide slowest (1.15/zoom), + * 9-fold rhombuses and rHEALPix fastest (0.6/zoom) — clamped to the type's + * resolution range. + */ +export function dggalResolutionForZoom(zoom: number, type: DggalType): number { + let factor: number; + switch (type) { + case "ISEA3H": + case "IVEA3H": + case "RTEA3H": + factor = 1.15; + break; + case "ISEA4R": + case "IVEA4R": + case "RTEA4R": + case "HEALPix": + factor = 0.95; + break; + case "ISEA7H": + case "ISEA7H_Z7": + case "IVEA7H": + case "IVEA7H_Z7": + case "RTEA7H": + case "RTEA7H_Z7": + factor = 0.65; + break; + case "ISEA9R": + case "IVEA9R": + case "RTEA9R": + case "rHEALPix": + factor = 0.6; + break; + default: + // GNOSISGlobalGrid: one resolution per zoom level. + factor = 1; + break; + } + return Math.min(DGGAL_TYPES[type], Math.max(0, Math.floor(zoom * factor))); +} + +/** The resolution actually rendered: zoom-derived when automatic, else manual. */ +function effectiveResolution(): number { + return settings.autoResolution && map + ? dggalResolutionForZoom(map.getZoom(), settings.dggrsType) + : Math.min(settings.resolution, DGGAL_TYPES[settings.dggrsType]); +} + +export function normalizeDggalGridSettings(value: unknown): DggalGridSettings { + const candidate = (value ?? {}) as Partial; + const dggrsType = + typeof candidate.dggrsType === "string" && Object.hasOwn(DGGAL_TYPES, candidate.dggrsType) + ? (candidate.dggrsType as DggalType) + : DEFAULT_DGGAL_GRID_SETTINGS.dggrsType; + return { + dggrsType, + autoResolution: + typeof candidate.autoResolution === "boolean" + ? candidate.autoResolution + : DEFAULT_DGGAL_GRID_SETTINGS.autoResolution, + resolution: Math.round( + clampNumber( + candidate.resolution, + 0, + DGGAL_TYPES[dggrsType], + Math.min(DEFAULT_DGGAL_GRID_SETTINGS.resolution, DGGAL_TYPES[dggrsType]), + ), + ), + fillColor: color(candidate.fillColor, DEFAULT_DGGAL_GRID_SETTINGS.fillColor), + fillOpacity: clampNumber(candidate.fillOpacity, 0, 1, DEFAULT_DGGAL_GRID_SETTINGS.fillOpacity), + lineColor: color(candidate.lineColor, DEFAULT_DGGAL_GRID_SETTINGS.lineColor), + lineWidth: clampNumber(candidate.lineWidth, 0.1, 8, DEFAULT_DGGAL_GRID_SETTINGS.lineWidth), + showLabels: + typeof candidate.showLabels === "boolean" + ? candidate.showLabels + : DEFAULT_DGGAL_GRID_SETTINGS.showLabels, + includeNeighbors: + typeof candidate.includeNeighbors === "boolean" + ? candidate.includeNeighbors + : DEFAULT_DGGAL_GRID_SETTINGS.includeNeighbors, + includeParents: + typeof candidate.includeParents === "boolean" + ? candidate.includeParents + : DEFAULT_DGGAL_GRID_SETTINGS.includeParents, + }; +} + +/** + * Avoid thousands of overlapping IDs when the grid is viewed globally. Zone + * area shrinks with each resolution step, so roughly one zoom level per + * resolution keeps the on-screen label density steady. + */ +export function dggalLabelMinZoom(resolution: number): number { + return Math.min(18, Math.max(2, Math.round(resolution) + 1)); +} + +export function setDggalGridSettings(patch: Partial): void { + const previousResolution = effectiveResolution(); + // Leaving automatic mode adopts the current zoom-derived resolution as the + // fixed one, so the grid stays put instead of jumping to the stale slider. + if (settings.autoResolution && patch.autoResolution === false && patch.resolution === undefined) { + patch = { ...patch, resolution: previousResolution }; + } + const next = normalizeDggalGridSettings({ ...settings, ...patch }); + const typeChanged = next.dggrsType !== settings.dggrsType; + const engine = activeDggrs(); + // Grid type changes the lattice entirely — drop the selection and its + // neighbor/parent overlays rather than re-mapping a meaningless zone id. + if (typeChanged) { + selectedCell = null; + } + settings = next; + const resolution = effectiveResolution(); + if ( + engine && + selectedCell && + patch.resolution !== undefined && + resolution !== previousResolution + ) { + // Re-derive the selection only for an explicit slider change; toggling + // automatic resolution (like zooming in automatic mode) keeps the clicked + // zone and its neighbors/parents as they are. + const center = engine.getZoneWGS84Centroid(engine.getZoneFromTextID(selectedCell)); + selectedCell = engine.getZoneTextID(engine.getZoneFromWGS84Centroid(resolution, center)); + } + // Only the rendered type/resolution changes the geometry, so a + // paint/layout-only edit skips rebuilding up to DGGAL_VIEWPORT_CELL_LIMIT + // features. + if (typeChanged || resolution !== previousResolution) { + refresh(); + } else { + applyStyle(); + updateSelectedSource(); + } + if (panelContainer) renderPanel(panelContainer); +} + +/** + * A zone boundary as a closed lon/lat ring in degrees. DGGAL's refined + * vertices already keep antimeridian-crossing zones contiguous (longitudes + * may pass ±180, which MapLibre renders in the adjacent world copy). + */ +function zoneRing(engine: DggalDggrs, zone: bigint): [number, number][] { + const ring = engine + .getZoneRefinedWGS84Vertices(zone, 0) + .map(({ lat, lon }): [number, number] => [lon * DEG_PER_RAD, lat * DEG_PER_RAD]); + if (ring.length > 0) { + const [firstLng, firstLat] = ring[0]; + const [lastLng, lastLat] = ring[ring.length - 1]; + if (firstLng !== lastLng || firstLat !== lastLat) ring.push([firstLng, firstLat]); + } + return ring; +} + +/** Convert a DGGAL zone (text ID) to a GeoJSON polygon with export attributes. */ +export function dggalZoneFeature(engine: DggalDggrs, cell: string): Feature { + const zone = engine.getZoneFromTextID(cell); + const centroid = engine.getZoneWGS84Centroid(zone); + return { + type: "Feature", + id: cell, + properties: { + dggal: cell, + resolution: engine.getZoneLevel(zone), + center_lat: centroid.lat * DEG_PER_RAD, + center_lng: centroid.lon * DEG_PER_RAD, + }, + geometry: { type: "Polygon", coordinates: [zoneRing(engine, zone)] }, + }; +} + +function normalizeLon(lon: number): number { + let x = lon; + while (x > 180) x -= 360; + while (x < -180) x += 360; + return x; +} + +/** + * Fill a WGS84 bounding box with DGGAL zones, mirroring vgrid-maplibre's + * DGGALGrid: the engine's `listZones` does the viewport query natively. + */ +export function dggalGridForBounds( + engine: DggalDggrs, + bounds: [number, number, number, number], + resolution: number, + limit = DGGAL_VIEWPORT_CELL_LIMIT, +): FeatureCollection { + let [west, south, east, north] = bounds; + south = Math.max(-90, Math.min(90, south)); + north = Math.max(-90, Math.min(90, north)); + // Measure the raw span before wrapping endpoints into [-180, 180]. Otherwise + // [0, …, 360, …] collapses to a zero-width box and listZones returns nothing. + if (east - west >= 360) { + west = -180; + east = 180; + } else { + west = normalizeLon(west); + east = normalizeLon(east); + } + // Wrapped antimeridian bounds (west > east) must be split — a negative span + // undercounts area and feeds listZones an inverted bbox. + if (east < west) { + const left = dggalGridForBounds(engine, [west, south, 180, north], resolution, limit); + const right = dggalGridForBounds(engine, [-180, south, east, north], resolution, limit); + const seen = new Set(); + const features: Feature[] = []; + for (const feature of [...left.features, ...right.features]) { + const id = String(feature.properties?.dggal ?? feature.id); + if (seen.has(id)) continue; + seen.add(id); + features.push(feature); + if (features.length > limit) { + throw new RangeError(`DGGAL zone limit exceeded: ${limit}`); + } + } + return { type: "FeatureCollection", features }; + } + // Reject obviously oversized requests before materializing the zone list. + // The global zone count is exact per type, so the spherical rectangle + // estimate is close; the hard cap below remains the final guard. + const radians = Math.PI / 180; + const areaFraction = + ((east - west) * radians * Math.abs(Math.sin(north * radians) - Math.sin(south * radians))) / + (4 * Math.PI); + if (Number(engine.countZones(resolution)) * areaFraction > limit * 1.2) { + throw new RangeError(`DGGAL zone limit exceeded: ${limit}`); + } + + const zones = engine.listZones(resolution, { + ll: { lat: south * RAD_PER_DEG, lon: west * RAD_PER_DEG }, + ur: { lat: north * RAD_PER_DEG, lon: east * RAD_PER_DEG }, + }); + if (zones.length > limit) { + throw new RangeError(`DGGAL zone limit exceeded: ${limit}`); + } + return { + type: "FeatureCollection", + features: zones.map((zone) => dggalZoneFeature(engine, engine.getZoneTextID(zone))), + }; +} + +function removeLayers(activeMap: MapLibreMap): void { + for (const id of [ + SELECTED_LINE_LAYER_ID, + SELECTED_FILL_LAYER_ID, + PARENTS_LINE_LAYER_ID, + LABEL_LAYER_ID, + LINE_LAYER_ID, + FILL_LAYER_ID, + ]) { + if (activeMap.getLayer(id)) activeMap.removeLayer(id); + } + for (const id of [SELECTED_SOURCE_ID, PARENTS_SOURCE_ID, SOURCE_ID]) { + if (activeMap.getSource(id)) activeMap.removeSource(id); + } +} + +function ensureLayers(): void { + if (!map) return; + if (!map.getSource(SOURCE_ID)) { + map.addSource(SOURCE_ID, { type: "geojson", data: currentGrid }); + map.addLayer({ + id: FILL_LAYER_ID, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": settings.fillColor, "fill-opacity": settings.fillOpacity }, + }); + map.addLayer({ + id: LINE_LAYER_ID, + type: "line", + source: SOURCE_ID, + paint: { "line-color": settings.lineColor, "line-width": settings.lineWidth }, + }); + map.addLayer({ + id: LABEL_LAYER_ID, + type: "symbol", + source: SOURCE_ID, + minzoom: dggalLabelMinZoom(effectiveResolution()), + layout: { + "text-field": ["get", "dggal"], + "text-font": pickTextFont(map), + "text-size": 10, + visibility: settings.showLabels ? "visible" : "none", + }, + paint: { + "text-color": settings.lineColor, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }, + }); + } + // Added before the selected layers so the selected zone stays on top of its + // (larger, overlapping) parents. + if (!map.getSource(PARENTS_SOURCE_ID)) { + map.addSource(PARENTS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: PARENTS_LINE_LAYER_ID, + type: "line", + source: PARENTS_SOURCE_ID, + paint: { + "line-color": "#f59e0b", + "line-width": SELECTED_LINE_WIDTH * 2, + "line-dasharray": [2, 2], + }, + }); + } + if (!map.getSource(SELECTED_SOURCE_ID)) { + map.addSource(SELECTED_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: SELECTED_FILL_LAYER_ID, + type: "fill", + source: SELECTED_SOURCE_ID, + paint: { "fill-color": "#f59e0b", "fill-opacity": 0.25 }, + }); + map.addLayer({ + id: SELECTED_LINE_LAYER_ID, + type: "line", + source: SELECTED_SOURCE_ID, + paint: { "line-color": "#f59e0b", "line-width": SELECTED_LINE_WIDTH }, + }); + } +} + +function applyStyle(): void { + if (!map) return; + ensureLayers(); + map.setPaintProperty(FILL_LAYER_ID, "fill-color", settings.fillColor); + map.setPaintProperty(FILL_LAYER_ID, "fill-opacity", settings.fillOpacity); + map.setPaintProperty(LINE_LAYER_ID, "line-color", settings.lineColor); + map.setPaintProperty(LINE_LAYER_ID, "line-width", settings.lineWidth); + map.setPaintProperty(LABEL_LAYER_ID, "text-color", settings.lineColor); + map.setLayoutProperty(LABEL_LAYER_ID, "visibility", settings.showLabels ? "visible" : "none"); + map.setLayerZoomRange(LABEL_LAYER_ID, dggalLabelMinZoom(effectiveResolution()), 24); +} + +function refresh(): void { + if (!map) return; + const engine = activeDggrs(); + if (!engine) return; + const resolution = effectiveResolution(); + // The selected zone (and its neighbors/parents) deliberately stays at the + // resolution it was clicked at: in automatic mode a zoom or pan changes the + // rendered grid, but re-deriving the selection would silently replace the + // zone the user identified. Only an explicit settings change re-indexes it + // (see setDggalGridSettings). + try { + const bounds = map.getBounds(); + currentGrid = dggalGridForBounds( + engine, + [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], + resolution, + ); + currentError = null; + } catch (error) { + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = + error instanceof RangeError ? labels.tooManyCells(DGGAL_VIEWPORT_CELL_LIMIT) : String(error); + } + applyStyle(); + (map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid); + updateSelectedSource(); + // Pan/zoom only updates the status line (and auto-resolution readout) — + // rebuilding the whole panel would destroy open color pickers / focused inputs. + updatePanelStatus(); +} + +/** Update the status line (and auto-resolution readout) without recreating controls. */ +function updatePanelStatus(): void { + const status = panelContainer?.querySelector("[data-dggal-status]"); + if (!status) { + if (panelContainer) renderPanel(panelContainer); + return; + } + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + if (settings.autoResolution) { + const shown = String(effectiveResolution()); + const resolution = panelContainer?.querySelector("[data-dggal-resolution]"); + const resolutionValue = panelContainer?.querySelector( + "[data-dggal-resolution-value]", + ); + if (resolution) { + resolution.value = shown; + resolution.title = shown; + } + if (resolutionValue) resolutionValue.textContent = shown; + } +} + +/** + * Every direct parent of a zone, one level up. The JS binding pads the + * result out to the DGGRS's maximum parent count with uninitialized zone + * handles, so entries are filtered to the expected level. + */ +export function dggalParentZones(engine: DggalDggrs, cell: string): string[] { + const zone = engine.getZoneFromTextID(cell); + const level = engine.getZoneLevel(zone); + if (level <= 0) return []; + const parents = new Set(); + for (const parent of engine.getZoneParents(zone)) { + try { + if (engine.getZoneLevel(parent) === level - 1) { + parents.add(engine.getZoneTextID(parent)); + } + } catch { + // Garbage padding entry — skip. + } + } + return [...parents]; +} + +/** Direct children one level down, filtered like {@link dggalParentZones}. */ +function childZones(engine: DggalDggrs, cell: string): string[] { + const zone = engine.getZoneFromTextID(cell); + const level = engine.getZoneLevel(zone); + const children = new Set(); + for (const child of engine.getZoneChildren(zone)) { + try { + if (engine.getZoneLevel(child) === level + 1) { + children.add(engine.getZoneTextID(child)); + } + } catch { + // Garbage padding entry — skip. + } + } + return [...children]; +} + +/** The zone plus its edge/vertex neighbors at the same level. */ +function neighborCells(cell: string): string[] { + const engine = activeDggrs(); + if (!engine) return [cell]; + const zone = engine.getZoneFromTextID(cell); + const level = engine.getZoneLevel(zone); + const ids = new Set([cell]); + for (const { zone: neighbor } of engine.getZoneNeighbors(zone)) { + try { + if (engine.getZoneLevel(neighbor) === level) { + ids.add(engine.getZoneTextID(neighbor)); + } + } catch { + // Garbage padding entry — skip. + } + } + return [...ids]; +} + +function selectedCells(): string[] { + if (!selectedCell) return []; + return settings.includeNeighbors ? neighborCells(selectedCell) : [selectedCell]; +} + +function updateSelectedSource(): void { + const engine = activeDggrs(); + if (!engine) return; + const source = map?.getSource(SELECTED_SOURCE_ID) as GeoJSONSource | undefined; + source?.setData({ + type: "FeatureCollection", + features: selectedCells().map((cell) => dggalZoneFeature(engine, cell)), + }); + const parentsSource = map?.getSource(PARENTS_SOURCE_ID) as GeoJSONSource | undefined; + parentsSource?.setData({ + type: "FeatureCollection", + features: + settings.includeParents && selectedCell + ? dggalParentZones(engine, selectedCell).map((cell) => dggalZoneFeature(engine, cell)) + : [], + }); +} + +function gridCsv(grid: FeatureCollection): string { + const header = "dggal,resolution,center_lat,center_lng"; + const rows = grid.features.map((feature) => { + const p = feature.properties!; + return [p.dggal, p.resolution, p.center_lat, p.center_lng].join(","); + }); + return [header, ...rows].join("\n"); +} + +function fitSelected(): void { + const engine = activeDggrs(); + if (!selectedCell || !appRef || !engine) return; + // The ring stays contiguous across the antimeridian, so min/max longitudes + // never span the world. + const ring = zoneRing(engine, engine.getZoneFromTextID(selectedCell)); + const lons = ring.map(([lng]) => lng); + const lats = ring.map(([, lat]) => lat); + appRef.fitBounds?.([Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)]); +} + +function renderPanel(container: HTMLElement): void { + panelContainer = container; + container.replaceChildren(); + container.style.font = "13px/1.4 system-ui, sans-serif"; + + const section = document.createElement("div"); + section.style.display = "grid"; + section.style.gap = "10px"; + section.style.padding = "12px"; + container.appendChild(section); + + const row = (text: string, input: HTMLElement): void => { + const label = document.createElement("label"); + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.justifyContent = "space-between"; + label.style.gap = "12px"; + const span = document.createElement("span"); + span.textContent = text; + label.append(span, input); + section.appendChild(label); + }; + const button = (text: string, action: () => void, disabled = false): HTMLButtonElement => { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = text; + element.disabled = disabled; + element.style.padding = "6px 8px"; + element.style.border = "1px solid hsl(var(--border))"; + element.style.borderRadius = "6px"; + element.style.background = "hsl(var(--background))"; + element.style.color = "inherit"; + element.style.cursor = disabled ? "not-allowed" : "pointer"; + element.style.opacity = disabled ? "0.5" : "1"; + element.style.transition = "background-color 120ms ease, border-color 120ms ease"; + element.addEventListener("mouseenter", () => { + if (!element.disabled) element.style.background = "hsl(var(--muted))"; + }); + element.addEventListener("mouseleave", () => { + element.style.background = "hsl(var(--background))"; + }); + element.addEventListener("click", action); + return element; + }; + + const typeSelect = document.createElement("select"); + for (const name of DGGAL_TYPE_NAMES) { + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + typeSelect.appendChild(option); + } + typeSelect.value = settings.dggrsType; + typeSelect.style.padding = "4px 6px"; + typeSelect.style.border = "1px solid hsl(var(--border))"; + typeSelect.style.borderRadius = "6px"; + typeSelect.style.background = "hsl(var(--background))"; + typeSelect.style.color = "inherit"; + typeSelect.addEventListener("change", () => + setDggalGridSettings({ dggrsType: typeSelect.value as DggalType }), + ); + row(labels.gridType, typeSelect); + + const autoResolution = document.createElement("input"); + autoResolution.type = "checkbox"; + autoResolution.checked = settings.autoResolution; + autoResolution.addEventListener("change", () => + setDggalGridSettings({ autoResolution: autoResolution.checked }), + ); + row(labels.autoResolution, autoResolution); + + // In automatic mode the slider is a read-only indicator of the zoom-derived + // resolution; updatePanelStatus() keeps it in sync on every moveend. + const shownResolution = effectiveResolution(); + const resolution = document.createElement("input"); + resolution.dataset.dggalResolution = ""; + resolution.type = "range"; + resolution.min = "0"; + resolution.max = String(DGGAL_TYPES[settings.dggrsType]); + resolution.value = String(shownResolution); + resolution.title = String(shownResolution); + resolution.disabled = settings.autoResolution; + resolution.addEventListener("input", () => { + resolution.title = resolution.value; + }); + resolution.addEventListener("change", () => + setDggalGridSettings({ resolution: Number(resolution.value) }), + ); + const resolutionWrap = document.createElement("span"); + resolutionWrap.style.display = "flex"; + resolutionWrap.style.alignItems = "center"; + resolutionWrap.style.gap = "6px"; + resolutionWrap.style.opacity = settings.autoResolution ? "0.6" : "1"; + const resolutionValue = document.createElement("strong"); + resolutionValue.dataset.dggalResolutionValue = ""; + resolutionValue.textContent = String(shownResolution); + resolution.addEventListener("input", () => { + resolutionValue.textContent = resolution.value; + }); + resolutionWrap.append(resolution, resolutionValue); + row(labels.resolution, resolutionWrap); + + for (const [text, key] of [ + [labels.fillColor, "fillColor"], + [labels.lineColor, "lineColor"], + ] as const) { + const input = document.createElement("input"); + input.type = "color"; + input.value = settings[key]; + // `change` (not `input`): setDggalGridSettings re-renders the panel, + // which would destroy the picker mid-drag. + input.addEventListener("change", () => setDggalGridSettings({ [key]: input.value })); + row(text, input); + } + for (const [text, key, min, max, step] of [ + [labels.fillOpacity, "fillOpacity", 0, 1, 0.05], + [labels.lineWidth, "lineWidth", 0.1, 8, 0.1], + ] as const) { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.value = String(settings[key]); + input.style.width = "72px"; + input.addEventListener("change", () => setDggalGridSettings({ [key]: Number(input.value) })); + row(text, input); + } + for (const [text, key] of [ + [labels.showLabels, "showLabels"], + [labels.includeNeighbors, "includeNeighbors"], + [labels.includeParents, "includeParents"], + ] as const) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = settings[key]; + input.addEventListener("change", () => setDggalGridSettings({ [key]: input.checked })); + row(text, input); + } + + const status = document.createElement("div"); + status.dataset.dggalStatus = ""; + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + section.appendChild(status); + + const hint = document.createElement("div"); + hint.textContent = labels.identifyHint; + hint.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(hint); + + const selectedHeading = document.createElement("strong"); + selectedHeading.textContent = labels.selectedCell; + section.appendChild(selectedHeading); + + const engine = activeDggrs(); + if (selectedCell && engine) { + const zone = engine.getZoneFromTextID(selectedCell); + const level = engine.getZoneLevel(zone); + const centroid = engine.getZoneWGS84Centroid(zone); + const details = document.createElement("dl"); + details.style.margin = "0"; + details.style.display = "grid"; + details.style.gridTemplateColumns = "auto 1fr"; + details.style.gap = "4px 10px"; + const addDetail = (term: string, value: string): void => { + const dt = document.createElement("dt"); + dt.textContent = term; + dt.style.color = "var(--muted-foreground, #6b7280)"; + const dd = document.createElement("dd"); + dd.textContent = value; + dd.style.margin = "0"; + dd.style.overflowWrap = "anywhere"; + // Multi-line values (one overlapping parent per line) keep their breaks. + dd.style.whiteSpace = "pre-line"; + details.append(dt, dd); + }; + addDetail("ID", selectedCell); + addDetail(labels.resolution, String(level)); + addDetail( + labels.center, + `${(centroid.lat * DEG_PER_RAD).toFixed(6)}, ${(centroid.lon * DEG_PER_RAD).toFixed(6)}`, + ); + if (level > 0) { + // Every overlapping parent one level up, matching the dashed parent + // outlines on the map. + addDetail(labels.parent, dggalParentZones(engine, selectedCell).join("\n")); + } + if (level < DGGAL_TYPES[settings.dggrsType]) { + addDetail(labels.children, String(childZones(engine, selectedCell).length)); + } + addDetail(labels.neighbors, String(neighborCells(selectedCell).length - 1)); + section.appendChild(details); + } else { + const empty = document.createElement("div"); + empty.textContent = labels.noSelection; + empty.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(empty); + } + + const actions = document.createElement("div"); + actions.style.display = "grid"; + actions.style.gridTemplateColumns = "1fr 1fr"; + actions.style.gap = "6px"; + actions.append( + button( + labels.copyId, + () => { + if (selectedCell) void navigator.clipboard?.writeText(selectedCell); + }, + !selectedCell, + ), + button(labels.zoomToCell, fitSelected, !selectedCell), + button( + labels.addAsLayer, + () => { + if (currentGrid.features.length) { + appRef?.addGeoJsonLayer( + `DGGAL ${settings.dggrsType} (resolution ${effectiveResolution()})`, + currentGrid, + ); + } + }, + currentGrid.features.length === 0, + ), + button( + labels.exportGeoJson, + () => { + appRef?.exportTextFile?.( + `dggal-${settings.dggrsType.toLowerCase()}-r${effectiveResolution()}.geojson`, + JSON.stringify(currentGrid, null, 2), + { + description: "GeoJSON", + extensions: ["geojson"], + mimeType: "application/geo+json", + promptName: true, + }, + ); + }, + currentGrid.features.length === 0, + ), + button( + labels.exportCsv, + () => { + appRef?.exportTextFile?.( + `dggal-${settings.dggrsType.toLowerCase()}-r${effectiveResolution()}.csv`, + gridCsv(currentGrid), + { + description: "CSV", + extensions: ["csv"], + mimeType: "text/csv", + promptName: true, + }, + ); + }, + currentGrid.features.length === 0, + ), + ); + section.appendChild(actions); +} + +function settingsEqual(a: DggalGridSettings, b: DggalGridSettings): boolean { + return Object.keys(a).every( + (key) => a[key as keyof DggalGridSettings] === b[key as keyof DggalGridSettings], + ); +} + +export const maplibreDggalPlugin: GeoLibrePlugin = { + id: DGGAL_PLUGIN_ID, + name: "DGGAL", + version: "1.0.0", + activate: async (app) => { + const activeMap = app.getMap?.(); + if (!activeMap) return false; + const generation = (activationGeneration += 1); + // Await WASM before mutating map/panel state so a deactivate during the + // load cannot race a late attach (leaked listeners / panels / layers). + let engine: DggalEngine; + try { + engine = await loadDggal(); + } catch (error) { + if (generation === activationGeneration) { + currentError = error instanceof Error ? error.message : String(error); + } + return false; + } + if (generation !== activationGeneration) return false; + dggal = engine; + map = activeMap; + appRef = app; + moveHandler = () => scheduleRefresh(); + clickHandler = (event) => { + const engine = activeDggrs(); + if (!engine) return; + const zone = engine.getZoneFromWGS84Centroid(effectiveResolution(), { + lat: event.lngLat.lat * RAD_PER_DEG, + lon: event.lngLat.lng * RAD_PER_DEG, + }); + selectedCell = engine.getZoneTextID(zone); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }; + activeMap.on("moveend", moveHandler); + activeMap.on("click", clickHandler); + unsubscribeBasemap = app.onBasemapChange(() => { + cachedTextFont = null; + activeMap.once("idle", refresh); + }); + unregisterPanel = + app.registerRightPanel?.({ + id: PANEL_ID, + title: () => labels.getTitle?.() ?? labels.title, + dock: "right-of-style", + defaultWidth: 340, + render: (container) => renderPanel(container), + // Closing the panel ends the identify session: drop the clicked zone + // and, with it, the neighbor/parent overlays derived from it. + onClose: () => { + selectedCell = null; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }, + }) ?? null; + refresh(); + app.openRightPanel?.(PANEL_ID); + }, + deactivate: (app) => { + activationGeneration += 1; + cancelScheduledRefresh(); + if (map && moveHandler) map.off("moveend", moveHandler); + if (map && clickHandler) map.off("click", clickHandler); + unsubscribeBasemap?.(); + unregisterPanel?.(); + if (map) removeLayers(map); + moveHandler = null; + clickHandler = null; + unsubscribeBasemap = null; + unregisterPanel = null; + panelContainer = null; + selectedCell = null; + dggrs?.delete(); + dggrs = null; + dggrsType = null; + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = null; + cachedTextFont = null; + dggal = null; + map = null; + appRef = null; + app.closeRightPanel?.(PANEL_ID); + }, + getProjectState: () => + settingsEqual(settings, DEFAULT_DGGAL_GRID_SETTINGS) ? undefined : { ...settings }, + applyProjectState: (_app, state) => { + const next = normalizeDggalGridSettings(state); + if (settingsEqual(settings, next)) return false; + // A selection's zone ID is meaningless under another DGGRS, so a project + // switching grid type drops it. + if (next.dggrsType !== settings.dggrsType) { + selectedCell = null; + } + settings = next; + refresh(); + if (panelContainer) renderPanel(panelContainer); + }, +}; diff --git a/packages/plugins/src/plugins/maplibre-dggrid.ts b/packages/plugins/src/plugins/maplibre-dggrid.ts new file mode 100644 index 000000000..2b60514ad --- /dev/null +++ b/packages/plugins/src/plugins/maplibre-dggrid.ts @@ -0,0 +1,1359 @@ +import type { Feature, FeatureCollection, Polygon, Position } from "geojson"; +import type { GeoJSONSource, Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; +import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; + +export const DGGRID_PLUGIN_ID = "maplibre-dggrid"; + +const PANEL_ID = "geolibre-dggrid-panel"; +const SOURCE_ID = "geolibre-dggrid-grid-source"; +const FILL_LAYER_ID = "geolibre-dggrid-grid-fill"; +const LINE_LAYER_ID = "geolibre-dggrid-grid-line"; +const LABEL_LAYER_ID = "geolibre-dggrid-grid-label"; +const SELECTED_SOURCE_ID = "geolibre-dggrid-selected-source"; +const SELECTED_FILL_LAYER_ID = "geolibre-dggrid-selected-fill"; +const SELECTED_LINE_LAYER_ID = "geolibre-dggrid-selected-line"; +const PARENTS_SOURCE_ID = "geolibre-dggrid-parents-source"; +const PARENTS_LINE_LAYER_ID = "geolibre-dggrid-parents-line"; + +const SELECTED_LINE_WIDTH = 3; + +/** vgrid-maplibre's DGGRID maxResolution default. */ +export const MAX_DGGRID_RESOLUTION = 21; + +/** Prevent a fine resolution over a large viewport from freezing the browser. */ +export const DGGRID_VIEWPORT_CELL_LIMIT = 20_000; + +/** Cell shapes DGGRID supports (the panel labels this "Cell type"). */ +export const DGGRID_TOPOLOGIES = ["HEXAGON", "DIAMOND", "TRIANGLE"] as const; +export type DggridTopology = (typeof DGGRID_TOPOLOGIES)[number]; + +/** Projections placing the icosahedron faces onto the sphere. */ +export const DGGRID_PROJECTIONS = ["ISEA", "FULLER"] as const; +export type DggridProjection = (typeof DGGRID_PROJECTIONS)[number]; + +/** + * Subdivision apertures the engine accepts for hexagons. DIAMOND and + * TRIANGLE grids only exist with aperture 4 — any other value aborts the + * WASM engine with `DgIDGGS::makeRF(): invalid aperture` — so normalization + * pins them to 4. + */ +export const DGGRID_APERTURES = [3, 4, 7] as const; +export type DggridAperture = (typeof DGGRID_APERTURES)[number]; + +/** A DGGS configuration accepted by webdggrid's `setDggs`. */ +export interface DggridConfig { + poleCoordinates: { lat: number; lng: number }; + azimuth: number; + topology: DggridTopology; + projection: DggridProjection; + aperture?: DggridAperture; +} + +/** + * The subset of a webdggrid `Webdggrid` instance this plugin uses. The + * package's root typings only describe the async loader — the full class + * declaration sits behind a subpath export whose declared file casing does + * not exist on disk — so the shape is mirrored here and the loaded instance + * is cast to it. + */ +export interface DggridEngine { + setDggs(dggs: DggridConfig, resolution: number): void; + cellAreaKM(resolution?: number): number; + geoToSequenceNum(coordinates: number[][], resolution?: number): bigint[]; + sequenceNumToGeo(sequenceNum: bigint[], resolution?: number): Position[]; + sequenceNumToGrid(sequenceNum: bigint[], resolution?: number, unwrap?: boolean): Position[][]; + sequenceNumNeighbors(sequenceNum: bigint[], resolution?: number): bigint[][]; + sequenceNumParent(sequenceNum: bigint[], resolution?: number): bigint[]; + sequenceNumAllParents(sequenceNum: bigint[], resolution?: number): bigint[][]; + sequenceNumChildren(sequenceNum: bigint[], resolution?: number): bigint[][]; +} + +/** + * The default DGGS: ISEA4H (icosahedral Snyder equal-area, aperture-4 + * hexagons), vgrid-maplibre's DGGRID default configuration. The panel's Cell + * type / Projection / Aperture pickers derive variations of it. + */ +export const DGGRID_CONFIG: DggridConfig = { + poleCoordinates: { lat: 0, lng: 0 }, + azimuth: 0, + topology: "HEXAGON", + projection: "ISEA", + aperture: 4, +}; + +export interface DggridGridSettings { + /** Cell shape ("Cell type" in the panel). Non-hexagon pins aperture to 4. */ + topology: DggridTopology; + projection: DggridProjection; + aperture: DggridAperture; + /** Derive the resolution from the map zoom instead of the manual slider. */ + autoResolution: boolean; + resolution: number; + fillColor: string; + fillOpacity: number; + lineColor: string; + lineWidth: number; + showLabels: boolean; + includeNeighbors: boolean; + includeParents: boolean; +} + +export const DEFAULT_DGGRID_GRID_SETTINGS: DggridGridSettings = { + topology: "HEXAGON", + projection: "ISEA", + aperture: 4, + autoResolution: true, + // Useful immediately at GeoLibre's default world view: resolution 3 tiles + // the globe with 642 aperture-4 hexagons. + resolution: 3, + fillColor: "#9333ea", + fillOpacity: 0.08, + lineColor: "#9333ea", + lineWidth: 1, + showLabels: true, + includeNeighbors: false, + includeParents: false, +}; + +export interface DggridLabels { + title: string; + getTitle?: () => string; + controlTitle: string; + cellType: string; + topologyHexagon: string; + topologyDiamond: string; + topologyTriangle: string; + projection: string; + aperture: string; + autoResolution: string; + resolution: string; + cellCount: (count: number) => string; + tooManyCells: (limit: number) => string; + fillColor: string; + fillOpacity: string; + lineColor: string; + lineWidth: string; + showLabels: string; + identifyHint: string; + selectedCell: string; + noSelection: string; + copyId: string; + parent: string; + children: string; + neighbors: string; + center: string; + zoomToCell: string; + addAsLayer: string; + exportGeoJson: string; + exportCsv: string; + includeNeighbors: string; + includeParents: string; +} + +export const DEFAULT_DGGRID_LABELS: DggridLabels = { + title: "DGGRID", + controlTitle: "DGGRID settings", + cellType: "Cell type", + topologyHexagon: "Hexagon", + topologyDiamond: "Diamond", + topologyTriangle: "Triangle", + projection: "Projection", + aperture: "Aperture", + autoResolution: "Automatic resolution", + resolution: "Resolution", + cellCount: (count) => `${count.toLocaleString()} cells in view`, + tooManyCells: (limit) => + `This view exceeds the ${limit.toLocaleString()} cell limit. Zoom in or lower the resolution.`, + fillColor: "Fill color", + fillOpacity: "Fill opacity", + lineColor: "Outline color", + lineWidth: "Outline width", + showLabels: "Show cell IDs", + identifyHint: "Click the map to identify a DGGRID cell.", + selectedCell: "Selected cell", + noSelection: "No cell selected", + copyId: "Copy ID", + parent: "Parent(s)", + children: "Children", + neighbors: "Neighbors", + center: "Center", + zoomToCell: "Zoom to cell", + addAsLayer: "Add grid as layer", + exportGeoJson: "Export GeoJSON", + exportCsv: "Export CSV", + includeNeighbors: "Include selected cell neighbors", + includeParents: "Include selected cell parent(s)", +}; + +let labels: DggridLabels = { ...DEFAULT_DGGRID_LABELS }; +let settings: DggridGridSettings = { ...DEFAULT_DGGRID_GRID_SETTINGS }; +let map: MapLibreMap | null = null; +let appRef: GeoLibreAppAPI | null = null; +let dggs: DggridEngine | null = null; +let unregisterPanel: (() => void) | null = null; +let moveHandler: (() => void) | null = null; +let clickHandler: ((event: MapMouseEvent) => void) | null = null; +let unsubscribeBasemap: (() => void) | null = null; +let panelContainer: HTMLElement | null = null; +// A DGGRID sequence number does not encode its resolution (unlike H3/S2/A5 +// ids), so the selection carries both. +let selectedCell: string | null = null; +let selectedResolution = 0; + +let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; +let currentError: string | null = null; +let cachedTextFont: string[] | null = null; +let pendingRefresh: number | null = null; +/** Bumped on every activate/deactivate so an in-flight WASM load cannot attach after teardown. */ +let activationGeneration = 0; + +let dggsPromise: Promise | null = null; + +/** + * Load the webdggrid WASM module once and reuse the instance. Imported + * dynamically so the ~270 kB module stays out of the main bundle until the + * plugin is activated. `Webdggrid.load()` is typed as returning the class but + * resolves to an instance, hence the cast. A failed load clears the cache so + * the next activate can retry. + */ +export function loadDggrid(): Promise { + dggsPromise ??= import("webdggrid") + .then(async (module) => { + const instance = (await module.Webdggrid.load()) as unknown as DggridEngine; + instance.setDggs({ ...DGGRID_CONFIG }, DEFAULT_DGGRID_GRID_SETTINGS.resolution); + return instance; + }) + .catch((error) => { + dggsPromise = null; + throw error; + }); + return dggsPromise; +} + +/** + * Coalesce viewport-driven rebuilds. Inertial pans emit `moveend` in bursts, + * and each rebuild walks up to DGGRID_VIEWPORT_CELL_LIMIT cells on the main + * thread. + */ +function scheduleRefresh(): void { + if (pendingRefresh !== null) return; + pendingRefresh = requestAnimationFrame(() => { + pendingRefresh = null; + refresh(); + }); +} + +function cancelScheduledRefresh(): void { + if (pendingRefresh === null) return; + cancelAnimationFrame(pendingRefresh); + pendingRefresh = null; +} + +/** Reuse a font already present in the active basemap to avoid glyph 404s. */ +function pickTextFont(activeMap: MapLibreMap): string[] { + if (cachedTextFont) return cachedTextFont; + let fallback: string[] | null = null; + for (const layer of activeMap.getStyle()?.layers ?? []) { + if (layer.id === LABEL_LAYER_ID || layer.type !== "symbol") continue; + const font = (layer.layout as { "text-font"?: string[] } | undefined)?.["text-font"]; + if (!Array.isArray(font) || font.length === 0) continue; + if (font.every((name) => !/italic|bold/i.test(name))) return (cachedTextFont = font); + fallback ??= font; + } + return (cachedTextFont = fallback ?? ["Open Sans Regular", "Arial Unicode MS Regular"]); +} + +export function setDggridLabels(next: Partial): void { + labels = { ...labels, ...next }; + if (panelContainer) renderPanel(panelContainer); +} + +export function getDggridGridSettings(): DggridGridSettings { + return { ...settings }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function color(value: unknown, fallback: string): string { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) + ? value.toLowerCase() + : fallback; +} + +/** + * The automatic zoom→resolution rule, mirroring vgrid-maplibre's DGGRIDGrid + * (https://www.npmjs.com/package/vgrid-maplibre): the factor depends on the + * aperture — a higher aperture means fewer resolution steps cover the same + * zoom range — clamped to the valid range. + */ +export function dggridResolutionForZoom(zoom: number, aperture: DggridAperture = 4): number { + const factor = aperture === 3 ? 1.15 : aperture === 7 ? 0.65 : 0.95; + return Math.min(MAX_DGGRID_RESOLUTION, Math.max(0, Math.floor(zoom * factor))); +} + +/** The resolution actually rendered: zoom-derived when automatic, else manual. */ +function effectiveResolution(): number { + return settings.autoResolution && map + ? dggridResolutionForZoom(map.getZoom(), settings.aperture) + : settings.resolution; +} + +/** The DGGS configuration derived from the current settings. */ +function currentConfig(): DggridConfig { + return { + poleCoordinates: { lat: 0, lng: 0 }, + azimuth: 0, + topology: settings.topology, + projection: settings.projection, + aperture: settings.aperture, + }; +} + +export function normalizeDggridGridSettings(value: unknown): DggridGridSettings { + const candidate = (value ?? {}) as Partial; + const topology = DGGRID_TOPOLOGIES.includes(candidate.topology as DggridTopology) + ? (candidate.topology as DggridTopology) + : DEFAULT_DGGRID_GRID_SETTINGS.topology; + const aperture = DGGRID_APERTURES.includes(candidate.aperture as DggridAperture) + ? (candidate.aperture as DggridAperture) + : DEFAULT_DGGRID_GRID_SETTINGS.aperture; + return { + topology, + projection: DGGRID_PROJECTIONS.includes(candidate.projection as DggridProjection) + ? (candidate.projection as DggridProjection) + : DEFAULT_DGGRID_GRID_SETTINGS.projection, + // Diamond and triangle grids only exist with aperture 4 (anything else + // aborts the WASM engine). + aperture: topology === "HEXAGON" ? aperture : 4, + autoResolution: + typeof candidate.autoResolution === "boolean" + ? candidate.autoResolution + : DEFAULT_DGGRID_GRID_SETTINGS.autoResolution, + resolution: Math.round( + clampNumber( + candidate.resolution, + 0, + MAX_DGGRID_RESOLUTION, + DEFAULT_DGGRID_GRID_SETTINGS.resolution, + ), + ), + fillColor: color(candidate.fillColor, DEFAULT_DGGRID_GRID_SETTINGS.fillColor), + fillOpacity: clampNumber(candidate.fillOpacity, 0, 1, DEFAULT_DGGRID_GRID_SETTINGS.fillOpacity), + lineColor: color(candidate.lineColor, DEFAULT_DGGRID_GRID_SETTINGS.lineColor), + lineWidth: clampNumber(candidate.lineWidth, 0.1, 8, DEFAULT_DGGRID_GRID_SETTINGS.lineWidth), + showLabels: + typeof candidate.showLabels === "boolean" + ? candidate.showLabels + : DEFAULT_DGGRID_GRID_SETTINGS.showLabels, + includeNeighbors: + typeof candidate.includeNeighbors === "boolean" + ? candidate.includeNeighbors + : DEFAULT_DGGRID_GRID_SETTINGS.includeNeighbors, + includeParents: + typeof candidate.includeParents === "boolean" + ? candidate.includeParents + : DEFAULT_DGGRID_GRID_SETTINGS.includeParents, + }; +} + +/** + * Avoid thousands of overlapping IDs when the grid is viewed globally. + * Aperture-4 cell area shrinks 4x per resolution step (2x linearly), so one + * zoom level per resolution keeps the on-screen label density roughly + * constant. + */ +export function dggridLabelMinZoom(resolution: number): number { + return Math.min(18, Math.max(2, Math.round(resolution) + 1)); +} + +export function setDggridGridSettings(patch: Partial): void { + const previousResolution = effectiveResolution(); + // Leaving automatic mode adopts the current zoom-derived resolution as the + // fixed one, so the grid stays put instead of jumping to the stale slider. + if (settings.autoResolution && patch.autoResolution === false && patch.resolution === undefined) { + patch = { ...patch, resolution: previousResolution }; + } + const next = normalizeDggridGridSettings({ ...settings, ...patch }); + const topologyChanged = next.topology !== settings.topology; + const configChanged = + topologyChanged || + next.projection !== settings.projection || + next.aperture !== settings.aperture; + // Cell type (topology) changes the lattice entirely — drop the selection + // and its neighbor/parent overlays rather than re-mapping a meaningless id. + // Projection/aperture still re-derive from the cell center while the engine + // holds the outgoing config. + let selectionCenter: Position | null = null; + if (topologyChanged) { + selectedCell = null; + selectedResolution = 0; + } else if (dggs && selectedCell && configChanged) { + selectionCenter = dggs.sequenceNumToGeo([BigInt(selectedCell)], selectedResolution)[0]; + } + settings = next; + const resolution = effectiveResolution(); + if (dggs && selectedCell && configChanged) { + dggs.setDggs(currentConfig(), resolution); + selectedCell = selectionCenter + ? dggs.geoToSequenceNum([selectionCenter], resolution)[0].toString() + : null; + selectedResolution = resolution; + } else if ( + dggs && + selectedCell && + patch.resolution !== undefined && + resolution !== previousResolution + ) { + // Re-derive the selection only for an explicit slider change; toggling + // automatic resolution (like zooming in automatic mode) keeps the clicked + // cell and its neighbors/parents as they are. + const [lng, lat] = dggs.sequenceNumToGeo([BigInt(selectedCell)], selectedResolution)[0]; + selectedCell = dggs.geoToSequenceNum([[lng, lat]], resolution)[0].toString(); + selectedResolution = resolution; + } + // Only the rendered configuration/resolution changes the geometry, so a + // paint/layout-only edit skips rebuilding up to DGGRID_VIEWPORT_CELL_LIMIT + // features. + if (configChanged || resolution !== previousResolution) { + refresh(); + } else { + applyStyle(); + updateSelectedSource(); + } + if (panelContainer) renderPanel(panelContainer); +} + +function normalizeLon(lon: number): number { + let x = lon; + while (x > 180) x -= 360; + while (x < -180) x += 360; + return x; +} + +/** Ray-casting point-in-polygon; ring is [lng, lat][] (closed or not). */ +function pointInRing(lon: number, lat: number, ring: Position[]): boolean { + if (!ring?.length) return false; + const last = ring.length - 1; + const closed = ring[0][0] === ring[last][0] && ring[0][1] === ring[last][1]; + const count = closed ? ring.length - 1 : ring.length; + let inside = false; + for (let i = 0, j = count - 1; i < count; j = i++) { + const xi = ring[i][0]; + const yi = ring[i][1]; + const xj = ring[j][0]; + const yj = ring[j][1]; + if (yj - yi === 0) continue; + const intersect = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; +} + +/** Counter-clockwise test for proper intersection checks. */ +function ccw(ax: number, ay: number, bx: number, by: number, cx: number, cy: number): boolean { + return (cy - ay) * (bx - ax) > (by - ay) * (cx - ax); +} + +function segmentsIntersect( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, + dx: number, + dy: number, +): boolean { + const a = ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy); + const b = ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy); + return a && b; +} + +function segmentCrossesLonLatRect( + ax: number, + ay: number, + bx: number, + by: number, + west: number, + south: number, + east: number, + north: number, +): boolean { + if (Math.max(ax, bx) < west || Math.min(ax, bx) > east) return false; + if (Math.max(ay, by) < south || Math.min(ay, by) > north) return false; + if (ax >= west && ax <= east && ay >= south && ay <= north) return true; + if (bx >= west && bx <= east && by >= south && by <= north) return true; + const edges: Array<[number, number, number, number]> = [ + [west, south, east, south], + [east, south, east, north], + [east, north, west, north], + [west, north, west, south], + ]; + return edges.some(([x1, y1, x2, y2]) => segmentsIntersect(ax, ay, bx, by, x1, y1, x2, y2)); +} + +/** Whether a cell ring intersects the axis-aligned lon/lat rect (planar test). */ +function ringIntersectsBounds( + ring: Position[], + west: number, + south: number, + east: number, + north: number, +): boolean { + if (!ring?.length) return false; + // Unwrap for continuity, then shift into the rect's longitude window so + // vertex / containment / segment tests share one frame (raw ±180 mixes break + // antimeridian cells). + const framed: Position[] = []; + for (const [rawLon, lat] of ring) { + let lon = rawLon; + if (framed.length > 0) { + const reference = framed[0][0]; + while (lon - reference > 180) lon -= 360; + while (lon - reference < -180) lon += 360; + } + framed.push([lon, lat]); + } + const mid = (west + east) / 2; + const shift = Math.round((mid - framed[0][0]) / 360) * 360; + const normalized = + shift === 0 ? framed : framed.map(([lon, lat]) => [lon + shift, lat] as Position); + + const last = normalized.length - 1; + const closed = + normalized[0][0] === normalized[last][0] && normalized[0][1] === normalized[last][1]; + const count = closed ? normalized.length - 1 : normalized.length; + + for (let i = 0; i < count; i += 1) { + const lon = normalized[i][0]; + const lat = normalized[i][1]; + if (lon >= west && lon <= east && lat >= south && lat <= north) return true; + } + for (const [lon, lat] of [ + [west, south], + [west, north], + [east, south], + [east, north], + [(west + east) / 2, (south + north) / 2], + ]) { + if (pointInRing(lon, lat, normalized)) return true; + } + for (let i = 0; i < count; i += 1) { + const j = (i + 1) % count; + if ( + segmentCrossesLonLatRect( + normalized[i][0], + normalized[i][1], + normalized[j][0], + normalized[j][1], + west, + south, + east, + north, + ) + ) { + return true; + } + } + return false; +} + +/** One or two lon/lat rects for viewport bounds (handles the antimeridian). */ +function boundsToRects( + bounds: [number, number, number, number], +): Array<[number, number, number, number]> { + const west = normalizeLon(bounds[0]); + const east = normalizeLon(bounds[2]); + const south = bounds[1]; + const north = bounds[3]; + if (west <= east) return [[west, south, east, north]]; + return [ + [west, south, 180, north], + [-180, south, east, north], + ]; +} + +function closeRing(ring: Position[]): Position[] { + if (ring.length === 0) return ring; + const [firstLng, firstLat] = ring[0]; + const [lastLng, lastLat] = ring[ring.length - 1]; + return firstLng === lastLng && firstLat === lastLat ? ring : [...ring, [firstLng, firstLat]]; +} + +/** Convert a DGGRID cell (sequence number string) to a GeoJSON polygon. */ +export function dggridCellFeature( + engine: DggridEngine, + cell: string, + resolution: number, +): Feature { + const id = BigInt(cell); + // unwrap (the default) keeps antimeridian-crossing rings contiguous, with + // longitudes past ±180 rendered in MapLibre's adjacent world copy. + const ring = closeRing(engine.sequenceNumToGrid([id], resolution)[0]); + const [lng, lat] = engine.sequenceNumToGeo([id], resolution)[0]; + return { + type: "Feature", + id: cell, + properties: { + dggrid: cell, + resolution, + center_lat: lat, + center_lng: lng, + }, + geometry: { type: "Polygon", coordinates: [ring] }, + }; +} + +/** + * Cell ids under a lattice of sample points, mirroring vgrid-maplibre's + * `buildSamplePoints` fallback for grids where neighbor lookup is + * unavailable (TRIANGLE topology). The sample budget grows with resolution + * (the stand-in for vgrid's map zoom), so coverage may miss cells that only + * clip a corner of the view — the tradeoff vgrid accepts. + */ +function sampledCells( + engine: DggridEngine, + west: number, + south: number, + span: number, + latSpan: number, + resolution: number, +): bigint[] { + const maxSamples = Math.min( + 420, + Math.max(72, Math.round(56 + resolution * 22 + resolution * resolution * 0.35)), + ); + const gridCap = Math.min(42, Math.max(10, Math.ceil(Math.sqrt(maxSamples)) + 8)); + const aspect = span / Math.max(1e-9, latSpan); + let cols = Math.ceil(Math.sqrt(maxSamples * aspect)); + cols = Math.min(gridCap, Math.max(3, cols)); + let rows = Math.ceil(maxSamples / cols); + rows = Math.min(gridCap, Math.max(3, rows)); + + const coords: number[][] = []; + for (let i = 0; i <= rows; i += 1) { + const lat = south + (latSpan * i) / rows; + for (let j = 0; j <= cols; j += 1) { + coords.push([normalizeLon(west + (span * j) / cols), lat]); + } + } + const seen = new Set(); + const unique: bigint[] = []; + for (const id of engine.geoToSequenceNum(coords, resolution)) { + const key = id.toString(); + if (!seen.has(key)) { + seen.add(key); + unique.push(id); + } + } + return unique; +} + +/** + * Fill a WGS84 bounding box with DGGRID cells, mirroring vgrid-maplibre's + * DGGRIDGrid: BFS from a seed cell at the viewport center, expanding through + * `sequenceNumNeighbors` while cells intersect the bounds. TRIANGLE grids + * have no neighbor lookup, so they fall back to point sampling. + */ +export function dggridGridForBounds( + engine: DggridEngine, + bounds: [number, number, number, number], + resolution: number, + limit = DGGRID_VIEWPORT_CELL_LIMIT, + config: DggridConfig = DGGRID_CONFIG, +): FeatureCollection { + const [west, southRaw, east, northRaw] = bounds; + const south = Math.max(-89.999999, Math.min(89.999999, southRaw)); + const north = Math.max(-89.999999, Math.min(89.999999, northRaw)); + const span = Math.min(360, east >= west ? east - west : east + 360 - west); + // Reject obviously oversized requests before the BFS materializes the full + // result. ISEA cells are equal-area, so the estimate is close; the exact + // hard cap below remains the final guard. + const radians = Math.PI / 180; + const areaKm2 = + 6371.0088 ** 2 * + span * + radians * + Math.abs(Math.sin(north * radians) - Math.sin(south * radians)); + engine.setDggs({ ...config }, resolution); + if (areaKm2 / engine.cellAreaKM(resolution) > limit * 1.2) { + throw new RangeError(`DGGRID cell limit exceeded: ${limit}`); + } + + let collected: bigint[]; + if (config.topology === "TRIANGLE") { + collected = sampledCells(engine, west, south, span, north - south, resolution); + if (collected.length > limit) { + throw new RangeError(`DGGRID cell limit exceeded: ${limit}`); + } + } else { + const rects = boundsToRects([west, south, east, north]); + const centerLng = normalizeLon(west + span / 2); + const centerLat = (south + north) / 2; + const seed = engine.geoToSequenceNum([[centerLng, centerLat]], resolution)[0]; + + collected = []; + const covered = new Set(); + const queue: bigint[] = [seed]; + let head = 0; + // Guard against pathological loops; generous next to the cell limit + // because the frontier also visits cells that only touch the bounds. + const maxPops = Math.max(100_000, limit * 20); + let pops = 0; + + while (head < queue.length && pops < maxPops) { + pops += 1; + const id = queue[head++]; + const key = id.toString(); + if (covered.has(key)) continue; + covered.add(key); + + const ring = engine.sequenceNumToGrid([id], resolution)[0]; + if (!rects.some(([w, s, e, n]) => ringIntersectsBounds(ring, w, s, e, n))) continue; + + collected.push(id); + if (collected.length > limit) { + throw new RangeError(`DGGRID cell limit exceeded: ${limit}`); + } + + for (const neighbor of engine.sequenceNumNeighbors([id], resolution)[0] ?? []) { + if (!covered.has(neighbor.toString())) queue.push(neighbor); + } + } + if (head < queue.length) { + throw new RangeError(`DGGRID traversal limit exceeded: ${maxPops}`); + } + } + + return { + type: "FeatureCollection", + features: collected.map((id) => dggridCellFeature(engine, id.toString(), resolution)), + }; +} + +function removeLayers(activeMap: MapLibreMap): void { + for (const id of [ + SELECTED_LINE_LAYER_ID, + SELECTED_FILL_LAYER_ID, + PARENTS_LINE_LAYER_ID, + LABEL_LAYER_ID, + LINE_LAYER_ID, + FILL_LAYER_ID, + ]) { + if (activeMap.getLayer(id)) activeMap.removeLayer(id); + } + for (const id of [SELECTED_SOURCE_ID, PARENTS_SOURCE_ID, SOURCE_ID]) { + if (activeMap.getSource(id)) activeMap.removeSource(id); + } +} + +function ensureLayers(): void { + if (!map) return; + if (!map.getSource(SOURCE_ID)) { + map.addSource(SOURCE_ID, { type: "geojson", data: currentGrid }); + map.addLayer({ + id: FILL_LAYER_ID, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": settings.fillColor, "fill-opacity": settings.fillOpacity }, + }); + map.addLayer({ + id: LINE_LAYER_ID, + type: "line", + source: SOURCE_ID, + paint: { "line-color": settings.lineColor, "line-width": settings.lineWidth }, + }); + map.addLayer({ + id: LABEL_LAYER_ID, + type: "symbol", + source: SOURCE_ID, + minzoom: dggridLabelMinZoom(effectiveResolution()), + layout: { + "text-field": ["get", "dggrid"], + "text-font": pickTextFont(map), + "text-size": 10, + visibility: settings.showLabels ? "visible" : "none", + }, + paint: { + "text-color": settings.lineColor, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }, + }); + } + // Added before the selected layers so the selected cell stays on top of its + // (larger, overlapping) parents. + if (!map.getSource(PARENTS_SOURCE_ID)) { + map.addSource(PARENTS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: PARENTS_LINE_LAYER_ID, + type: "line", + source: PARENTS_SOURCE_ID, + paint: { + "line-color": "#f59e0b", + "line-width": SELECTED_LINE_WIDTH * 2, + "line-dasharray": [2, 2], + }, + }); + } + if (!map.getSource(SELECTED_SOURCE_ID)) { + map.addSource(SELECTED_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: SELECTED_FILL_LAYER_ID, + type: "fill", + source: SELECTED_SOURCE_ID, + paint: { "fill-color": "#f59e0b", "fill-opacity": 0.25 }, + }); + map.addLayer({ + id: SELECTED_LINE_LAYER_ID, + type: "line", + source: SELECTED_SOURCE_ID, + paint: { "line-color": "#f59e0b", "line-width": SELECTED_LINE_WIDTH }, + }); + } +} + +function applyStyle(): void { + if (!map) return; + ensureLayers(); + map.setPaintProperty(FILL_LAYER_ID, "fill-color", settings.fillColor); + map.setPaintProperty(FILL_LAYER_ID, "fill-opacity", settings.fillOpacity); + map.setPaintProperty(LINE_LAYER_ID, "line-color", settings.lineColor); + map.setPaintProperty(LINE_LAYER_ID, "line-width", settings.lineWidth); + map.setPaintProperty(LABEL_LAYER_ID, "text-color", settings.lineColor); + map.setLayoutProperty(LABEL_LAYER_ID, "visibility", settings.showLabels ? "visible" : "none"); + map.setLayerZoomRange(LABEL_LAYER_ID, dggridLabelMinZoom(effectiveResolution()), 24); +} + +function refresh(): void { + if (!map || !dggs) return; + const resolution = effectiveResolution(); + // The selected cell (and its neighbors/parents) deliberately stays at the + // resolution it was clicked at: in automatic mode a zoom or pan changes the + // rendered grid, but re-deriving the selection would silently replace the + // cell the user identified. Only an explicit settings change re-indexes it + // (see setDggridGridSettings). + try { + const bounds = map.getBounds(); + currentGrid = dggridGridForBounds( + dggs, + [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], + resolution, + DGGRID_VIEWPORT_CELL_LIMIT, + currentConfig(), + ); + currentError = null; + } catch (error) { + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = + error instanceof RangeError ? labels.tooManyCells(DGGRID_VIEWPORT_CELL_LIMIT) : String(error); + } + applyStyle(); + (map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid); + updateSelectedSource(); + // Pan/zoom only updates the status line (and auto-resolution readout) — + // rebuilding the whole panel would destroy open color pickers / focused inputs. + updatePanelStatus(); +} + +/** Update the status line (and auto-resolution readout) without recreating controls. */ +function updatePanelStatus(): void { + const status = panelContainer?.querySelector("[data-dggrid-status]"); + if (!status) { + if (panelContainer) renderPanel(panelContainer); + return; + } + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + if (settings.autoResolution) { + const shown = String(effectiveResolution()); + const resolution = panelContainer?.querySelector("[data-dggrid-resolution]"); + const resolutionValue = panelContainer?.querySelector( + "[data-dggrid-resolution-value]", + ); + if (resolution) { + resolution.value = shown; + resolution.title = shown; + } + if (resolutionValue) resolutionValue.textContent = shown; + } +} + +/** + * The cell plus its edge neighbors at the same resolution. The engine has no + * neighbor lookup for TRIANGLE grids, so the cell stands alone there. + */ +function neighborCells(cell: string): string[] { + if (!dggs || settings.topology === "TRIANGLE") return [cell]; + const ids = new Set([cell]); + for (const neighbor of dggs.sequenceNumNeighbors([BigInt(cell)], selectedResolution)[0] ?? []) { + ids.add(neighbor.toString()); + } + return [...ids]; +} + +function selectedCells(): string[] { + if (!selectedCell) return []; + return settings.includeNeighbors ? neighborCells(selectedCell) : [selectedCell]; +} + +/** + * Every resolution r-1 cell the selected cell overlaps. Aperture-4 hexagons + * do not nest exactly, so a boundary cell can touch several coarser cells; + * webdggrid's sequenceNumAllParents returns them all, canonical parent first. + */ +function parentCells(cell: string): string[] { + if (!dggs || selectedResolution <= 0) return []; + return (dggs.sequenceNumAllParents([BigInt(cell)], selectedResolution)[0] ?? []).map((id) => + id.toString(), + ); +} + +function updateSelectedSource(): void { + if (!dggs) return; + const source = map?.getSource(SELECTED_SOURCE_ID) as GeoJSONSource | undefined; + source?.setData({ + type: "FeatureCollection", + features: selectedCells().map((cell) => dggridCellFeature(dggs!, cell, selectedResolution)), + }); + const parentsSource = map?.getSource(PARENTS_SOURCE_ID) as GeoJSONSource | undefined; + parentsSource?.setData({ + type: "FeatureCollection", + features: + settings.includeParents && selectedCell + ? parentCells(selectedCell).map((cell) => + dggridCellFeature(dggs!, cell, selectedResolution - 1), + ) + : [], + }); +} + +function gridCsv(grid: FeatureCollection): string { + const header = "dggrid,resolution,center_lat,center_lng"; + const rows = grid.features.map((feature) => { + const p = feature.properties!; + return [p.dggrid, p.resolution, p.center_lat, p.center_lng].join(","); + }); + return [header, ...rows].join("\n"); +} + +function fitSelected(): void { + if (!selectedCell || !appRef || !dggs) return; + // The ring is unwrapped to stay contiguous across the antimeridian, so + // min/max longitudes never span the world. + const ring = dggs.sequenceNumToGrid([BigInt(selectedCell)], selectedResolution)[0]; + const lons = ring.map(([lng]) => lng); + const lats = ring.map(([, lat]) => lat); + appRef.fitBounds?.([Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)]); +} + +function renderPanel(container: HTMLElement): void { + panelContainer = container; + container.replaceChildren(); + container.style.font = "13px/1.4 system-ui, sans-serif"; + + const section = document.createElement("div"); + section.style.display = "grid"; + section.style.gap = "10px"; + section.style.padding = "12px"; + container.appendChild(section); + + const row = (text: string, input: HTMLElement): void => { + const label = document.createElement("label"); + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.justifyContent = "space-between"; + label.style.gap = "12px"; + const span = document.createElement("span"); + span.textContent = text; + label.append(span, input); + section.appendChild(label); + }; + const button = (text: string, action: () => void, disabled = false): HTMLButtonElement => { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = text; + element.disabled = disabled; + element.style.padding = "6px 8px"; + element.style.border = "1px solid hsl(var(--border))"; + element.style.borderRadius = "6px"; + element.style.background = "hsl(var(--background))"; + element.style.color = "inherit"; + element.style.cursor = disabled ? "not-allowed" : "pointer"; + element.style.opacity = disabled ? "0.5" : "1"; + element.style.transition = "background-color 120ms ease, border-color 120ms ease"; + element.addEventListener("mouseenter", () => { + if (!element.disabled) element.style.background = "hsl(var(--muted))"; + }); + element.addEventListener("mouseleave", () => { + element.style.background = "hsl(var(--background))"; + }); + element.addEventListener("click", action); + return element; + }; + + const select = ( + options: Array<[string, string]>, + value: string, + onChange: (value: string) => void, + disabled = false, + ): HTMLSelectElement => { + const element = document.createElement("select"); + for (const [optionValue, optionLabel] of options) { + const option = document.createElement("option"); + option.value = optionValue; + option.textContent = optionLabel; + element.appendChild(option); + } + element.value = value; + element.disabled = disabled; + element.style.padding = "4px 6px"; + element.style.border = "1px solid hsl(var(--border))"; + element.style.borderRadius = "6px"; + element.style.background = "hsl(var(--background))"; + element.style.color = "inherit"; + element.style.opacity = disabled ? "0.6" : "1"; + element.addEventListener("change", () => onChange(element.value)); + return element; + }; + + row( + labels.cellType, + select( + [ + ["HEXAGON", labels.topologyHexagon], + ["DIAMOND", labels.topologyDiamond], + ["TRIANGLE", labels.topologyTriangle], + ], + settings.topology, + (value) => setDggridGridSettings({ topology: value as DggridTopology }), + ), + ); + row( + labels.projection, + select( + DGGRID_PROJECTIONS.map((projection) => [projection, projection]), + settings.projection, + (value) => setDggridGridSettings({ projection: value as DggridProjection }), + ), + ); + // Diamond and triangle grids only exist with aperture 4, so the picker is a + // read-only indicator there (normalization pins the value regardless). + row( + labels.aperture, + select( + DGGRID_APERTURES.map((aperture) => [String(aperture), String(aperture)]), + String(settings.aperture), + (value) => setDggridGridSettings({ aperture: Number(value) as DggridAperture }), + settings.topology !== "HEXAGON", + ), + ); + + const autoResolution = document.createElement("input"); + autoResolution.type = "checkbox"; + autoResolution.checked = settings.autoResolution; + autoResolution.addEventListener("change", () => + setDggridGridSettings({ autoResolution: autoResolution.checked }), + ); + row(labels.autoResolution, autoResolution); + + // In automatic mode the slider is a read-only indicator of the zoom-derived + // resolution; updatePanelStatus() keeps it in sync on every moveend. + const shownResolution = effectiveResolution(); + const resolution = document.createElement("input"); + resolution.dataset.dggridResolution = ""; + resolution.type = "range"; + resolution.min = "0"; + resolution.max = String(MAX_DGGRID_RESOLUTION); + resolution.value = String(shownResolution); + resolution.title = String(shownResolution); + resolution.disabled = settings.autoResolution; + resolution.addEventListener("input", () => { + resolution.title = resolution.value; + }); + resolution.addEventListener("change", () => + setDggridGridSettings({ resolution: Number(resolution.value) }), + ); + const resolutionWrap = document.createElement("span"); + resolutionWrap.style.display = "flex"; + resolutionWrap.style.alignItems = "center"; + resolutionWrap.style.gap = "6px"; + resolutionWrap.style.opacity = settings.autoResolution ? "0.6" : "1"; + const resolutionValue = document.createElement("strong"); + resolutionValue.dataset.dggridResolutionValue = ""; + resolutionValue.textContent = String(shownResolution); + resolution.addEventListener("input", () => { + resolutionValue.textContent = resolution.value; + }); + resolutionWrap.append(resolution, resolutionValue); + row(labels.resolution, resolutionWrap); + + for (const [text, key] of [ + [labels.fillColor, "fillColor"], + [labels.lineColor, "lineColor"], + ] as const) { + const input = document.createElement("input"); + input.type = "color"; + input.value = settings[key]; + // `change` (not `input`): setDggridGridSettings re-renders the panel, + // which would destroy the picker mid-drag. + input.addEventListener("change", () => setDggridGridSettings({ [key]: input.value })); + row(text, input); + } + for (const [text, key, min, max, step] of [ + [labels.fillOpacity, "fillOpacity", 0, 1, 0.05], + [labels.lineWidth, "lineWidth", 0.1, 8, 0.1], + ] as const) { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.value = String(settings[key]); + input.style.width = "72px"; + input.addEventListener("change", () => setDggridGridSettings({ [key]: Number(input.value) })); + row(text, input); + } + for (const [text, key] of [ + [labels.showLabels, "showLabels"], + [labels.includeNeighbors, "includeNeighbors"], + [labels.includeParents, "includeParents"], + ] as const) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = settings[key]; + // Neighbor lookup does not exist for TRIANGLE grids. + input.disabled = key === "includeNeighbors" && settings.topology === "TRIANGLE"; + input.addEventListener("change", () => setDggridGridSettings({ [key]: input.checked })); + row(text, input); + } + + const status = document.createElement("div"); + status.dataset.dggridStatus = ""; + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + section.appendChild(status); + + const hint = document.createElement("div"); + hint.textContent = labels.identifyHint; + hint.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(hint); + + const selectedHeading = document.createElement("strong"); + selectedHeading.textContent = labels.selectedCell; + section.appendChild(selectedHeading); + + if (selectedCell && dggs) { + const id = BigInt(selectedCell); + const [lng, lat] = dggs.sequenceNumToGeo([id], selectedResolution)[0]; + const details = document.createElement("dl"); + details.style.margin = "0"; + details.style.display = "grid"; + details.style.gridTemplateColumns = "auto 1fr"; + details.style.gap = "4px 10px"; + const addDetail = (term: string, value: string): void => { + const dt = document.createElement("dt"); + dt.textContent = term; + dt.style.color = "var(--muted-foreground, #6b7280)"; + const dd = document.createElement("dd"); + dd.textContent = value; + dd.style.margin = "0"; + dd.style.overflowWrap = "anywhere"; + // Multi-line values (one overlapping parent per line) keep their breaks. + dd.style.whiteSpace = "pre-line"; + details.append(dt, dd); + }; + addDetail("ID", selectedCell); + addDetail(labels.resolution, String(selectedResolution)); + addDetail(labels.center, `${lat.toFixed(6)}, ${lng.toFixed(6)}`); + if (selectedResolution > 0) { + // Every overlapping r-1 cell (canonical parent first), matching the + // dashed parent outlines on the map. + addDetail(labels.parent, parentCells(selectedCell).join("\n")); + } + if (selectedResolution < MAX_DGGRID_RESOLUTION) { + addDetail( + labels.children, + String((dggs.sequenceNumChildren([id], selectedResolution)[0] ?? []).length), + ); + } + if (settings.topology !== "TRIANGLE") { + addDetail(labels.neighbors, String(neighborCells(selectedCell).length - 1)); + } + section.appendChild(details); + } else { + const empty = document.createElement("div"); + empty.textContent = labels.noSelection; + empty.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(empty); + } + + const actions = document.createElement("div"); + actions.style.display = "grid"; + actions.style.gridTemplateColumns = "1fr 1fr"; + actions.style.gap = "6px"; + actions.append( + button( + labels.copyId, + () => { + if (selectedCell) void navigator.clipboard?.writeText(selectedCell); + }, + !selectedCell, + ), + button(labels.zoomToCell, fitSelected, !selectedCell), + button( + labels.addAsLayer, + () => { + if (currentGrid.features.length) { + appRef?.addGeoJsonLayer(`DGGRID grid (resolution ${effectiveResolution()})`, currentGrid); + } + }, + currentGrid.features.length === 0, + ), + button( + labels.exportGeoJson, + () => { + appRef?.exportTextFile?.( + `dggrid-r${effectiveResolution()}.geojson`, + JSON.stringify(currentGrid, null, 2), + { + description: "GeoJSON", + extensions: ["geojson"], + mimeType: "application/geo+json", + promptName: true, + }, + ); + }, + currentGrid.features.length === 0, + ), + button( + labels.exportCsv, + () => { + appRef?.exportTextFile?.(`dggrid-r${effectiveResolution()}.csv`, gridCsv(currentGrid), { + description: "CSV", + extensions: ["csv"], + mimeType: "text/csv", + promptName: true, + }); + }, + currentGrid.features.length === 0, + ), + ); + section.appendChild(actions); +} + +function settingsEqual(a: DggridGridSettings, b: DggridGridSettings): boolean { + return Object.keys(a).every( + (key) => a[key as keyof DggridGridSettings] === b[key as keyof DggridGridSettings], + ); +} + +export const maplibreDggridPlugin: GeoLibrePlugin = { + id: DGGRID_PLUGIN_ID, + name: "DGGRID", + version: "1.0.0", + activate: async (app) => { + const activeMap = app.getMap?.(); + if (!activeMap) return false; + const generation = (activationGeneration += 1); + // Await WASM before mutating map/panel state so a deactivate during the + // load cannot race a late attach (leaked listeners / panels / layers). + let engine: DggridEngine; + try { + engine = await loadDggrid(); + } catch (error) { + if (generation === activationGeneration) { + currentError = error instanceof Error ? error.message : String(error); + } + return false; + } + if (generation !== activationGeneration) return false; + dggs = engine; + map = activeMap; + appRef = app; + moveHandler = () => scheduleRefresh(); + clickHandler = (event) => { + if (!dggs) return; + const resolution = effectiveResolution(); + selectedCell = dggs + .geoToSequenceNum([[event.lngLat.lng, event.lngLat.lat]], resolution)[0] + .toString(); + selectedResolution = resolution; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }; + activeMap.on("moveend", moveHandler); + activeMap.on("click", clickHandler); + unsubscribeBasemap = app.onBasemapChange(() => { + cachedTextFont = null; + activeMap.once("idle", refresh); + }); + unregisterPanel = + app.registerRightPanel?.({ + id: PANEL_ID, + title: () => labels.getTitle?.() ?? labels.title, + dock: "right-of-style", + defaultWidth: 340, + render: (container) => renderPanel(container), + // Closing the panel ends the identify session: drop the clicked cell + // and, with it, the neighbor/parent overlays derived from it. + onClose: () => { + selectedCell = null; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }, + }) ?? null; + refresh(); + app.openRightPanel?.(PANEL_ID); + }, + deactivate: (app) => { + activationGeneration += 1; + cancelScheduledRefresh(); + if (map && moveHandler) map.off("moveend", moveHandler); + if (map && clickHandler) map.off("click", clickHandler); + unsubscribeBasemap?.(); + unregisterPanel?.(); + if (map) removeLayers(map); + moveHandler = null; + clickHandler = null; + unsubscribeBasemap = null; + unregisterPanel = null; + panelContainer = null; + selectedCell = null; + selectedResolution = 0; + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = null; + cachedTextFont = null; + dggs = null; + map = null; + appRef = null; + app.closeRightPanel?.(PANEL_ID); + }, + getProjectState: () => + settingsEqual(settings, DEFAULT_DGGRID_GRID_SETTINGS) ? undefined : { ...settings }, + applyProjectState: (_app, state) => { + const next = normalizeDggridGridSettings(state); + if (settingsEqual(settings, next)) return false; + // A selection's sequence number is meaningless under another DGGS + // configuration, so a project switching config drops it. + if ( + next.topology !== settings.topology || + next.projection !== settings.projection || + next.aperture !== settings.aperture + ) { + selectedCell = null; + } + settings = next; + refresh(); + if (panelContainer) renderPanel(panelContainer); + }, +}; diff --git a/packages/plugins/src/plugins/maplibre-geohash.ts b/packages/plugins/src/plugins/maplibre-geohash.ts new file mode 100644 index 000000000..ce2f09fd7 --- /dev/null +++ b/packages/plugins/src/plugins/maplibre-geohash.ts @@ -0,0 +1,856 @@ +import type { Feature, FeatureCollection, Polygon } from "geojson"; +import type { GeoJSONSource, Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; +import geohash from "ngeohash"; +import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; + +export const GEOHASH_PLUGIN_ID = "maplibre-geohash"; + +const PANEL_ID = "geolibre-geohash-panel"; +const SOURCE_ID = "geolibre-geohash-grid-source"; +const FILL_LAYER_ID = "geolibre-geohash-grid-fill"; +const LINE_LAYER_ID = "geolibre-geohash-grid-line"; +const LABEL_LAYER_ID = "geolibre-geohash-grid-label"; +const SELECTED_SOURCE_ID = "geolibre-geohash-selected-source"; +const SELECTED_FILL_LAYER_ID = "geolibre-geohash-selected-fill"; +const SELECTED_LINE_LAYER_ID = "geolibre-geohash-selected-line"; +const PARENT_SOURCE_ID = "geolibre-geohash-parent-source"; +const PARENT_LINE_LAYER_ID = "geolibre-geohash-parent-line"; + +const SELECTED_LINE_WIDTH = 3; + +/** Prevent a fine precision over a large viewport from freezing the browser. */ +export const GEOHASH_VIEWPORT_CELL_LIMIT = 20_000; + +/** Geohash character precision: each step adds 5 bits of lat/lon. */ +export const MIN_GEOHASH_PRECISION = 1; +export const MAX_GEOHASH_PRECISION = 12; + +/** Every geohash cell subdivides into 32 children (base32 alphabet). */ +export const GEOHASH_CHILDREN_PER_CELL = 32; + +export interface GeohashGridSettings { + /** Derive the precision from the map zoom instead of the manual slider. */ + autoResolution: boolean; + /** Character precision ("resolution"): 1–12. */ + resolution: number; + fillColor: string; + fillOpacity: number; + lineColor: string; + lineWidth: number; + showLabels: boolean; + includeNeighbors: boolean; + includeParent: boolean; +} + +export const DEFAULT_GEOHASH_GRID_SETTINGS: GeohashGridSettings = { + autoResolution: true, + // Useful immediately at GeoLibre's default world view: precision 1 tiles + // the globe with 32 forty-five-degree cells. + resolution: 1, + fillColor: "#7c3aed", + fillOpacity: 0.08, + lineColor: "#7c3aed", + lineWidth: 1, + showLabels: true, + includeNeighbors: false, + includeParent: false, +}; + +export interface GeohashLabels { + title: string; + getTitle?: () => string; + controlTitle: string; + autoResolution: string; + resolution: string; + cellCount: (count: number) => string; + tooManyCells: (limit: number) => string; + fillColor: string; + fillOpacity: string; + lineColor: string; + lineWidth: string; + showLabels: string; + identifyHint: string; + selectedCell: string; + noSelection: string; + copyId: string; + parent: string; + children: string; + neighbors: string; + center: string; + zoomToCell: string; + addAsLayer: string; + exportGeoJson: string; + exportCsv: string; + includeNeighbors: string; + includeParent: string; +} + +export const DEFAULT_GEOHASH_LABELS: GeohashLabels = { + title: "Geohash", + controlTitle: "Geohash settings", + autoResolution: "Automatic resolution", + resolution: "Precision", + cellCount: (count) => `${count.toLocaleString()} cells in view`, + tooManyCells: (limit) => + `This view exceeds the ${limit.toLocaleString()} cell limit. Zoom in or lower the precision.`, + fillColor: "Fill color", + fillOpacity: "Fill opacity", + lineColor: "Outline color", + lineWidth: "Outline width", + showLabels: "Show cell IDs", + identifyHint: "Click the map to identify a Geohash cell.", + selectedCell: "Selected cell", + noSelection: "No cell selected", + copyId: "Copy ID", + parent: "Parent", + children: "Children", + neighbors: "Neighbors", + center: "Center", + zoomToCell: "Zoom to cell", + addAsLayer: "Add grid as layer", + exportGeoJson: "Export GeoJSON", + exportCsv: "Export CSV", + includeNeighbors: "Include selected cell neighbors", + includeParent: "Include selected cell parent", +}; + +let labels: GeohashLabels = { ...DEFAULT_GEOHASH_LABELS }; +let settings: GeohashGridSettings = { ...DEFAULT_GEOHASH_GRID_SETTINGS }; +let map: MapLibreMap | null = null; +let appRef: GeoLibreAppAPI | null = null; +let unregisterPanel: (() => void) | null = null; +let moveHandler: (() => void) | null = null; +let clickHandler: ((event: MapMouseEvent) => void) | null = null; +let unsubscribeBasemap: (() => void) | null = null; +let panelContainer: HTMLElement | null = null; +/** The selected cell's geohash (its length is its precision). */ +let selectedCell: string | null = null; + +let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; +let currentError: string | null = null; +let cachedTextFont: string[] | null = null; +let pendingRefresh: number | null = null; + +/** + * Coalesce viewport-driven rebuilds. Inertial pans emit `moveend` in bursts, + * and each rebuild materializes up to GEOHASH_VIEWPORT_CELL_LIMIT cells on the + * main thread. + */ +function scheduleRefresh(): void { + if (pendingRefresh !== null) return; + pendingRefresh = requestAnimationFrame(() => { + pendingRefresh = null; + refresh(); + }); +} + +function cancelScheduledRefresh(): void { + if (pendingRefresh === null) return; + cancelAnimationFrame(pendingRefresh); + pendingRefresh = null; +} + +/** Reuse a font already present in the active basemap to avoid glyph 404s. */ +function pickTextFont(activeMap: MapLibreMap): string[] { + if (cachedTextFont) return cachedTextFont; + let fallback: string[] | null = null; + for (const layer of activeMap.getStyle()?.layers ?? []) { + if (layer.id === LABEL_LAYER_ID || layer.type !== "symbol") continue; + const font = (layer.layout as { "text-font"?: string[] } | undefined)?.["text-font"]; + if (!Array.isArray(font) || font.length === 0) continue; + if (font.every((name) => !/italic|bold/i.test(name))) return (cachedTextFont = font); + fallback ??= font; + } + return (cachedTextFont = fallback ?? ["Open Sans Regular", "Arial Unicode MS Regular"]); +} + +export function setGeohashLabels(next: Partial): void { + labels = { ...labels, ...next }; + if (panelContainer) renderPanel(panelContainer); +} + +export function getGeohashGridSettings(): GeohashGridSettings { + return { ...settings }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function color(value: unknown, fallback: string): string { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) + ? value.toLowerCase() + : fallback; +} + +/** Wrap a longitude into (−180, 180] so ngeohash's clamp-to-range encode is well-defined. */ +function wrapLongitude(lng: number): number { + const wrapped = ((((lng + 180) % 360) + 360) % 360) - 180; + // MapLibre's continuous world uses 180 as the east edge of the base copy; + // ngeohash treats 180 as the west edge of that same cell, so keep −180. + return wrapped === -180 ? -180 : wrapped === 180 ? -180 : wrapped; +} + +/** + * The automatic zoom→precision rule, mirroring vgrid-maplibre's GeohashGrid + * (https://www.npmjs.com/package/vgrid-maplibre): `floor(zoom * 0.45)`, + * clamped to the usable precision range. (vgrid's published clamp starts at + * 0, but precision 0 is not a valid geohash, so we raise the floor to 1.) + */ +export function geohashResolutionForZoom(zoom: number): number { + return Math.min(MAX_GEOHASH_PRECISION, Math.max(MIN_GEOHASH_PRECISION, Math.floor(zoom * 0.45))); +} + +/** The precision actually rendered: zoom-derived when automatic, else manual. */ +function effectiveResolution(): number { + return settings.autoResolution && map + ? geohashResolutionForZoom(map.getZoom()) + : settings.resolution; +} + +export function normalizeGeohashGridSettings(value: unknown): GeohashGridSettings { + const candidate = (value ?? {}) as Partial; + return { + autoResolution: + typeof candidate.autoResolution === "boolean" + ? candidate.autoResolution + : DEFAULT_GEOHASH_GRID_SETTINGS.autoResolution, + resolution: Math.round( + clampNumber( + candidate.resolution, + MIN_GEOHASH_PRECISION, + MAX_GEOHASH_PRECISION, + DEFAULT_GEOHASH_GRID_SETTINGS.resolution, + ), + ), + fillColor: color(candidate.fillColor, DEFAULT_GEOHASH_GRID_SETTINGS.fillColor), + fillOpacity: clampNumber( + candidate.fillOpacity, + 0, + 1, + DEFAULT_GEOHASH_GRID_SETTINGS.fillOpacity, + ), + lineColor: color(candidate.lineColor, DEFAULT_GEOHASH_GRID_SETTINGS.lineColor), + lineWidth: clampNumber(candidate.lineWidth, 0.1, 8, DEFAULT_GEOHASH_GRID_SETTINGS.lineWidth), + showLabels: + typeof candidate.showLabels === "boolean" + ? candidate.showLabels + : DEFAULT_GEOHASH_GRID_SETTINGS.showLabels, + includeNeighbors: + typeof candidate.includeNeighbors === "boolean" + ? candidate.includeNeighbors + : DEFAULT_GEOHASH_GRID_SETTINGS.includeNeighbors, + includeParent: + typeof candidate.includeParent === "boolean" + ? candidate.includeParent + : DEFAULT_GEOHASH_GRID_SETTINGS.includeParent, + }; +} + +/** + * Avoid thousands of overlapping IDs when the grid is viewed globally. + * Precision grows roughly every 2¼ zoom levels (`1 / 0.45`), so labels appear + * about one step below the zoom that would pick the next finer precision. + */ +export function geohashLabelMinZoom(precision: number): number { + return Math.min(22, Math.max(2, Math.round(precision / 0.45) - 1)); +} + +export function setGeohashGridSettings(patch: Partial): void { + const previousResolution = effectiveResolution(); + // Leaving automatic mode adopts the current zoom-derived precision as the + // fixed one, so the grid stays put instead of jumping to the stale slider. + if (settings.autoResolution && patch.autoResolution === false && patch.resolution === undefined) { + patch = { ...patch, resolution: previousResolution }; + } + settings = normalizeGeohashGridSettings({ ...settings, ...patch }); + const resolution = effectiveResolution(); + // Re-derive the selection only for an explicit precision change; toggling + // automatic resolution (like zooming in automatic mode) keeps the clicked + // cell and its neighbors/parent as they are. + if (selectedCell && patch.resolution !== undefined && resolution !== previousResolution) { + const { latitude, longitude } = geohash.decode(selectedCell); + selectedCell = geohash.encode(latitude, longitude, resolution); + } + // Only the rendered precision changes the geometry, so a paint/layout-only + // edit skips rebuilding up to GEOHASH_VIEWPORT_CELL_LIMIT features. + if (resolution !== previousResolution) { + refresh(); + } else { + applyStyle(); + updateSelectedSource(); + } + if (panelContainer) renderPanel(panelContainer); +} + +/** + * Convert a geohash to a GeoJSON polygon with export attributes. `lngOffset` + * (a multiple of 360) places the ring in the world copy a dateline-crossing + * viewport is actually looking at — ngeohash always returns normalized + * longitudes in (−180, 180]. + */ +export function geohashCellFeature(cell: string, lngOffset = 0): Feature { + const [south, west, north, east] = geohash.decode_bbox(cell); + const { latitude, longitude } = geohash.decode(cell); + return { + type: "Feature", + id: cell, + properties: { + geohash: cell, + resolution: cell.length, + center_lat: latitude, + center_lng: longitude, + }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [west + lngOffset, south], + [east + lngOffset, south], + [east + lngOffset, north], + [west + lngOffset, north], + [west + lngOffset, south], + ], + ], + }, + }; +} + +/** + * Fill a WGS84 bounding box with geohash cells, mirroring vgrid-maplibre's + * GeohashGrid: cells are an axis-aligned lat/lon grid, so the fill walks the + * rows and columns intersecting the box. Longitudes may run past ±180 + * (MapLibre's continuous bounds); each cell is encoded from its wrapped + * centroid but drawn in the viewport's world copy. + */ +export function geohashGridForBounds( + bounds: [number, number, number, number], + precision: number, + limit = GEOHASH_VIEWPORT_CELL_LIMIT, +): FeatureCollection { + let [west, south, east, north] = bounds; + south = Math.max(-90, Math.min(90, south)); + north = Math.max(-90, Math.min(90, north)); + if (east - west >= 360) { + west = -180; + east = 180; + } + // Odd/even precisions swap which axis gets the extra bit, so measure both + // dimensions from a reference cell rather than hard-coding the table. + const [refSouth, refWest, refNorth, refEast] = geohash.decode_bbox( + geohash.encode(0, 0, precision), + ); + const latHeight = refNorth - refSouth; + const lngWidth = refEast - refWest; + + if (((east - west) / lngWidth) * ((north - south) / latHeight) > limit * 1.2) { + throw new RangeError(`Geohash cell limit exceeded: ${limit}`); + } + + const startLng = Math.floor((west + 180) / lngWidth) * lngWidth - 180; + const startLat = Math.max(-90, Math.floor((south + 90) / latHeight) * latHeight - 90); + + const features: Feature[] = []; + // Floating-point walks of the grid can land on the same cell twice near + // cell boundaries; key by (id, world copy) so a dateline-crossing view can + // still draw the same hash in two adjacent copies. + const seen = new Set(); + for (let lng = startLng; lng < east; lng += lngWidth) { + for (let lat = startLat; lat < north && lat < 90; lat += latHeight) { + const centerLng = lng + lngWidth / 2; + const centerLat = lat + latHeight / 2; + const cell = geohash.encode(centerLat, wrapLongitude(centerLng), precision); + const [, cellWest, , cellEast] = geohash.decode_bbox(cell); + const lngOffset = Math.round((centerLng - (cellWest + cellEast) / 2) / 360) * 360; + const key = `${cell}@${lngOffset}`; + if (seen.has(key)) continue; + seen.add(key); + features.push(geohashCellFeature(cell, lngOffset)); + if (features.length > limit) { + throw new RangeError(`Geohash cell limit exceeded: ${limit}`); + } + } + } + return { type: "FeatureCollection", features }; +} + +/** + * Geohash is a strictly nested grid, so a cell has exactly one parent: the + * hash with its last character removed. + */ +export function geohashParentCell(cell: string): string | null { + return cell.length > MIN_GEOHASH_PRECISION ? cell.slice(0, -1) : null; +} + +/** + * The cell plus its surrounding grid cells. `ngeohash.neighbors` returns the + * 8-adjacent set and can emit duplicates near the poles, so we dedupe. + */ +export function geohashNeighborCells(cell: string): string[] { + return [...new Set([cell, ...geohash.neighbors(cell)])]; +} + +function removeLayers(activeMap: MapLibreMap): void { + for (const id of [ + SELECTED_LINE_LAYER_ID, + SELECTED_FILL_LAYER_ID, + PARENT_LINE_LAYER_ID, + LABEL_LAYER_ID, + LINE_LAYER_ID, + FILL_LAYER_ID, + ]) { + if (activeMap.getLayer(id)) activeMap.removeLayer(id); + } + for (const id of [SELECTED_SOURCE_ID, PARENT_SOURCE_ID, SOURCE_ID]) { + if (activeMap.getSource(id)) activeMap.removeSource(id); + } +} + +function ensureLayers(): void { + if (!map) return; + if (!map.getSource(SOURCE_ID)) { + map.addSource(SOURCE_ID, { type: "geojson", data: currentGrid }); + map.addLayer({ + id: FILL_LAYER_ID, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": settings.fillColor, "fill-opacity": settings.fillOpacity }, + }); + map.addLayer({ + id: LINE_LAYER_ID, + type: "line", + source: SOURCE_ID, + paint: { "line-color": settings.lineColor, "line-width": settings.lineWidth }, + }); + map.addLayer({ + id: LABEL_LAYER_ID, + type: "symbol", + source: SOURCE_ID, + minzoom: geohashLabelMinZoom(effectiveResolution()), + layout: { + "text-field": ["get", "geohash"], + "text-font": pickTextFont(map), + "text-size": 10, + visibility: settings.showLabels ? "visible" : "none", + }, + paint: { + "text-color": settings.lineColor, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }, + }); + } + // Added before the selected layers so the selected cell stays on top of its + // (larger, surrounding) parent. + if (!map.getSource(PARENT_SOURCE_ID)) { + map.addSource(PARENT_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: PARENT_LINE_LAYER_ID, + type: "line", + source: PARENT_SOURCE_ID, + paint: { + "line-color": "#f59e0b", + "line-width": SELECTED_LINE_WIDTH * 2, + "line-dasharray": [2, 2], + }, + }); + } + if (!map.getSource(SELECTED_SOURCE_ID)) { + map.addSource(SELECTED_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: SELECTED_FILL_LAYER_ID, + type: "fill", + source: SELECTED_SOURCE_ID, + paint: { "fill-color": "#f59e0b", "fill-opacity": 0.25 }, + }); + map.addLayer({ + id: SELECTED_LINE_LAYER_ID, + type: "line", + source: SELECTED_SOURCE_ID, + paint: { "line-color": "#f59e0b", "line-width": SELECTED_LINE_WIDTH }, + }); + } +} + +function applyStyle(): void { + if (!map) return; + ensureLayers(); + map.setPaintProperty(FILL_LAYER_ID, "fill-color", settings.fillColor); + map.setPaintProperty(FILL_LAYER_ID, "fill-opacity", settings.fillOpacity); + map.setPaintProperty(LINE_LAYER_ID, "line-color", settings.lineColor); + map.setPaintProperty(LINE_LAYER_ID, "line-width", settings.lineWidth); + map.setPaintProperty(LABEL_LAYER_ID, "text-color", settings.lineColor); + map.setLayoutProperty(LABEL_LAYER_ID, "visibility", settings.showLabels ? "visible" : "none"); + map.setLayerZoomRange(LABEL_LAYER_ID, geohashLabelMinZoom(effectiveResolution()), 24); +} + +function refresh(): void { + if (!map) return; + const resolution = effectiveResolution(); + // The selected cell (and its neighbors/parent) deliberately stays at the + // precision it was clicked at: in automatic mode a zoom or pan changes the + // rendered grid, but re-deriving the selection would silently replace the + // cell the user identified. Only an explicit settings change re-indexes it + // (see setGeohashGridSettings). + try { + const bounds = map.getBounds(); + currentGrid = geohashGridForBounds( + [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], + resolution, + ); + currentError = null; + } catch (error) { + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = + error instanceof RangeError + ? labels.tooManyCells(GEOHASH_VIEWPORT_CELL_LIMIT) + : String(error); + } + applyStyle(); + (map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); +} + +function selectedCells(): string[] { + if (!selectedCell) return []; + return settings.includeNeighbors ? geohashNeighborCells(selectedCell) : [selectedCell]; +} + +function updateSelectedSource(): void { + const source = map?.getSource(SELECTED_SOURCE_ID) as GeoJSONSource | undefined; + source?.setData({ + type: "FeatureCollection", + features: selectedCells().map((cell) => geohashCellFeature(cell)), + }); + const parent = settings.includeParent && selectedCell ? geohashParentCell(selectedCell) : null; + const parentSource = map?.getSource(PARENT_SOURCE_ID) as GeoJSONSource | undefined; + parentSource?.setData({ + type: "FeatureCollection", + features: parent ? [geohashCellFeature(parent)] : [], + }); +} + +function gridCsv(grid: FeatureCollection): string { + const header = "geohash,resolution,center_lat,center_lng"; + const rows = grid.features.map((feature) => { + const p = feature.properties!; + return [p.geohash, p.resolution, p.center_lat, p.center_lng].join(","); + }); + return [header, ...rows].join("\n"); +} + +function fitSelected(): void { + if (!selectedCell || !appRef) return; + const [south, west, north, east] = geohash.decode_bbox(selectedCell); + appRef.fitBounds?.([west, south, east, north]); +} + +function renderPanel(container: HTMLElement): void { + panelContainer = container; + container.replaceChildren(); + container.style.font = "13px/1.4 system-ui, sans-serif"; + + const section = document.createElement("div"); + section.style.display = "grid"; + section.style.gap = "10px"; + section.style.padding = "12px"; + container.appendChild(section); + + const row = (text: string, input: HTMLElement): void => { + const label = document.createElement("label"); + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.justifyContent = "space-between"; + label.style.gap = "12px"; + const span = document.createElement("span"); + span.textContent = text; + label.append(span, input); + section.appendChild(label); + }; + const button = (text: string, action: () => void, disabled = false): HTMLButtonElement => { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = text; + element.disabled = disabled; + element.style.padding = "6px 8px"; + element.style.border = "1px solid hsl(var(--border))"; + element.style.borderRadius = "6px"; + element.style.background = "hsl(var(--background))"; + element.style.color = "inherit"; + element.style.cursor = disabled ? "not-allowed" : "pointer"; + element.style.opacity = disabled ? "0.5" : "1"; + element.style.transition = "background-color 120ms ease, border-color 120ms ease"; + element.addEventListener("mouseenter", () => { + if (!element.disabled) element.style.background = "hsl(var(--muted))"; + }); + element.addEventListener("mouseleave", () => { + element.style.background = "hsl(var(--background))"; + }); + element.addEventListener("click", action); + return element; + }; + + const autoResolution = document.createElement("input"); + autoResolution.type = "checkbox"; + autoResolution.checked = settings.autoResolution; + autoResolution.addEventListener("change", () => + setGeohashGridSettings({ autoResolution: autoResolution.checked }), + ); + row(labels.autoResolution, autoResolution); + + // In automatic mode the slider becomes a read-only indicator of the + // zoom-derived precision; refresh() re-renders the panel on every moveend, + // so it tracks zoom gestures. + const shownResolution = effectiveResolution(); + const resolution = document.createElement("input"); + resolution.type = "range"; + resolution.min = String(MIN_GEOHASH_PRECISION); + resolution.max = String(MAX_GEOHASH_PRECISION); + resolution.value = String(shownResolution); + resolution.title = String(shownResolution); + resolution.disabled = settings.autoResolution; + resolution.addEventListener("input", () => { + resolution.title = resolution.value; + }); + resolution.addEventListener("change", () => + setGeohashGridSettings({ resolution: Number(resolution.value) }), + ); + const resolutionWrap = document.createElement("span"); + resolutionWrap.style.display = "flex"; + resolutionWrap.style.alignItems = "center"; + resolutionWrap.style.gap = "6px"; + resolutionWrap.style.opacity = settings.autoResolution ? "0.6" : "1"; + const resolutionValue = document.createElement("strong"); + resolutionValue.textContent = String(shownResolution); + resolution.addEventListener("input", () => { + resolutionValue.textContent = resolution.value; + }); + resolutionWrap.append(resolution, resolutionValue); + row(labels.resolution, resolutionWrap); + + for (const [text, key] of [ + [labels.fillColor, "fillColor"], + [labels.lineColor, "lineColor"], + ] as const) { + const input = document.createElement("input"); + input.type = "color"; + input.value = settings[key]; + // `change` (not `input`): setGeohashGridSettings re-renders the panel, + // which would destroy the picker mid-drag. + input.addEventListener("change", () => setGeohashGridSettings({ [key]: input.value })); + row(text, input); + } + for (const [text, key, min, max, step] of [ + [labels.fillOpacity, "fillOpacity", 0, 1, 0.05], + [labels.lineWidth, "lineWidth", 0.1, 8, 0.1], + ] as const) { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.value = String(settings[key]); + input.style.width = "72px"; + input.addEventListener("change", () => setGeohashGridSettings({ [key]: Number(input.value) })); + row(text, input); + } + for (const [text, key] of [ + [labels.showLabels, "showLabels"], + [labels.includeNeighbors, "includeNeighbors"], + [labels.includeParent, "includeParent"], + ] as const) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = settings[key]; + input.addEventListener("change", () => setGeohashGridSettings({ [key]: input.checked })); + row(text, input); + } + + const status = document.createElement("div"); + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + section.appendChild(status); + + const hint = document.createElement("div"); + hint.textContent = labels.identifyHint; + hint.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(hint); + + const selectedHeading = document.createElement("strong"); + selectedHeading.textContent = labels.selectedCell; + section.appendChild(selectedHeading); + + if (selectedCell) { + const { latitude, longitude } = geohash.decode(selectedCell); + const details = document.createElement("dl"); + details.style.margin = "0"; + details.style.display = "grid"; + details.style.gridTemplateColumns = "auto 1fr"; + details.style.gap = "4px 10px"; + const addDetail = (term: string, value: string): void => { + const dt = document.createElement("dt"); + dt.textContent = term; + dt.style.color = "var(--muted-foreground, #6b7280)"; + const dd = document.createElement("dd"); + dd.textContent = value; + dd.style.margin = "0"; + dd.style.overflowWrap = "anywhere"; + details.append(dt, dd); + }; + addDetail("ID", selectedCell); + addDetail(labels.resolution, String(selectedCell.length)); + addDetail(labels.center, `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`); + const parent = geohashParentCell(selectedCell); + if (parent) addDetail(labels.parent, parent); + if (selectedCell.length < MAX_GEOHASH_PRECISION) { + addDetail(labels.children, String(GEOHASH_CHILDREN_PER_CELL)); + } + addDetail(labels.neighbors, String(geohashNeighborCells(selectedCell).length - 1)); + section.appendChild(details); + } else { + const empty = document.createElement("div"); + empty.textContent = labels.noSelection; + empty.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(empty); + } + + const actions = document.createElement("div"); + actions.style.display = "grid"; + actions.style.gridTemplateColumns = "1fr 1fr"; + actions.style.gap = "6px"; + actions.append( + button( + labels.copyId, + () => { + if (selectedCell) void navigator.clipboard?.writeText(selectedCell); + }, + !selectedCell, + ), + button(labels.zoomToCell, fitSelected, !selectedCell), + button( + labels.addAsLayer, + () => { + if (currentGrid.features.length) { + appRef?.addGeoJsonLayer(`Geohash (precision ${effectiveResolution()})`, currentGrid); + } + }, + currentGrid.features.length === 0, + ), + button( + labels.exportGeoJson, + () => { + appRef?.exportTextFile?.( + `geohash-p${effectiveResolution()}.geojson`, + JSON.stringify(currentGrid, null, 2), + { + description: "GeoJSON", + extensions: ["geojson"], + mimeType: "application/geo+json", + promptName: true, + }, + ); + }, + currentGrid.features.length === 0, + ), + button( + labels.exportCsv, + () => { + appRef?.exportTextFile?.(`geohash-p${effectiveResolution()}.csv`, gridCsv(currentGrid), { + description: "CSV", + extensions: ["csv"], + mimeType: "text/csv", + promptName: true, + }); + }, + currentGrid.features.length === 0, + ), + ); + section.appendChild(actions); +} + +function settingsEqual(a: GeohashGridSettings, b: GeohashGridSettings): boolean { + return Object.keys(a).every( + (key) => a[key as keyof GeohashGridSettings] === b[key as keyof GeohashGridSettings], + ); +} + +export const maplibreGeohashPlugin: GeoLibrePlugin = { + id: GEOHASH_PLUGIN_ID, + name: "Geohash", + version: "1.0.0", + activate: (app) => { + const activeMap = app.getMap?.(); + if (!activeMap) return false; + map = activeMap; + appRef = app; + moveHandler = () => scheduleRefresh(); + clickHandler = (event) => { + selectedCell = geohash.encode( + event.lngLat.lat, + wrapLongitude(event.lngLat.lng), + effectiveResolution(), + ); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }; + activeMap.on("moveend", moveHandler); + activeMap.on("click", clickHandler); + unsubscribeBasemap = app.onBasemapChange(() => { + cachedTextFont = null; + activeMap.once("idle", refresh); + }); + unregisterPanel = + app.registerRightPanel?.({ + id: PANEL_ID, + title: () => labels.getTitle?.() ?? labels.title, + dock: "right-of-style", + defaultWidth: 340, + render: (container) => renderPanel(container), + // Closing the panel ends the identify session: drop the clicked cell + // and, with it, the neighbor/parent overlays derived from it. + onClose: () => { + selectedCell = null; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }, + }) ?? null; + refresh(); + app.openRightPanel?.(PANEL_ID); + }, + deactivate: (app) => { + cancelScheduledRefresh(); + if (map && moveHandler) map.off("moveend", moveHandler); + if (map && clickHandler) map.off("click", clickHandler); + unsubscribeBasemap?.(); + unregisterPanel?.(); + if (map) removeLayers(map); + moveHandler = null; + clickHandler = null; + unsubscribeBasemap = null; + unregisterPanel = null; + panelContainer = null; + selectedCell = null; + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = null; + cachedTextFont = null; + map = null; + appRef = null; + app.closeRightPanel?.(PANEL_ID); + }, + getProjectState: () => + settingsEqual(settings, DEFAULT_GEOHASH_GRID_SETTINGS) ? undefined : { ...settings }, + applyProjectState: (_app, state) => { + const next = normalizeGeohashGridSettings(state); + if (settingsEqual(settings, next)) return false; + settings = next; + refresh(); + }, +}; diff --git a/packages/plugins/src/plugins/maplibre-h3.ts b/packages/plugins/src/plugins/maplibre-h3.ts index 8eb6bbd91..dec4131b4 100644 --- a/packages/plugins/src/plugins/maplibre-h3.ts +++ b/packages/plugins/src/plugins/maplibre-h3.ts @@ -1,4 +1,4 @@ -import type { Feature, FeatureCollection, MultiPolygon, Polygon } from "geojson"; +import type { Feature, FeatureCollection, Polygon } from "geojson"; import { cellToBoundary, cellToChildren, @@ -15,6 +15,15 @@ import { import type { GeoJSONSource, Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; +/** + * The icosahedron H3 projects onto, as densified great-circle edge lines. + * Fetched by MapLibre when the layer is first added; the Tauri CSP's blanket + * `https:` connect-src already allows the host. Offline, the overlay simply + * stays empty. + */ +const ICOSAHEDRON_GEOJSON_URL = + "https://raw.githubusercontent.com/opengeoshub/vgrid-maplibre/main/H3/icosahedron.geojson"; + export const H3_PLUGIN_ID = "maplibre-h3-grid"; const PANEL_ID = "geolibre-h3-panel"; @@ -25,11 +34,19 @@ const LABEL_LAYER_ID = "geolibre-h3-grid-label"; const SELECTED_SOURCE_ID = "geolibre-h3-selected-source"; const SELECTED_FILL_LAYER_ID = "geolibre-h3-selected-fill"; const SELECTED_LINE_LAYER_ID = "geolibre-h3-selected-line"; +const PARENTS_SOURCE_ID = "geolibre-h3-parents-source"; +const PARENTS_LINE_LAYER_ID = "geolibre-h3-parents-line"; +const ICOSAHEDRON_SOURCE_ID = "geolibre-h3-icosahedron-source"; +const ICOSAHEDRON_LINE_LAYER_ID = "geolibre-h3-icosahedron-line"; + +const SELECTED_LINE_WIDTH = 3; /** Prevent a fine resolution over a large viewport from freezing the browser. */ export const H3_VIEWPORT_CELL_LIMIT = 20_000; export interface H3GridSettings { + /** Derive the resolution from the map zoom instead of the manual slider. */ + autoResolution: boolean; resolution: number; fillColor: string; fillOpacity: number; @@ -37,9 +54,12 @@ export interface H3GridSettings { lineWidth: number; showLabels: boolean; includeNeighbors: boolean; + includeParents: boolean; + showIcosahedron: boolean; } export const DEFAULT_H3_GRID_SETTINGS: H3GridSettings = { + autoResolution: true, // Useful immediately at GeoLibre's default world view (resolution 3 would // already exceed the viewport safety cap). resolution: 2, @@ -49,12 +69,15 @@ export const DEFAULT_H3_GRID_SETTINGS: H3GridSettings = { lineWidth: 1, showLabels: true, includeNeighbors: false, + includeParents: false, + showIcosahedron: false, }; export interface H3Labels { title: string; getTitle?: () => string; controlTitle: string; + autoResolution: string; resolution: string; cellCount: (count: number) => string; tooManyCells: (limit: number) => string; @@ -81,11 +104,14 @@ export interface H3Labels { exportGeoJson: string; exportCsv: string; includeNeighbors: string; + includeParents: string; + showIcosahedron: string; } export const DEFAULT_H3_LABELS: H3Labels = { title: "H3 Grid", controlTitle: "H3 grid settings", + autoResolution: "Automatic resolution", resolution: "Resolution", cellCount: (count) => `${count.toLocaleString()} cells in view`, tooManyCells: (limit) => @@ -100,7 +126,7 @@ export const DEFAULT_H3_LABELS: H3Labels = { noSelection: "No cell selected", copyId: "Copy ID", copied: "Copied", - parent: "Parent", + parent: "Parent(s)", children: "Children", neighbors: "Neighbors", baseCell: "Base cell", @@ -113,6 +139,8 @@ export const DEFAULT_H3_LABELS: H3Labels = { exportGeoJson: "Export GeoJSON", exportCsv: "Export CSV", includeNeighbors: "Include selected cell neighbors", + includeParents: "Include selected cell parent(s)", + showIcosahedron: "Show icosahedron", }; let labels: H3Labels = { ...DEFAULT_H3_LABELS }; @@ -125,9 +153,8 @@ let clickHandler: ((event: MapMouseEvent) => void) | null = null; let unsubscribeBasemap: (() => void) | null = null; let panelContainer: HTMLElement | null = null; let selectedCell: string | null = null; -type H3PolygonGeometry = Polygon | MultiPolygon; -let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; +let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; let currentError: string | null = null; let cachedTextFont: string[] | null = null; let pendingRefresh: number | null = null; @@ -184,9 +211,30 @@ function color(value: unknown, fallback: string): string { : fallback; } +/** + * The automatic zoom→resolution rule, adapted from vgrid-maplibre's H3Grid + * getResolution (https://www.npmjs.com/package/vgrid-maplibre): H3 cell area + * shrinks ~7x per resolution step versus 4x per zoom level, so resolution + * advances at a fraction of a step per zoom level (0.9 here, tuned up from + * vgrid's 0.8 for a denser grid), offset so the world view starts at 0, + * clamped to the valid range. + */ +export function h3ResolutionForZoom(zoom: number): number { + return Math.min(15, Math.max(0, Math.floor((zoom - 3) * 0.9))); +} + +/** The resolution actually rendered: zoom-derived when automatic, else manual. */ +function effectiveResolution(): number { + return settings.autoResolution && map ? h3ResolutionForZoom(map.getZoom()) : settings.resolution; +} + export function normalizeH3GridSettings(value: unknown): H3GridSettings { const candidate = (value ?? {}) as Partial; return { + autoResolution: + typeof candidate.autoResolution === "boolean" + ? candidate.autoResolution + : DEFAULT_H3_GRID_SETTINGS.autoResolution, resolution: Math.round( clampNumber(candidate.resolution, 0, 15, DEFAULT_H3_GRID_SETTINGS.resolution), ), @@ -202,6 +250,14 @@ export function normalizeH3GridSettings(value: unknown): H3GridSettings { typeof candidate.includeNeighbors === "boolean" ? candidate.includeNeighbors : DEFAULT_H3_GRID_SETTINGS.includeNeighbors, + includeParents: + typeof candidate.includeParents === "boolean" + ? candidate.includeParents + : DEFAULT_H3_GRID_SETTINGS.includeParents, + showIcosahedron: + typeof candidate.showIcosahedron === "boolean" + ? candidate.showIcosahedron + : DEFAULT_H3_GRID_SETTINGS.showIcosahedron, }; } @@ -211,15 +267,24 @@ export function h3LabelMinZoom(resolution: number): number { } export function setH3GridSettings(patch: Partial): void { - const previousResolution = settings.resolution; + const previousResolution = effectiveResolution(); + // Leaving automatic mode adopts the current zoom-derived resolution as the + // fixed one, so the grid stays put instead of jumping to the stale slider. + if (settings.autoResolution && patch.autoResolution === false && patch.resolution === undefined) { + patch = { ...patch, resolution: previousResolution }; + } settings = normalizeH3GridSettings({ ...settings, ...patch }); - if (selectedCell && settings.resolution !== previousResolution) { + const resolution = effectiveResolution(); + // Re-derive the selection only for an explicit slider change; toggling + // automatic resolution (like zooming in automatic mode) keeps the clicked + // cell and its neighbors/parents as they are. + if (selectedCell && patch.resolution !== undefined && resolution !== previousResolution) { const [lat, lng] = cellToLatLng(selectedCell); - selectedCell = latLngToCell(lat, lng, settings.resolution); + selectedCell = latLngToCell(lat, lng, resolution); } - // Only the resolution changes the geometry, so a paint/layout-only edit skips - // rebuilding up to H3_VIEWPORT_CELL_LIMIT features. - if (settings.resolution !== previousResolution) { + // Only the rendered resolution changes the geometry, so a paint/layout-only + // edit skips rebuilding up to H3_VIEWPORT_CELL_LIMIT features. + if (resolution !== previousResolution) { refresh(); } else { applyStyle(); @@ -229,83 +294,25 @@ export function setH3GridSettings(patch: Partial): void { } /** - * Keep a cell boundary contiguous around its center. h3-js returns longitudes - * in [-180, 180], so a cell straddling the antimeridian otherwise contains a - * ~360° jump that MapLibre draws as a line across the entire world. - * - * MapLibre accepts unwrapped longitudes outside [-180, 180] and places them in - * the adjacent world copy, which preserves the small hexagon at the seam. + * Keep a cell boundary contiguous across the antimeridian, mirroring + * vgrid-maplibre's H3Grid (https://www.npmjs.com/package/vgrid-maplibre): + * when a ring carries a vertex west of -130°, every positive longitude is + * shifted down by 360°. A cell straddling the seam mixes ~+179 and ~-179 + * values, so the shift makes the ring contiguous around -180; MapLibre + * renders longitudes past -180 in the adjacent world copy. Rings entirely + * away from the seam never match the -130 test and pass through unchanged. */ -export function unwrapH3Boundary( - ring: [number, number][], - centerLongitude: number, -): [number, number][] { - return ring.map(([longitude, latitude]) => { - let unwrapped = longitude; - while (unwrapped - centerLongitude > 180) unwrapped -= 360; - while (unwrapped - centerLongitude < -180) unwrapped += 360; - return [unwrapped, latitude]; - }); -} - -function clipRingAtLongitude( - ring: [number, number][], - longitude: number, - keepLower: boolean, -): [number, number][] { - const output: [number, number][] = []; - const points = - ring.at(-1)?.[0] === ring[0]?.[0] && ring.at(-1)?.[1] === ring[0]?.[1] - ? ring.slice(0, -1) - : ring; - if (points.length < 3) return []; - - const inside = ([x]: [number, number]): boolean => (keepLower ? x <= longitude : x >= longitude); - for (let index = 0; index < points.length; index += 1) { - const current = points[index]; - const previous = points[(index + points.length - 1) % points.length]; - const currentInside = inside(current); - const previousInside = inside(previous); - if (currentInside !== previousInside) { - const ratio = (longitude - previous[0]) / (current[0] - previous[0]); - output.push([longitude, previous[1] + ratio * (current[1] - previous[1])]); - } - if (currentInside) output.push(current); - } - if (output.length >= 3) output.push([...output[0]] as [number, number]); - return output; -} - -/** Split a contiguous cell ring at ±180° so exported coordinates stay valid. */ -export function h3BoundaryGeometry( - ring: [number, number][], - centerLongitude: number, -): H3PolygonGeometry { - const unwrapped = unwrapH3Boundary(ring, centerLongitude); - const longitudes = unwrapped.map(([longitude]) => longitude); - const min = Math.min(...longitudes); - const max = Math.max(...longitudes); - if (max <= 180 && min >= -180) { - return { type: "Polygon", coordinates: [unwrapped] }; - } - - // A ring can graze the seam so narrowly that one side clips to nothing; an - // empty linear ring would be invalid GeoJSON, so drop it and emit a Polygon. - const seam = max > 180 ? 180 : -180; - const shift = max > 180 ? -360 : 360; - const kept = clipRingAtLongitude(unwrapped, seam, max > 180); - const wrapped = clipRingAtLongitude(unwrapped, seam, max <= 180).map( - ([longitude, latitude]) => [longitude + shift, latitude] as [number, number], +export function h3FixTransmeridianBoundary(ring: [number, number][]): [number, number][] { + if (!ring.some(([longitude]) => longitude < -130)) return ring; + return ring.map(([longitude, latitude]) => + longitude > 0 ? [longitude - 360, latitude] : [longitude, latitude], ); - const rings = [kept, wrapped].filter((ring) => ring.length >= 4); - if (rings.length === 1) return { type: "Polygon", coordinates: [rings[0]] }; - return { type: "MultiPolygon", coordinates: rings.map((ring) => [ring]) }; } /** Convert an H3 cell to a GeoJSON polygon with useful export attributes. */ -export function h3CellFeature(cell: string): Feature { +export function h3CellFeature(cell: string): Feature { const [lat, lng] = cellToLatLng(cell); - const boundary = cellToBoundary(cell, true) as [number, number][]; + const boundary = h3FixTransmeridianBoundary(cellToBoundary(cell, true) as [number, number][]); return { type: "Feature", id: cell, @@ -317,7 +324,7 @@ export function h3CellFeature(cell: string): Feature { center_lng: lng, is_pentagon: isPentagon(cell), }, - geometry: h3BoundaryGeometry(boundary, lng), + geometry: { type: "Polygon", coordinates: [boundary] }, }; } @@ -329,7 +336,7 @@ export function h3GridForBounds( bounds: [number, number, number, number], resolution: number, limit = H3_VIEWPORT_CELL_LIMIT, -): FeatureCollection { +): FeatureCollection { const [west, southRaw, east, northRaw] = bounds; const south = Math.max(-89.999999, Math.min(89.999999, southRaw)); const north = Math.max(-89.999999, Math.min(89.999999, northRaw)); @@ -385,13 +392,15 @@ function removeLayers(activeMap: MapLibreMap): void { for (const id of [ SELECTED_LINE_LAYER_ID, SELECTED_FILL_LAYER_ID, + PARENTS_LINE_LAYER_ID, + ICOSAHEDRON_LINE_LAYER_ID, LABEL_LAYER_ID, LINE_LAYER_ID, FILL_LAYER_ID, ]) { if (activeMap.getLayer(id)) activeMap.removeLayer(id); } - for (const id of [SELECTED_SOURCE_ID, SOURCE_ID]) { + for (const id of [SELECTED_SOURCE_ID, PARENTS_SOURCE_ID, ICOSAHEDRON_SOURCE_ID, SOURCE_ID]) { if (activeMap.getSource(id)) activeMap.removeSource(id); } } @@ -416,7 +425,7 @@ function ensureLayers(): void { id: LABEL_LAYER_ID, type: "symbol", source: SOURCE_ID, - minzoom: h3LabelMinZoom(settings.resolution), + minzoom: h3LabelMinZoom(effectiveResolution()), layout: { "text-field": ["get", "h3"], "text-font": pickTextFont(map), @@ -430,6 +439,41 @@ function ensureLayers(): void { }, }); } + if (!map.getSource(ICOSAHEDRON_SOURCE_ID)) { + map.addSource(ICOSAHEDRON_SOURCE_ID, { + type: "geojson", + data: ICOSAHEDRON_GEOJSON_URL, + }); + map.addLayer({ + id: ICOSAHEDRON_LINE_LAYER_ID, + type: "line", + source: ICOSAHEDRON_SOURCE_ID, + layout: { visibility: settings.showIcosahedron ? "visible" : "none" }, + paint: { + "line-color": "#dc2626", + "line-width": 1.5, + "line-dasharray": [2, 2], + }, + }); + } + // Added before the selected layers so the selected cell stays on top of its + // (larger, overlapping) parents. + if (!map.getSource(PARENTS_SOURCE_ID)) { + map.addSource(PARENTS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: PARENTS_LINE_LAYER_ID, + type: "line", + source: PARENTS_SOURCE_ID, + paint: { + "line-color": "#f59e0b", + "line-width": SELECTED_LINE_WIDTH * 2, + "line-dasharray": [2, 2], + }, + }); + } if (!map.getSource(SELECTED_SOURCE_ID)) { map.addSource(SELECTED_SOURCE_ID, { type: "geojson", @@ -445,7 +489,7 @@ function ensureLayers(): void { id: SELECTED_LINE_LAYER_ID, type: "line", source: SELECTED_SOURCE_ID, - paint: { "line-color": "#f59e0b", "line-width": 3 }, + paint: { "line-color": "#f59e0b", "line-width": SELECTED_LINE_WIDTH }, }); } } @@ -459,16 +503,27 @@ function applyStyle(): void { map.setPaintProperty(LINE_LAYER_ID, "line-width", settings.lineWidth); map.setPaintProperty(LABEL_LAYER_ID, "text-color", settings.lineColor); map.setLayoutProperty(LABEL_LAYER_ID, "visibility", settings.showLabels ? "visible" : "none"); - map.setLayerZoomRange(LABEL_LAYER_ID, h3LabelMinZoom(settings.resolution), 24); + map.setLayerZoomRange(LABEL_LAYER_ID, h3LabelMinZoom(effectiveResolution()), 24); + map.setLayoutProperty( + ICOSAHEDRON_LINE_LAYER_ID, + "visibility", + settings.showIcosahedron ? "visible" : "none", + ); } function refresh(): void { if (!map) return; + const resolution = effectiveResolution(); + // The selected cell (and its neighbors/parents) deliberately stays at the + // resolution it was clicked at: in automatic mode a zoom or pan changes the + // rendered grid, but re-deriving the selection would silently replace the + // cell the user identified. Only an explicit settings change re-indexes it + // (see setH3GridSettings). try { const bounds = map.getBounds(); currentGrid = h3GridForBounds( [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], - settings.resolution, + resolution, ); currentError = null; } catch (error) { @@ -487,15 +542,51 @@ function selectedCells(): string[] { return settings.includeNeighbors ? gridDisk(selectedCell, 1) : [selectedCell]; } +/** + * Every resolution r-1 cell the selected cell overlaps. H3's hierarchy is + * approximate — a child hexagon is not perfectly contained in cellToParent, + * its corners can spill into the parent's neighbors. Any overlapping coarser + * cell is larger than the cell itself, so it must cover part of the boundary: + * sampling the boundary vertices, nudged slightly toward the center so parents + * that merely touch the edge are excluded, finds them all. + */ +function parentCells(cell: string): string[] { + const resolution = getResolution(cell); + if (resolution <= 0) return []; + const [centerLat, centerLng] = cellToLatLng(cell); + const parents = new Set([cellToParent(cell, resolution - 1)]); + // The unwrapped ring keeps vertex longitudes adjacent to the center for + // dateline cells; latLngToCell accepts longitudes outside [-180, 180]. + const ring = h3FixTransmeridianBoundary(cellToBoundary(cell, true) as [number, number][]); + const ringCenterLng = + centerLng > 0 && ring.some(([lng]) => lng < -130) ? centerLng - 360 : centerLng; + for (const [lng, lat] of ring) { + parents.add( + latLngToCell( + centerLat + (lat - centerLat) * 0.999, + ringCenterLng + (lng - ringCenterLng) * 0.999, + resolution - 1, + ), + ); + } + return [...parents]; +} + function updateSelectedSource(): void { const source = map?.getSource(SELECTED_SOURCE_ID) as GeoJSONSource | undefined; source?.setData({ type: "FeatureCollection", features: selectedCells().map(h3CellFeature), }); + const parentsSource = map?.getSource(PARENTS_SOURCE_ID) as GeoJSONSource | undefined; + parentsSource?.setData({ + type: "FeatureCollection", + features: + settings.includeParents && selectedCell ? parentCells(selectedCell).map(h3CellFeature) : [], + }); } -function gridCsv(grid: FeatureCollection): string { +function gridCsv(grid: FeatureCollection): string { const header = "h3,resolution,base_cell,center_lat,center_lng,is_pentagon"; const rows = grid.features.map((feature) => { const p = feature.properties!; @@ -506,11 +597,7 @@ function gridCsv(grid: FeatureCollection): string { function fitSelected(): void { if (!selectedCell || !appRef) return; - const [, centerLongitude] = cellToLatLng(selectedCell); - const ring = unwrapH3Boundary( - cellToBoundary(selectedCell, true) as [number, number][], - centerLongitude, - ); + const ring = h3FixTransmeridianBoundary(cellToBoundary(selectedCell, true) as [number, number][]); const lons = ring.map(([lng]) => lng); const lats = ring.map(([, lat]) => lat); appRef.fitBounds?.([Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)]); @@ -561,12 +648,25 @@ function renderPanel(container: HTMLElement): void { return element; }; + const autoResolution = document.createElement("input"); + autoResolution.type = "checkbox"; + autoResolution.checked = settings.autoResolution; + autoResolution.addEventListener("change", () => + setH3GridSettings({ autoResolution: autoResolution.checked }), + ); + row(labels.autoResolution, autoResolution); + + // In automatic mode the slider becomes a read-only indicator of the + // zoom-derived resolution; refresh() re-renders the panel on every moveend, + // so it tracks zoom gestures. + const shownResolution = effectiveResolution(); const resolution = document.createElement("input"); resolution.type = "range"; resolution.min = "0"; resolution.max = "15"; - resolution.value = String(settings.resolution); - resolution.title = String(settings.resolution); + resolution.value = String(shownResolution); + resolution.title = String(shownResolution); + resolution.disabled = settings.autoResolution; resolution.addEventListener("input", () => { resolution.title = resolution.value; }); @@ -577,8 +677,9 @@ function renderPanel(container: HTMLElement): void { resolutionWrap.style.display = "flex"; resolutionWrap.style.alignItems = "center"; resolutionWrap.style.gap = "6px"; + resolutionWrap.style.opacity = settings.autoResolution ? "0.6" : "1"; const resolutionValue = document.createElement("strong"); - resolutionValue.textContent = String(settings.resolution); + resolutionValue.textContent = String(shownResolution); resolution.addEventListener("input", () => { resolutionValue.textContent = resolution.value; }); @@ -614,6 +715,8 @@ function renderPanel(container: HTMLElement): void { for (const [text, key] of [ [labels.showLabels, "showLabels"], [labels.includeNeighbors, "includeNeighbors"], + [labels.includeParents, "includeParents"], + [labels.showIcosahedron, "showIcosahedron"], ] as const) { const input = document.createElement("input"); input.type = "checkbox"; @@ -651,6 +754,8 @@ function renderPanel(container: HTMLElement): void { dd.textContent = value; dd.style.margin = "0"; dd.style.overflowWrap = "anywhere"; + // Multi-line values (one overlapping parent per line) keep their breaks. + dd.style.whiteSpace = "pre-line"; details.append(dt, dd); }; addDetail("ID", selectedCell); @@ -659,7 +764,9 @@ function renderPanel(container: HTMLElement): void { addDetail(labels.center, `${lat.toFixed(6)}, ${lng.toFixed(6)}`); addDetail(labels.pentagon, isPentagon(selectedCell) ? labels.yes : labels.no); if (getResolution(selectedCell) > 0) { - addDetail(labels.parent, cellToParent(selectedCell, getResolution(selectedCell) - 1)); + // Every overlapping r-1 cell (canonical cellToParent first), matching + // the dashed parent outlines on the map. + addDetail(labels.parent, parentCells(selectedCell).join("\n")); } if (getResolution(selectedCell) < 15) { addDetail( @@ -693,7 +800,7 @@ function renderPanel(container: HTMLElement): void { labels.addAsLayer, () => { if (currentGrid.features.length) { - appRef?.addGeoJsonLayer(`H3 grid (resolution ${settings.resolution})`, currentGrid); + appRef?.addGeoJsonLayer(`H3 grid (resolution ${effectiveResolution()})`, currentGrid); } }, currentGrid.features.length === 0, @@ -702,7 +809,7 @@ function renderPanel(container: HTMLElement): void { labels.exportGeoJson, () => { appRef?.exportTextFile?.( - `h3-grid-r${settings.resolution}.geojson`, + `h3-grid-r${effectiveResolution()}.geojson`, JSON.stringify(currentGrid, null, 2), { description: "GeoJSON", @@ -717,7 +824,7 @@ function renderPanel(container: HTMLElement): void { button( labels.exportCsv, () => { - appRef?.exportTextFile?.(`h3-grid-r${settings.resolution}.csv`, gridCsv(currentGrid), { + appRef?.exportTextFile?.(`h3-grid-r${effectiveResolution()}.csv`, gridCsv(currentGrid), { description: "CSV", extensions: ["csv"], mimeType: "text/csv", @@ -747,7 +854,7 @@ export const maplibreH3Plugin: GeoLibrePlugin = { appRef = app; moveHandler = () => scheduleRefresh(); clickHandler = (event) => { - selectedCell = latLngToCell(event.lngLat.lat, event.lngLat.lng, settings.resolution); + selectedCell = latLngToCell(event.lngLat.lat, event.lngLat.lng, effectiveResolution()); updateSelectedSource(); if (panelContainer) renderPanel(panelContainer); }; @@ -761,9 +868,16 @@ export const maplibreH3Plugin: GeoLibrePlugin = { app.registerRightPanel?.({ id: PANEL_ID, title: () => labels.getTitle?.() ?? labels.title, - dock: "replace-style", + dock: "right-of-style", defaultWidth: 340, render: (container) => renderPanel(container), + // Closing the panel ends the identify session: drop the clicked cell + // and, with it, the neighbor/parent overlays derived from it. + onClose: () => { + selectedCell = null; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }, }) ?? null; refresh(); app.openRightPanel?.(PANEL_ID); diff --git a/packages/plugins/src/plugins/maplibre-olc.ts b/packages/plugins/src/plugins/maplibre-olc.ts new file mode 100644 index 000000000..ad549db3b --- /dev/null +++ b/packages/plugins/src/plugins/maplibre-olc.ts @@ -0,0 +1,902 @@ +import type { Feature, FeatureCollection, Polygon } from "geojson"; +import type { GeoJSONSource, Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; +import OpenLocationCodeModule from "open-location-code-typescript"; +import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; + +// The library ships CommonJS with an `exports.default` class. Depending on +// who loads it (Vite, tsx's CJS transform, Node's native ESM interop) the +// default import is either the class or the exports object wrapping it. +const OpenLocationCode = ((OpenLocationCodeModule as { default?: unknown }).default ?? + OpenLocationCodeModule) as typeof OpenLocationCodeModule; + +export const OLC_PLUGIN_ID = "maplibre-olc"; + +const PANEL_ID = "geolibre-olc-panel"; +const SOURCE_ID = "geolibre-olc-grid-source"; +const FILL_LAYER_ID = "geolibre-olc-grid-fill"; +const LINE_LAYER_ID = "geolibre-olc-grid-line"; +const LABEL_LAYER_ID = "geolibre-olc-grid-label"; +const SELECTED_SOURCE_ID = "geolibre-olc-selected-source"; +const SELECTED_FILL_LAYER_ID = "geolibre-olc-selected-fill"; +const SELECTED_LINE_LAYER_ID = "geolibre-olc-selected-line"; +const PARENT_SOURCE_ID = "geolibre-olc-parent-source"; +const PARENT_LINE_LAYER_ID = "geolibre-olc-parent-line"; + +const SELECTED_LINE_WIDTH = 3; + +/** Prevent a fine code length over a large viewport from freezing the browser. */ +export const OLC_VIEWPORT_CELL_LIMIT = 20_000; + +/** + * The code lengths a full Open Location Code can have: digit pairs up to 10, + * then single grid-refinement digits (where cells stop being square). + */ +export const OLC_CODE_LENGTHS = [2, 4, 6, 8, 10, 11, 12, 13, 14, 15] as const; + +export type OlcCodeLength = (typeof OLC_CODE_LENGTHS)[number]; + +export const MAX_OLC_CODE_LENGTH: OlcCodeLength = 15; + +export interface OlcGridSettings { + /** Derive the code length from the map zoom instead of the manual picker. */ + autoResolution: boolean; + /** Full code length ("resolution"): one of OLC_CODE_LENGTHS. */ + resolution: OlcCodeLength; + fillColor: string; + fillOpacity: number; + lineColor: string; + lineWidth: number; + showLabels: boolean; + includeNeighbors: boolean; + includeParent: boolean; +} + +export const DEFAULT_OLC_GRID_SETTINGS: OlcGridSettings = { + autoResolution: true, + // Useful immediately at GeoLibre's default world view: length-2 codes tile + // the globe with 162 twenty-degree cells. + resolution: 2, + fillColor: "#e11d48", + fillOpacity: 0.08, + lineColor: "#e11d48", + lineWidth: 1, + showLabels: true, + includeNeighbors: false, + includeParent: false, +}; + +export interface OlcLabels { + title: string; + getTitle?: () => string; + controlTitle: string; + autoResolution: string; + resolution: string; + cellCount: (count: number) => string; + tooManyCells: (limit: number) => string; + fillColor: string; + fillOpacity: string; + lineColor: string; + lineWidth: string; + showLabels: string; + identifyHint: string; + selectedCell: string; + noSelection: string; + copyId: string; + parent: string; + children: string; + neighbors: string; + center: string; + zoomToCell: string; + addAsLayer: string; + exportGeoJson: string; + exportCsv: string; + includeNeighbors: string; + includeParent: string; +} + +export const DEFAULT_OLC_LABELS: OlcLabels = { + title: "OLC", + controlTitle: "OLC settings", + autoResolution: "Automatic resolution", + resolution: "Code length", + cellCount: (count) => `${count.toLocaleString()} cells in view`, + tooManyCells: (limit) => + `This view exceeds the ${limit.toLocaleString()} cell limit. Zoom in or lower the code length.`, + fillColor: "Fill color", + fillOpacity: "Fill opacity", + lineColor: "Outline color", + lineWidth: "Outline width", + showLabels: "Show cell IDs", + identifyHint: "Click the map to identify an OLC cell.", + selectedCell: "Selected cell", + noSelection: "No cell selected", + copyId: "Copy ID", + parent: "Parent", + children: "Children", + neighbors: "Neighbors", + center: "Center", + zoomToCell: "Zoom to cell", + addAsLayer: "Add grid as layer", + exportGeoJson: "Export GeoJSON", + exportCsv: "Export CSV", + includeNeighbors: "Include selected cell neighbors", + includeParent: "Include selected cell parent", +}; + +let labels: OlcLabels = { ...DEFAULT_OLC_LABELS }; +let settings: OlcGridSettings = { ...DEFAULT_OLC_GRID_SETTINGS }; +let map: MapLibreMap | null = null; +let appRef: GeoLibreAppAPI | null = null; +let unregisterPanel: (() => void) | null = null; +let moveHandler: (() => void) | null = null; +let clickHandler: ((event: MapMouseEvent) => void) | null = null; +let unsubscribeBasemap: (() => void) | null = null; +let panelContainer: HTMLElement | null = null; +/** The selected cell's full Open Location Code (encodes its own length). */ +let selectedCell: string | null = null; + +let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; +let currentError: string | null = null; +let cachedTextFont: string[] | null = null; +let pendingRefresh: number | null = null; + +/** + * Coalesce viewport-driven rebuilds. Inertial pans emit `moveend` in bursts, + * and each rebuild materializes up to OLC_VIEWPORT_CELL_LIMIT cells on the + * main thread. + */ +function scheduleRefresh(): void { + if (pendingRefresh !== null) return; + pendingRefresh = requestAnimationFrame(() => { + pendingRefresh = null; + refresh(); + }); +} + +function cancelScheduledRefresh(): void { + if (pendingRefresh === null) return; + cancelAnimationFrame(pendingRefresh); + pendingRefresh = null; +} + +/** Reuse a font already present in the active basemap to avoid glyph 404s. */ +function pickTextFont(activeMap: MapLibreMap): string[] { + if (cachedTextFont) return cachedTextFont; + let fallback: string[] | null = null; + for (const layer of activeMap.getStyle()?.layers ?? []) { + if (layer.id === LABEL_LAYER_ID || layer.type !== "symbol") continue; + const font = (layer.layout as { "text-font"?: string[] } | undefined)?.["text-font"]; + if (!Array.isArray(font) || font.length === 0) continue; + if (font.every((name) => !/italic|bold/i.test(name))) return (cachedTextFont = font); + fallback ??= font; + } + return (cachedTextFont = fallback ?? ["Open Sans Regular", "Arial Unicode MS Regular"]); +} + +export function setOlcLabels(next: Partial): void { + labels = { ...labels, ...next }; + if (panelContainer) renderPanel(panelContainer); +} + +export function getOlcGridSettings(): OlcGridSettings { + return { ...settings }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function color(value: unknown, fallback: string): string { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) + ? value.toLowerCase() + : fallback; +} + +/** Snap an arbitrary number to the nearest valid full-code length. */ +function toCodeLength(value: unknown, fallback: OlcCodeLength): OlcCodeLength { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + let best: OlcCodeLength = OLC_CODE_LENGTHS[0]; + for (const length of OLC_CODE_LENGTHS) { + if (Math.abs(length - number) < Math.abs(best - number)) best = length; + } + return best; +} + +/** + * The automatic zoom→code-length rule, mirroring vgrid-maplibre's OLCGrid + * (https://www.npmjs.com/package/vgrid-maplibre): step through the valid + * lengths as the ~20°/1°/0.05°… cells reach a useful on-screen size. + */ +export function olcResolutionForZoom(zoom: number): OlcCodeLength { + if (zoom <= 6) return 2; + if (zoom <= 10) return 4; + if (zoom <= 14) return 6; + if (zoom <= 18) return 8; + if (zoom <= 21) return 10; + if (zoom <= 23) return 11; + if (zoom <= 25) return 12; + if (zoom <= 27) return 13; + if (zoom <= 29) return 14; + return 15; +} + +/** The code length actually rendered: zoom-derived when automatic, else manual. */ +function effectiveResolution(): OlcCodeLength { + return settings.autoResolution && map ? olcResolutionForZoom(map.getZoom()) : settings.resolution; +} + +export function normalizeOlcGridSettings(value: unknown): OlcGridSettings { + const candidate = (value ?? {}) as Partial; + return { + autoResolution: + typeof candidate.autoResolution === "boolean" + ? candidate.autoResolution + : DEFAULT_OLC_GRID_SETTINGS.autoResolution, + resolution: toCodeLength(candidate.resolution, DEFAULT_OLC_GRID_SETTINGS.resolution), + fillColor: color(candidate.fillColor, DEFAULT_OLC_GRID_SETTINGS.fillColor), + fillOpacity: clampNumber(candidate.fillOpacity, 0, 1, DEFAULT_OLC_GRID_SETTINGS.fillOpacity), + lineColor: color(candidate.lineColor, DEFAULT_OLC_GRID_SETTINGS.lineColor), + lineWidth: clampNumber(candidate.lineWidth, 0.1, 8, DEFAULT_OLC_GRID_SETTINGS.lineWidth), + showLabels: + typeof candidate.showLabels === "boolean" + ? candidate.showLabels + : DEFAULT_OLC_GRID_SETTINGS.showLabels, + includeNeighbors: + typeof candidate.includeNeighbors === "boolean" + ? candidate.includeNeighbors + : DEFAULT_OLC_GRID_SETTINGS.includeNeighbors, + includeParent: + typeof candidate.includeParent === "boolean" + ? candidate.includeParent + : DEFAULT_OLC_GRID_SETTINGS.includeParent, + }; +} + +/** + * Avoid thousands of overlapping IDs when the grid is viewed globally: show + * labels only from the zoom step below the one whose automatic rule picks the + * next (finer) code length. + */ +export function olcLabelMinZoom(codeLength: number): number { + const minZoom: Record = { + 2: 2, + 4: 5, + 6: 9, + 8: 13, + 10: 17, + 11: 19, + 12: 21, + 13: 22, + 14: 23, + 15: 24, + }; + return minZoom[toCodeLength(codeLength, 2)] ?? 2; +} + +export function setOlcGridSettings(patch: Partial): void { + const previousResolution = effectiveResolution(); + // Leaving automatic mode adopts the current zoom-derived code length as the + // fixed one, so the grid stays put instead of jumping to the stale picker. + if (settings.autoResolution && patch.autoResolution === false && patch.resolution === undefined) { + patch = { ...patch, resolution: previousResolution }; + } + settings = normalizeOlcGridSettings({ ...settings, ...patch }); + const resolution = effectiveResolution(); + // Re-derive the selection only for an explicit code-length change; toggling + // automatic resolution (like zooming in automatic mode) keeps the clicked + // cell and its neighbors/parent as they are. + if (selectedCell && patch.resolution !== undefined && resolution !== previousResolution) { + const area = OpenLocationCode.decode(selectedCell); + selectedCell = OpenLocationCode.encode(area.latitudeCenter, area.longitudeCenter, resolution); + } + // Only the rendered code length changes the geometry, so a paint/layout-only + // edit skips rebuilding up to OLC_VIEWPORT_CELL_LIMIT features. + if (resolution !== previousResolution) { + refresh(); + } else { + applyStyle(); + updateSelectedSource(); + } + if (panelContainer) renderPanel(panelContainer); +} + +/** + * Convert an OLC cell to a GeoJSON polygon with export attributes. `lngOffset` + * (a multiple of 360) places the ring in the world copy a dateline-crossing + * viewport is actually looking at. + */ +export function olcCellFeature(cell: string, lngOffset = 0): Feature { + const area = OpenLocationCode.decode(cell); + const west = area.longitudeLo + lngOffset; + const east = area.longitudeHi + lngOffset; + return { + type: "Feature", + id: cell, + properties: { + olc: cell, + resolution: area.codeLength, + center_lat: area.latitudeCenter, + center_lng: area.longitudeCenter, + }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [west, area.latitudeLo], + [east, area.latitudeLo], + [east, area.latitudeHi], + [west, area.latitudeHi], + [west, area.latitudeLo], + ], + ], + }, + }; +} + +/** + * Fill a WGS84 bounding box with OLC cells, mirroring vgrid-maplibre's + * OLCGrid: cells are an axis-aligned lat/lon grid anchored at -180/-90, so + * the fill walks the rows and columns intersecting the box. Longitudes may + * run past ±180 (MapLibre's continuous bounds); each cell is encoded from its + * normalized centroid but drawn in the viewport's world copy. + */ +export function olcGridForBounds( + bounds: [number, number, number, number], + codeLength: OlcCodeLength, + limit = OLC_VIEWPORT_CELL_LIMIT, +): FeatureCollection { + let [west, south, east, north] = bounds; + south = Math.max(-90, Math.min(90, south)); + north = Math.max(-90, Math.min(90, north)); + if (east - west >= 360) { + west = -180; + east = 180; + } + // Above length 10 the grid refinement is 4 columns × 5 rows, so measure the + // two cell dimensions independently from a reference cell. + const reference = OpenLocationCode.decode(OpenLocationCode.encode(0, 0, codeLength)); + const latHeight = reference.getLatitudeHeight(); + const lngWidth = reference.getLongitudeWidth(); + + if (((east - west) / lngWidth) * ((north - south) / latHeight) > limit * 1.2) { + throw new RangeError(`OLC cell limit exceeded: ${limit}`); + } + + const startLng = Math.floor((west + 180) / lngWidth) * lngWidth - 180; + const startLat = Math.max(-90, Math.floor((south + 90) / latHeight) * latHeight - 90); + + const features: Feature[] = []; + // Floating-point walks of the grid can land on the same cell twice near + // cell boundaries; key by (id, world copy) so a dateline-crossing view can + // still draw the same code in two adjacent copies. + const seen = new Set(); + for (let lng = startLng; lng < east; lng += lngWidth) { + for (let lat = startLat; lat < north && lat < 90; lat += latHeight) { + const centerLng = lng + lngWidth / 2; + const cell = OpenLocationCode.encode(lat + latHeight / 2, centerLng, codeLength); + // 360° multiple between the drawn column and the normalized cell. + const lngOffset = + Math.round((centerLng - OpenLocationCode.decode(cell).longitudeCenter) / 360) * 360; + const key = `${cell}@${lngOffset}`; + if (seen.has(key)) continue; + seen.add(key); + features.push(olcCellFeature(cell, lngOffset)); + if (features.length > limit) { + throw new RangeError(`OLC cell limit exceeded: ${limit}`); + } + } + } + return { type: "FeatureCollection", features }; +} + +/** + * OLC is a strictly nested grid, so a cell has exactly one parent: the cell + * at the previous valid code length containing its center. + */ +export function olcParentCell(cell: string): string | null { + const area = OpenLocationCode.decode(cell); + const index = OLC_CODE_LENGTHS.indexOf(area.codeLength as OlcCodeLength); + if (index <= 0) return null; + return OpenLocationCode.encode( + area.latitudeCenter, + area.longitudeCenter, + OLC_CODE_LENGTHS[index - 1], + ); +} + +/** How many cells of the next valid code length subdivide this cell. */ +export function olcChildCount(cell: string): number { + const area = OpenLocationCode.decode(cell); + const index = OLC_CODE_LENGTHS.indexOf(area.codeLength as OlcCodeLength); + if (index < 0 || index >= OLC_CODE_LENGTHS.length - 1) return 0; + const child = OpenLocationCode.decode( + OpenLocationCode.encode(area.latitudeCenter, area.longitudeCenter, OLC_CODE_LENGTHS[index + 1]), + ); + return Math.round( + (area.getLatitudeHeight() / child.getLatitudeHeight()) * + (area.getLongitudeWidth() / child.getLongitudeWidth()), + ); +} + +/** + * The cell plus its (up to 8) surrounding grid cells, encoded from offset + * centroids. Cells in the top and bottom rows have no neighbors past the + * poles; the longitude wraps via encode's normalization. + */ +export function olcNeighborCells(cell: string): string[] { + const area = OpenLocationCode.decode(cell); + const latHeight = area.getLatitudeHeight(); + const lngWidth = area.getLongitudeWidth(); + const ids = new Set([cell]); + for (const dLat of [-1, 0, 1]) { + for (const dLng of [-1, 0, 1]) { + if (dLat === 0 && dLng === 0) continue; + const lat = area.latitudeCenter + dLat * latHeight; + if (lat < -90 || lat > 90) continue; + ids.add( + OpenLocationCode.encode(lat, area.longitudeCenter + dLng * lngWidth, area.codeLength), + ); + } + } + return [...ids]; +} + +function removeLayers(activeMap: MapLibreMap): void { + for (const id of [ + SELECTED_LINE_LAYER_ID, + SELECTED_FILL_LAYER_ID, + PARENT_LINE_LAYER_ID, + LABEL_LAYER_ID, + LINE_LAYER_ID, + FILL_LAYER_ID, + ]) { + if (activeMap.getLayer(id)) activeMap.removeLayer(id); + } + for (const id of [SELECTED_SOURCE_ID, PARENT_SOURCE_ID, SOURCE_ID]) { + if (activeMap.getSource(id)) activeMap.removeSource(id); + } +} + +function ensureLayers(): void { + if (!map) return; + if (!map.getSource(SOURCE_ID)) { + map.addSource(SOURCE_ID, { type: "geojson", data: currentGrid }); + map.addLayer({ + id: FILL_LAYER_ID, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": settings.fillColor, "fill-opacity": settings.fillOpacity }, + }); + map.addLayer({ + id: LINE_LAYER_ID, + type: "line", + source: SOURCE_ID, + paint: { "line-color": settings.lineColor, "line-width": settings.lineWidth }, + }); + map.addLayer({ + id: LABEL_LAYER_ID, + type: "symbol", + source: SOURCE_ID, + minzoom: olcLabelMinZoom(effectiveResolution()), + layout: { + "text-field": ["get", "olc"], + "text-font": pickTextFont(map), + "text-size": 10, + visibility: settings.showLabels ? "visible" : "none", + }, + paint: { + "text-color": settings.lineColor, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }, + }); + } + // Added before the selected layers so the selected cell stays on top of its + // (larger, surrounding) parent. + if (!map.getSource(PARENT_SOURCE_ID)) { + map.addSource(PARENT_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: PARENT_LINE_LAYER_ID, + type: "line", + source: PARENT_SOURCE_ID, + paint: { + "line-color": "#f59e0b", + "line-width": SELECTED_LINE_WIDTH * 2, + "line-dasharray": [2, 2], + }, + }); + } + if (!map.getSource(SELECTED_SOURCE_ID)) { + map.addSource(SELECTED_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: SELECTED_FILL_LAYER_ID, + type: "fill", + source: SELECTED_SOURCE_ID, + paint: { "fill-color": "#f59e0b", "fill-opacity": 0.25 }, + }); + map.addLayer({ + id: SELECTED_LINE_LAYER_ID, + type: "line", + source: SELECTED_SOURCE_ID, + paint: { "line-color": "#f59e0b", "line-width": SELECTED_LINE_WIDTH }, + }); + } +} + +function applyStyle(): void { + if (!map) return; + ensureLayers(); + map.setPaintProperty(FILL_LAYER_ID, "fill-color", settings.fillColor); + map.setPaintProperty(FILL_LAYER_ID, "fill-opacity", settings.fillOpacity); + map.setPaintProperty(LINE_LAYER_ID, "line-color", settings.lineColor); + map.setPaintProperty(LINE_LAYER_ID, "line-width", settings.lineWidth); + map.setPaintProperty(LABEL_LAYER_ID, "text-color", settings.lineColor); + map.setLayoutProperty(LABEL_LAYER_ID, "visibility", settings.showLabels ? "visible" : "none"); + map.setLayerZoomRange(LABEL_LAYER_ID, olcLabelMinZoom(effectiveResolution()), 24); +} + +function refresh(): void { + if (!map) return; + const resolution = effectiveResolution(); + // The selected cell (and its neighbors/parent) deliberately stays at the + // code length it was clicked at: in automatic mode a zoom or pan changes + // the rendered grid, but re-deriving the selection would silently replace + // the cell the user identified. Only an explicit settings change re-indexes + // it (see setOlcGridSettings). + try { + const bounds = map.getBounds(); + currentGrid = olcGridForBounds( + [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], + resolution, + ); + currentError = null; + } catch (error) { + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = + error instanceof RangeError ? labels.tooManyCells(OLC_VIEWPORT_CELL_LIMIT) : String(error); + } + applyStyle(); + (map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); +} + +function selectedCells(): string[] { + if (!selectedCell) return []; + return settings.includeNeighbors ? olcNeighborCells(selectedCell) : [selectedCell]; +} + +function updateSelectedSource(): void { + const source = map?.getSource(SELECTED_SOURCE_ID) as GeoJSONSource | undefined; + source?.setData({ + type: "FeatureCollection", + features: selectedCells().map((cell) => olcCellFeature(cell)), + }); + const parent = settings.includeParent && selectedCell ? olcParentCell(selectedCell) : null; + const parentSource = map?.getSource(PARENT_SOURCE_ID) as GeoJSONSource | undefined; + parentSource?.setData({ + type: "FeatureCollection", + features: parent ? [olcCellFeature(parent)] : [], + }); +} + +function gridCsv(grid: FeatureCollection): string { + const header = "olc,resolution,center_lat,center_lng"; + const rows = grid.features.map((feature) => { + const p = feature.properties!; + return [p.olc, p.resolution, p.center_lat, p.center_lng].join(","); + }); + return [header, ...rows].join("\n"); +} + +function fitSelected(): void { + if (!selectedCell || !appRef) return; + const area = OpenLocationCode.decode(selectedCell); + appRef.fitBounds?.([area.longitudeLo, area.latitudeLo, area.longitudeHi, area.latitudeHi]); +} + +function renderPanel(container: HTMLElement): void { + panelContainer = container; + container.replaceChildren(); + container.style.font = "13px/1.4 system-ui, sans-serif"; + + const section = document.createElement("div"); + section.style.display = "grid"; + section.style.gap = "10px"; + section.style.padding = "12px"; + container.appendChild(section); + + const row = (text: string, input: HTMLElement): void => { + const label = document.createElement("label"); + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.justifyContent = "space-between"; + label.style.gap = "12px"; + const span = document.createElement("span"); + span.textContent = text; + label.append(span, input); + section.appendChild(label); + }; + const button = (text: string, action: () => void, disabled = false): HTMLButtonElement => { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = text; + element.disabled = disabled; + element.style.padding = "6px 8px"; + element.style.border = "1px solid hsl(var(--border))"; + element.style.borderRadius = "6px"; + element.style.background = "hsl(var(--background))"; + element.style.color = "inherit"; + element.style.cursor = disabled ? "not-allowed" : "pointer"; + element.style.opacity = disabled ? "0.5" : "1"; + element.style.transition = "background-color 120ms ease, border-color 120ms ease"; + element.addEventListener("mouseenter", () => { + if (!element.disabled) element.style.background = "hsl(var(--muted))"; + }); + element.addEventListener("mouseleave", () => { + element.style.background = "hsl(var(--background))"; + }); + element.addEventListener("click", action); + return element; + }; + + const autoResolution = document.createElement("input"); + autoResolution.type = "checkbox"; + autoResolution.checked = settings.autoResolution; + autoResolution.addEventListener("change", () => + setOlcGridSettings({ autoResolution: autoResolution.checked }), + ); + row(labels.autoResolution, autoResolution); + + // Valid code lengths are not contiguous (…8, 10, 11…), so a dropdown + // replaces the range slider the other DGGS panels use. In automatic mode it + // becomes a read-only indicator of the zoom-derived length; refresh() + // re-renders the panel on every moveend, so it tracks zoom gestures. + const resolutionSelect = document.createElement("select"); + for (const length of OLC_CODE_LENGTHS) { + const option = document.createElement("option"); + option.value = String(length); + option.textContent = String(length); + resolutionSelect.appendChild(option); + } + resolutionSelect.value = String(effectiveResolution()); + resolutionSelect.disabled = settings.autoResolution; + resolutionSelect.style.padding = "4px 6px"; + resolutionSelect.style.border = "1px solid hsl(var(--border))"; + resolutionSelect.style.borderRadius = "6px"; + resolutionSelect.style.background = "hsl(var(--background))"; + resolutionSelect.style.color = "inherit"; + resolutionSelect.style.opacity = settings.autoResolution ? "0.6" : "1"; + resolutionSelect.addEventListener("change", () => + setOlcGridSettings({ resolution: Number(resolutionSelect.value) as OlcCodeLength }), + ); + row(labels.resolution, resolutionSelect); + + for (const [text, key] of [ + [labels.fillColor, "fillColor"], + [labels.lineColor, "lineColor"], + ] as const) { + const input = document.createElement("input"); + input.type = "color"; + input.value = settings[key]; + // `change` (not `input`): setOlcGridSettings re-renders the panel, which + // would destroy the picker mid-drag. + input.addEventListener("change", () => setOlcGridSettings({ [key]: input.value })); + row(text, input); + } + for (const [text, key, min, max, step] of [ + [labels.fillOpacity, "fillOpacity", 0, 1, 0.05], + [labels.lineWidth, "lineWidth", 0.1, 8, 0.1], + ] as const) { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.value = String(settings[key]); + input.style.width = "72px"; + input.addEventListener("change", () => setOlcGridSettings({ [key]: Number(input.value) })); + row(text, input); + } + for (const [text, key] of [ + [labels.showLabels, "showLabels"], + [labels.includeNeighbors, "includeNeighbors"], + [labels.includeParent, "includeParent"], + ] as const) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = settings[key]; + input.addEventListener("change", () => setOlcGridSettings({ [key]: input.checked })); + row(text, input); + } + + const status = document.createElement("div"); + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + section.appendChild(status); + + const hint = document.createElement("div"); + hint.textContent = labels.identifyHint; + hint.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(hint); + + const selectedHeading = document.createElement("strong"); + selectedHeading.textContent = labels.selectedCell; + section.appendChild(selectedHeading); + + if (selectedCell) { + const area = OpenLocationCode.decode(selectedCell); + const details = document.createElement("dl"); + details.style.margin = "0"; + details.style.display = "grid"; + details.style.gridTemplateColumns = "auto 1fr"; + details.style.gap = "4px 10px"; + const addDetail = (term: string, value: string): void => { + const dt = document.createElement("dt"); + dt.textContent = term; + dt.style.color = "var(--muted-foreground, #6b7280)"; + const dd = document.createElement("dd"); + dd.textContent = value; + dd.style.margin = "0"; + dd.style.overflowWrap = "anywhere"; + details.append(dt, dd); + }; + addDetail("ID", selectedCell); + addDetail(labels.resolution, String(area.codeLength)); + addDetail( + labels.center, + `${area.latitudeCenter.toFixed(6)}, ${area.longitudeCenter.toFixed(6)}`, + ); + const parent = olcParentCell(selectedCell); + if (parent) addDetail(labels.parent, parent); + const children = olcChildCount(selectedCell); + if (children > 0) addDetail(labels.children, String(children)); + addDetail(labels.neighbors, String(olcNeighborCells(selectedCell).length - 1)); + section.appendChild(details); + } else { + const empty = document.createElement("div"); + empty.textContent = labels.noSelection; + empty.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(empty); + } + + const actions = document.createElement("div"); + actions.style.display = "grid"; + actions.style.gridTemplateColumns = "1fr 1fr"; + actions.style.gap = "6px"; + actions.append( + button( + labels.copyId, + () => { + if (selectedCell) void navigator.clipboard?.writeText(selectedCell); + }, + !selectedCell, + ), + button(labels.zoomToCell, fitSelected, !selectedCell), + button( + labels.addAsLayer, + () => { + if (currentGrid.features.length) { + appRef?.addGeoJsonLayer(`OLC (code length ${effectiveResolution()})`, currentGrid); + } + }, + currentGrid.features.length === 0, + ), + button( + labels.exportGeoJson, + () => { + appRef?.exportTextFile?.( + `olc-l${effectiveResolution()}.geojson`, + JSON.stringify(currentGrid, null, 2), + { + description: "GeoJSON", + extensions: ["geojson"], + mimeType: "application/geo+json", + promptName: true, + }, + ); + }, + currentGrid.features.length === 0, + ), + button( + labels.exportCsv, + () => { + appRef?.exportTextFile?.(`olc-l${effectiveResolution()}.csv`, gridCsv(currentGrid), { + description: "CSV", + extensions: ["csv"], + mimeType: "text/csv", + promptName: true, + }); + }, + currentGrid.features.length === 0, + ), + ); + section.appendChild(actions); +} + +function settingsEqual(a: OlcGridSettings, b: OlcGridSettings): boolean { + return Object.keys(a).every( + (key) => a[key as keyof OlcGridSettings] === b[key as keyof OlcGridSettings], + ); +} + +export const maplibreOlcPlugin: GeoLibrePlugin = { + id: OLC_PLUGIN_ID, + name: "OLC", + version: "1.0.0", + activate: (app) => { + const activeMap = app.getMap?.(); + if (!activeMap) return false; + map = activeMap; + appRef = app; + moveHandler = () => scheduleRefresh(); + clickHandler = (event) => { + selectedCell = OpenLocationCode.encode( + event.lngLat.lat, + event.lngLat.lng, + effectiveResolution(), + ); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }; + activeMap.on("moveend", moveHandler); + activeMap.on("click", clickHandler); + unsubscribeBasemap = app.onBasemapChange(() => { + cachedTextFont = null; + activeMap.once("idle", refresh); + }); + unregisterPanel = + app.registerRightPanel?.({ + id: PANEL_ID, + title: () => labels.getTitle?.() ?? labels.title, + dock: "right-of-style", + defaultWidth: 340, + render: (container) => renderPanel(container), + // Closing the panel ends the identify session: drop the clicked cell + // and, with it, the neighbor/parent overlays derived from it. + onClose: () => { + selectedCell = null; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }, + }) ?? null; + refresh(); + app.openRightPanel?.(PANEL_ID); + }, + deactivate: (app) => { + cancelScheduledRefresh(); + if (map && moveHandler) map.off("moveend", moveHandler); + if (map && clickHandler) map.off("click", clickHandler); + unsubscribeBasemap?.(); + unregisterPanel?.(); + if (map) removeLayers(map); + moveHandler = null; + clickHandler = null; + unsubscribeBasemap = null; + unregisterPanel = null; + panelContainer = null; + selectedCell = null; + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = null; + cachedTextFont = null; + map = null; + appRef = null; + app.closeRightPanel?.(PANEL_ID); + }, + getProjectState: () => + settingsEqual(settings, DEFAULT_OLC_GRID_SETTINGS) ? undefined : { ...settings }, + applyProjectState: (_app, state) => { + const next = normalizeOlcGridSettings(state); + if (settingsEqual(settings, next)) return false; + settings = next; + refresh(); + }, +}; diff --git a/packages/plugins/src/plugins/maplibre-s2.ts b/packages/plugins/src/plugins/maplibre-s2.ts new file mode 100644 index 000000000..dee077618 --- /dev/null +++ b/packages/plugins/src/plugins/maplibre-s2.ts @@ -0,0 +1,884 @@ +import type { Feature, FeatureCollection, Polygon } from "geojson"; +import { geojson as s2geojson, s1, s2 } from "s2js"; +import type { GeoJSONSource, Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; +import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; + +export const S2_PLUGIN_ID = "maplibre-s2-grid"; + +const PANEL_ID = "geolibre-s2-panel"; +const SOURCE_ID = "geolibre-s2-grid-source"; +const FILL_LAYER_ID = "geolibre-s2-grid-fill"; +const LINE_LAYER_ID = "geolibre-s2-grid-line"; +const LABEL_LAYER_ID = "geolibre-s2-grid-label"; +const SELECTED_SOURCE_ID = "geolibre-s2-selected-source"; +const SELECTED_FILL_LAYER_ID = "geolibre-s2-selected-fill"; +const SELECTED_LINE_LAYER_ID = "geolibre-s2-selected-line"; +const PARENTS_SOURCE_ID = "geolibre-s2-parents-source"; +const PARENTS_LINE_LAYER_ID = "geolibre-s2-parents-line"; + +const SELECTED_LINE_WIDTH = 3; + +/** S2's finest subdivision (leaf cells). */ +export const MAX_S2_LEVEL = 30; + +/** Prevent a fine level over a large viewport from freezing the browser. */ +export const S2_VIEWPORT_CELL_LIMIT = 20_000; + +export interface S2GridSettings { + /** Derive the level from the map zoom instead of the manual slider. */ + autoResolution: boolean; + /** S2 level (0-30). Named `resolution` for parity with the H3/A5 plugins. */ + resolution: number; + fillColor: string; + fillOpacity: number; + lineColor: string; + lineWidth: number; + showLabels: boolean; + includeNeighbors: boolean; + includeParents: boolean; +} + +export const DEFAULT_S2_GRID_SETTINGS: S2GridSettings = { + autoResolution: true, + // Useful immediately at GeoLibre's default world view: level 4 tiles the + // globe with 1,536 cells (level 6's 24,576 would already exceed the + // viewport safety cap). + resolution: 4, + fillColor: "#2563eb", + fillOpacity: 0.08, + lineColor: "#2563eb", + lineWidth: 1, + showLabels: true, + includeNeighbors: false, + includeParents: false, +}; + +export interface S2Labels { + title: string; + getTitle?: () => string; + controlTitle: string; + autoResolution: string; + resolution: string; + cellCount: (count: number) => string; + tooManyCells: (limit: number) => string; + fillColor: string; + fillOpacity: string; + lineColor: string; + lineWidth: string; + showLabels: string; + identifyHint: string; + selectedCell: string; + noSelection: string; + copyId: string; + parent: string; + children: string; + neighbors: string; + center: string; + zoomToCell: string; + addAsLayer: string; + exportGeoJson: string; + exportCsv: string; + includeNeighbors: string; + includeParents: string; +} + +export const DEFAULT_S2_LABELS: S2Labels = { + title: "S2 Grid", + controlTitle: "S2 grid settings", + autoResolution: "Automatic resolution", + resolution: "Resolution", + cellCount: (count) => `${count.toLocaleString()} cells in view`, + tooManyCells: (limit) => + `This view exceeds the ${limit.toLocaleString()} cell limit. Zoom in or lower the resolution.`, + fillColor: "Fill color", + fillOpacity: "Fill opacity", + lineColor: "Outline color", + lineWidth: "Outline width", + showLabels: "Show cell IDs", + identifyHint: "Click the map to identify an S2 cell.", + selectedCell: "Selected cell", + noSelection: "No cell selected", + copyId: "Copy ID", + parent: "Parent", + children: "Children", + neighbors: "Neighbors", + center: "Center", + zoomToCell: "Zoom to cell", + addAsLayer: "Add grid as layer", + exportGeoJson: "Export GeoJSON", + exportCsv: "Export CSV", + includeNeighbors: "Include selected cell neighbors", + includeParents: "Include selected cell parent", +}; + +let labels: S2Labels = { ...DEFAULT_S2_LABELS }; +let settings: S2GridSettings = { ...DEFAULT_S2_GRID_SETTINGS }; +let map: MapLibreMap | null = null; +let appRef: GeoLibreAppAPI | null = null; +let unregisterPanel: (() => void) | null = null; +let moveHandler: (() => void) | null = null; +let clickHandler: ((event: MapMouseEvent) => void) | null = null; +let unsubscribeBasemap: (() => void) | null = null; +let panelContainer: HTMLElement | null = null; +let selectedCell: string | null = null; + +let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; +let currentError: string | null = null; +let cachedTextFont: string[] | null = null; +let pendingRefresh: number | null = null; + +/** + * Coalesce viewport-driven rebuilds. Inertial pans emit `moveend` in bursts, + * and each rebuild walks up to S2_VIEWPORT_CELL_LIMIT cells on the main thread. + */ +function scheduleRefresh(): void { + if (pendingRefresh !== null) return; + pendingRefresh = requestAnimationFrame(() => { + pendingRefresh = null; + refresh(); + }); +} + +function cancelScheduledRefresh(): void { + if (pendingRefresh === null) return; + cancelAnimationFrame(pendingRefresh); + pendingRefresh = null; +} + +/** Reuse a font already present in the active basemap to avoid glyph 404s. */ +function pickTextFont(activeMap: MapLibreMap): string[] { + if (cachedTextFont) return cachedTextFont; + let fallback: string[] | null = null; + for (const layer of activeMap.getStyle()?.layers ?? []) { + if (layer.id === LABEL_LAYER_ID || layer.type !== "symbol") continue; + const font = (layer.layout as { "text-font"?: string[] } | undefined)?.["text-font"]; + if (!Array.isArray(font) || font.length === 0) continue; + if (font.every((name) => !/italic|bold/i.test(name))) return (cachedTextFont = font); + fallback ??= font; + } + return (cachedTextFont = fallback ?? ["Open Sans Regular", "Arial Unicode MS Regular"]); +} + +export function setS2Labels(next: Partial): void { + labels = { ...labels, ...next }; + if (panelContainer) renderPanel(panelContainer); +} + +export function getS2GridSettings(): S2GridSettings { + return { ...settings }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function color(value: unknown, fallback: string): string { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) + ? value.toLowerCase() + : fallback; +} + +/** + * The automatic zoom→level rule, mirroring vgrid-maplibre's S2Grid + * (https://www.npmjs.com/package/vgrid-maplibre): one S2 level per zoom + * level, clamped to the valid range. + */ +export function s2LevelForZoom(zoom: number): number { + return Math.min(MAX_S2_LEVEL, Math.max(0, Math.floor(zoom))); +} + +/** The level actually rendered: zoom-derived when automatic, else manual. */ +function effectiveLevel(): number { + return settings.autoResolution && map ? s2LevelForZoom(map.getZoom()) : settings.resolution; +} + +export function normalizeS2GridSettings(value: unknown): S2GridSettings { + const candidate = (value ?? {}) as Partial; + return { + autoResolution: + typeof candidate.autoResolution === "boolean" + ? candidate.autoResolution + : DEFAULT_S2_GRID_SETTINGS.autoResolution, + resolution: Math.round( + clampNumber(candidate.resolution, 0, MAX_S2_LEVEL, DEFAULT_S2_GRID_SETTINGS.resolution), + ), + fillColor: color(candidate.fillColor, DEFAULT_S2_GRID_SETTINGS.fillColor), + fillOpacity: clampNumber(candidate.fillOpacity, 0, 1, DEFAULT_S2_GRID_SETTINGS.fillOpacity), + lineColor: color(candidate.lineColor, DEFAULT_S2_GRID_SETTINGS.lineColor), + lineWidth: clampNumber(candidate.lineWidth, 0.1, 8, DEFAULT_S2_GRID_SETTINGS.lineWidth), + showLabels: + typeof candidate.showLabels === "boolean" + ? candidate.showLabels + : DEFAULT_S2_GRID_SETTINGS.showLabels, + includeNeighbors: + typeof candidate.includeNeighbors === "boolean" + ? candidate.includeNeighbors + : DEFAULT_S2_GRID_SETTINGS.includeNeighbors, + includeParents: + typeof candidate.includeParents === "boolean" + ? candidate.includeParents + : DEFAULT_S2_GRID_SETTINGS.includeParents, + }; +} + +/** + * Avoid thousands of overlapping IDs when the grid is viewed globally. S2 cell + * area shrinks 4x per level (2x linearly), so one zoom level per S2 level + * keeps the on-screen label density roughly constant. + */ +export function s2LabelMinZoom(level: number): number { + return Math.min(18, Math.max(2, Math.round(level) + 1)); +} + +export function setS2GridSettings(patch: Partial): void { + const previousLevel = effectiveLevel(); + // Leaving automatic mode adopts the current zoom-derived level as the fixed + // one, so the grid stays put instead of jumping to the stale slider. + if (settings.autoResolution && patch.autoResolution === false && patch.resolution === undefined) { + patch = { ...patch, resolution: previousLevel }; + } + settings = normalizeS2GridSettings({ ...settings, ...patch }); + const level = effectiveLevel(); + // Re-derive the selection only for an explicit slider change; toggling + // automatic resolution (like zooming in automatic mode) keeps the clicked + // cell and its neighbors/parents as they are. + if (selectedCell && patch.resolution !== undefined && level !== previousLevel) { + selectedCell = reindexCell(selectedCell, level); + } + // Only the rendered level changes the geometry, so a paint/layout-only edit + // skips rebuilding up to S2_VIEWPORT_CELL_LIMIT features. + if (level !== previousLevel) { + refresh(); + } else { + applyStyle(); + updateSelectedSource(); + } + if (panelContainer) renderPanel(panelContainer); +} + +function cellIdFromToken(token: string): bigint { + return s2.cellid.fromToken(token); +} + +function cellCenter(id: bigint): [number, number] { + const latLng = s2.cellid.latLng(id); + return [s1.angle.degrees(latLng.lng), s1.angle.degrees(latLng.lat)]; +} + +function cellAtLonLat(lng: number, lat: number, level: number): string { + const leaf = s2.cellid.fromLatLng(s2.LatLng.fromDegrees(lat, lng)); + return s2.cellid.toToken(s2.cellid.parent(leaf, level)); +} + +function reindexCell(token: string, level: number): string { + const [lng, lat] = cellCenter(cellIdFromToken(token)); + return cellAtLonLat(lng, lat, level); +} + +/** + * The cell's four corners as a closed lon/lat ring. Cells crossing the + * antimeridian are unwrapped relative to their first vertex so the ring stays + * contiguous (MapLibre renders longitudes past ±180 in the adjacent world + * copy). + */ +function cellRing(id: bigint): [number, number][] { + const cell = s2.Cell.fromCellID(id); + const ring: [number, number][] = []; + for (let i = 0; i <= 4; i += 1) { + const vertex = s2.LatLng.fromPoint(cell.vertex(i % 4)); + let lng = s1.angle.degrees(vertex.lng); + const lat = s1.angle.degrees(vertex.lat); + if (ring.length > 0) { + const reference = ring[0][0]; + if (lng - reference > 180) lng -= 360; + if (lng - reference < -180) lng += 360; + } + ring.push([lng, lat]); + } + return ring; +} + +/** Convert an S2 cell (token) to a GeoJSON polygon with export attributes. */ +export function s2CellFeature(cell: string): Feature { + const id = cellIdFromToken(cell); + const [lng, lat] = cellCenter(id); + return { + type: "Feature", + id: cell, + properties: { + s2: cell, + // S2 calls this "level"; exported as `resolution` so attribute tables + // and CSVs read the same across the H3/S2/A5 plugins. + resolution: s2.cellid.level(id), + center_lat: lat, + center_lng: lng, + }, + geometry: { type: "Polygon", coordinates: [cellRing(id)] }, + }; +} + +const EARTH_AREA_M2 = 4 * Math.PI * 6371008.8 ** 2; + +/** Average S2 cell area: six level-0 faces, each subdividing 4x per level. */ +function avgCellAreaM2(level: number): number { + return EARTH_AREA_M2 / (6 * 4 ** level); +} + +/** + * s2js's GeoJSON reader expects longitudes in [-180, 180] and a loop wider + * than 180° is ambiguous (either side could be the interior), so bounds are + * cut into chunks of at most this many degrees before covering. Cells + * straddling a cut are returned by both chunks and deduplicated by token. + */ +const MAX_COVER_SPAN_DEGREES = 120; + +/** Fill a WGS84 bounding box with S2 cells at one level. */ +export function s2GridForBounds( + bounds: [number, number, number, number], + level: number, + limit = S2_VIEWPORT_CELL_LIMIT, +): FeatureCollection { + const [west, southRaw, east, northRaw] = bounds; + const south = Math.max(-89.999999, Math.min(89.999999, southRaw)); + const north = Math.max(-89.999999, Math.min(89.999999, northRaw)); + const span = Math.min(360, east >= west ? east - west : east + 360 - west); + // Reject obviously oversized requests before materializing the full result. + // This spherical rectangle estimate is deliberately a little conservative; + // the exact hard cap below remains the final guard. + const radians = Math.PI / 180; + const areaM2 = + 6371008.8 ** 2 * + span * + radians * + Math.abs(Math.sin(north * radians) - Math.sin(south * radians)); + if (areaM2 / avgCellAreaM2(level) > limit * 1.2) { + throw new RangeError(`S2 cell limit exceeded: ${limit}`); + } + + // Normalize the west edge into [-180, 180) and split the longitude span into + // in-range chunks (also the antimeridian handling: a crossing view becomes + // one chunk ending at 180 and another starting at -180). + const chunks: Array<[number, number]> = []; + let cursor = (((west % 360) + 540) % 360) - 180; + let remaining = span; + while (remaining > 1e-9) { + const step = Math.min(remaining, MAX_COVER_SPAN_DEGREES, 180 - cursor); + chunks.push([cursor, cursor + step]); + cursor = cursor + step >= 180 ? -180 : cursor + step; + remaining -= step; + } + + const coverer = new s2geojson.RegionCoverer({ minLevel: level, maxLevel: level }); + const cells = new Set(); + for (const [left, right] of chunks) { + const polygon: Polygon = { + type: "Polygon", + coordinates: [ + [ + [left, south], + [right, south], + [right, north], + [left, north], + [left, south], + ], + ], + }; + for (const id of coverer.covering(polygon)) { + cells.add(s2.cellid.toToken(id)); + if (cells.size > limit) { + throw new RangeError(`S2 cell limit exceeded: ${limit}`); + } + } + } + return { + type: "FeatureCollection", + features: [...cells].map(s2CellFeature), + }; +} + +function removeLayers(activeMap: MapLibreMap): void { + for (const id of [ + SELECTED_LINE_LAYER_ID, + SELECTED_FILL_LAYER_ID, + PARENTS_LINE_LAYER_ID, + LABEL_LAYER_ID, + LINE_LAYER_ID, + FILL_LAYER_ID, + ]) { + if (activeMap.getLayer(id)) activeMap.removeLayer(id); + } + for (const id of [SELECTED_SOURCE_ID, PARENTS_SOURCE_ID, SOURCE_ID]) { + if (activeMap.getSource(id)) activeMap.removeSource(id); + } +} + +function ensureLayers(): void { + if (!map) return; + if (!map.getSource(SOURCE_ID)) { + map.addSource(SOURCE_ID, { type: "geojson", data: currentGrid }); + map.addLayer({ + id: FILL_LAYER_ID, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": settings.fillColor, "fill-opacity": settings.fillOpacity }, + }); + map.addLayer({ + id: LINE_LAYER_ID, + type: "line", + source: SOURCE_ID, + paint: { "line-color": settings.lineColor, "line-width": settings.lineWidth }, + }); + map.addLayer({ + id: LABEL_LAYER_ID, + type: "symbol", + source: SOURCE_ID, + minzoom: s2LabelMinZoom(effectiveLevel()), + layout: { + "text-field": ["get", "s2"], + "text-font": pickTextFont(map), + "text-size": 10, + visibility: settings.showLabels ? "visible" : "none", + }, + paint: { + "text-color": settings.lineColor, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }, + }); + } + // Added before the selected layers so the selected cell stays on top of its + // (larger, overlapping) parent. + if (!map.getSource(PARENTS_SOURCE_ID)) { + map.addSource(PARENTS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: PARENTS_LINE_LAYER_ID, + type: "line", + source: PARENTS_SOURCE_ID, + paint: { + "line-color": "#f59e0b", + "line-width": SELECTED_LINE_WIDTH * 2, + "line-dasharray": [2, 2], + }, + }); + } + if (!map.getSource(SELECTED_SOURCE_ID)) { + map.addSource(SELECTED_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: SELECTED_FILL_LAYER_ID, + type: "fill", + source: SELECTED_SOURCE_ID, + paint: { "fill-color": "#f59e0b", "fill-opacity": 0.25 }, + }); + map.addLayer({ + id: SELECTED_LINE_LAYER_ID, + type: "line", + source: SELECTED_SOURCE_ID, + paint: { "line-color": "#f59e0b", "line-width": SELECTED_LINE_WIDTH }, + }); + } +} + +function applyStyle(): void { + if (!map) return; + ensureLayers(); + map.setPaintProperty(FILL_LAYER_ID, "fill-color", settings.fillColor); + map.setPaintProperty(FILL_LAYER_ID, "fill-opacity", settings.fillOpacity); + map.setPaintProperty(LINE_LAYER_ID, "line-color", settings.lineColor); + map.setPaintProperty(LINE_LAYER_ID, "line-width", settings.lineWidth); + map.setPaintProperty(LABEL_LAYER_ID, "text-color", settings.lineColor); + map.setLayoutProperty(LABEL_LAYER_ID, "visibility", settings.showLabels ? "visible" : "none"); + map.setLayerZoomRange(LABEL_LAYER_ID, s2LabelMinZoom(effectiveLevel()), 24); +} + +function refresh(): void { + if (!map) return; + const level = effectiveLevel(); + // The selected cell (and its neighbors/parents) deliberately stays at the + // level it was clicked at: in automatic mode a zoom or pan changes the + // rendered grid, but re-deriving the selection would silently replace the + // cell the user identified. Only an explicit settings change re-indexes it + // (see setS2GridSettings). + try { + const bounds = map.getBounds(); + currentGrid = s2GridForBounds( + [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], + level, + ); + currentError = null; + } catch (error) { + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = + error instanceof RangeError ? labels.tooManyCells(S2_VIEWPORT_CELL_LIMIT) : String(error); + } + applyStyle(); + (map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); +} + +/** The cell plus its edge and vertex neighbors at the same level. */ +function neighborCells(cell: string): string[] { + const id = cellIdFromToken(cell); + const level = s2.cellid.level(id); + const tokens = new Set([cell]); + for (const neighbor of s2.cellid.allNeighbors(id, level)) { + tokens.add(s2.cellid.toToken(neighbor)); + } + return [...tokens]; +} + +function selectedCells(): string[] { + if (!selectedCell) return []; + return settings.includeNeighbors ? neighborCells(selectedCell) : [selectedCell]; +} + +/** + * The direct parent, or none for a level-0 (face) cell. Unlike A5, S2 cells + * nest exactly, so a cell always has a single parent. + */ +function parentCells(cell: string): string[] { + const id = cellIdFromToken(cell); + const level = s2.cellid.level(id); + return level > 0 ? [s2.cellid.toToken(s2.cellid.parent(id, level - 1))] : []; +} + +function updateSelectedSource(): void { + const source = map?.getSource(SELECTED_SOURCE_ID) as GeoJSONSource | undefined; + source?.setData({ + type: "FeatureCollection", + features: selectedCells().map(s2CellFeature), + }); + const parentsSource = map?.getSource(PARENTS_SOURCE_ID) as GeoJSONSource | undefined; + parentsSource?.setData({ + type: "FeatureCollection", + features: + settings.includeParents && selectedCell ? parentCells(selectedCell).map(s2CellFeature) : [], + }); +} + +function gridCsv(grid: FeatureCollection): string { + const header = "s2,resolution,center_lat,center_lng"; + const rows = grid.features.map((feature) => { + const p = feature.properties!; + return [p.s2, p.resolution, p.center_lat, p.center_lng].join(","); + }); + return [header, ...rows].join("\n"); +} + +function fitSelected(): void { + if (!selectedCell || !appRef) return; + // The ring is unwrapped to stay contiguous across the antimeridian, so + // min/max longitudes never span the world. + const ring = cellRing(cellIdFromToken(selectedCell)); + const lons = ring.map(([lng]) => lng); + const lats = ring.map(([, lat]) => lat); + appRef.fitBounds?.([Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)]); +} + +function renderPanel(container: HTMLElement): void { + panelContainer = container; + container.replaceChildren(); + container.style.font = "13px/1.4 system-ui, sans-serif"; + + const section = document.createElement("div"); + section.style.display = "grid"; + section.style.gap = "10px"; + section.style.padding = "12px"; + container.appendChild(section); + + const row = (text: string, input: HTMLElement): void => { + const label = document.createElement("label"); + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.justifyContent = "space-between"; + label.style.gap = "12px"; + const span = document.createElement("span"); + span.textContent = text; + label.append(span, input); + section.appendChild(label); + }; + const button = (text: string, action: () => void, disabled = false): HTMLButtonElement => { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = text; + element.disabled = disabled; + element.style.padding = "6px 8px"; + element.style.border = "1px solid hsl(var(--border))"; + element.style.borderRadius = "6px"; + element.style.background = "hsl(var(--background))"; + element.style.color = "inherit"; + element.style.cursor = disabled ? "not-allowed" : "pointer"; + element.style.opacity = disabled ? "0.5" : "1"; + element.style.transition = "background-color 120ms ease, border-color 120ms ease"; + element.addEventListener("mouseenter", () => { + if (!element.disabled) element.style.background = "hsl(var(--muted))"; + }); + element.addEventListener("mouseleave", () => { + element.style.background = "hsl(var(--background))"; + }); + element.addEventListener("click", action); + return element; + }; + + const autoResolution = document.createElement("input"); + autoResolution.type = "checkbox"; + autoResolution.checked = settings.autoResolution; + autoResolution.addEventListener("change", () => + setS2GridSettings({ autoResolution: autoResolution.checked }), + ); + row(labels.autoResolution, autoResolution); + + // In automatic mode the slider becomes a read-only indicator of the + // zoom-derived level; refresh() re-renders the panel on every moveend, so it + // tracks zoom gestures. + const shownLevel = effectiveLevel(); + const resolution = document.createElement("input"); + resolution.type = "range"; + resolution.min = "0"; + resolution.max = String(MAX_S2_LEVEL); + resolution.value = String(shownLevel); + resolution.title = String(shownLevel); + resolution.disabled = settings.autoResolution; + resolution.addEventListener("input", () => { + resolution.title = resolution.value; + }); + resolution.addEventListener("change", () => + setS2GridSettings({ resolution: Number(resolution.value) }), + ); + const resolutionWrap = document.createElement("span"); + resolutionWrap.style.display = "flex"; + resolutionWrap.style.alignItems = "center"; + resolutionWrap.style.gap = "6px"; + resolutionWrap.style.opacity = settings.autoResolution ? "0.6" : "1"; + const resolutionValue = document.createElement("strong"); + resolutionValue.textContent = String(shownLevel); + resolution.addEventListener("input", () => { + resolutionValue.textContent = resolution.value; + }); + resolutionWrap.append(resolution, resolutionValue); + row(labels.resolution, resolutionWrap); + + for (const [text, key] of [ + [labels.fillColor, "fillColor"], + [labels.lineColor, "lineColor"], + ] as const) { + const input = document.createElement("input"); + input.type = "color"; + input.value = settings[key]; + // `change` (not `input`): setS2GridSettings re-renders the panel, which + // would destroy the picker mid-drag. + input.addEventListener("change", () => setS2GridSettings({ [key]: input.value })); + row(text, input); + } + for (const [text, key, min, max, step] of [ + [labels.fillOpacity, "fillOpacity", 0, 1, 0.05], + [labels.lineWidth, "lineWidth", 0.1, 8, 0.1], + ] as const) { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.value = String(settings[key]); + input.style.width = "72px"; + input.addEventListener("change", () => setS2GridSettings({ [key]: Number(input.value) })); + row(text, input); + } + for (const [text, key] of [ + [labels.showLabels, "showLabels"], + [labels.includeNeighbors, "includeNeighbors"], + [labels.includeParents, "includeParents"], + ] as const) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = settings[key]; + input.addEventListener("change", () => setS2GridSettings({ [key]: input.checked })); + row(text, input); + } + + const status = document.createElement("div"); + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + section.appendChild(status); + + const hint = document.createElement("div"); + hint.textContent = labels.identifyHint; + hint.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(hint); + + const selectedHeading = document.createElement("strong"); + selectedHeading.textContent = labels.selectedCell; + section.appendChild(selectedHeading); + + if (selectedCell) { + const id = cellIdFromToken(selectedCell); + const cellLevel = s2.cellid.level(id); + const [lng, lat] = cellCenter(id); + const details = document.createElement("dl"); + details.style.margin = "0"; + details.style.display = "grid"; + details.style.gridTemplateColumns = "auto 1fr"; + details.style.gap = "4px 10px"; + const addDetail = (term: string, value: string): void => { + const dt = document.createElement("dt"); + dt.textContent = term; + dt.style.color = "var(--muted-foreground, #6b7280)"; + const dd = document.createElement("dd"); + dd.textContent = value; + dd.style.margin = "0"; + dd.style.overflowWrap = "anywhere"; + details.append(dt, dd); + }; + addDetail("ID", selectedCell); + addDetail(labels.resolution, String(cellLevel)); + addDetail(labels.center, `${lat.toFixed(6)}, ${lng.toFixed(6)}`); + if (cellLevel > 0) { + addDetail(labels.parent, s2.cellid.toToken(s2.cellid.parent(id, cellLevel - 1))); + } + if (cellLevel < MAX_S2_LEVEL) { + addDetail(labels.children, String(s2.cellid.children(id).length)); + } + addDetail(labels.neighbors, String(neighborCells(selectedCell).length - 1)); + section.appendChild(details); + } else { + const empty = document.createElement("div"); + empty.textContent = labels.noSelection; + empty.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(empty); + } + + const actions = document.createElement("div"); + actions.style.display = "grid"; + actions.style.gridTemplateColumns = "1fr 1fr"; + actions.style.gap = "6px"; + actions.append( + button( + labels.copyId, + () => { + if (selectedCell) void navigator.clipboard?.writeText(selectedCell); + }, + !selectedCell, + ), + button(labels.zoomToCell, fitSelected, !selectedCell), + button( + labels.addAsLayer, + () => { + if (currentGrid.features.length) { + appRef?.addGeoJsonLayer(`S2 grid (resolution ${effectiveLevel()})`, currentGrid); + } + }, + currentGrid.features.length === 0, + ), + button( + labels.exportGeoJson, + () => { + appRef?.exportTextFile?.( + `s2-grid-r${effectiveLevel()}.geojson`, + JSON.stringify(currentGrid, null, 2), + { + description: "GeoJSON", + extensions: ["geojson"], + mimeType: "application/geo+json", + promptName: true, + }, + ); + }, + currentGrid.features.length === 0, + ), + button( + labels.exportCsv, + () => { + appRef?.exportTextFile?.(`s2-grid-r${effectiveLevel()}.csv`, gridCsv(currentGrid), { + description: "CSV", + extensions: ["csv"], + mimeType: "text/csv", + promptName: true, + }); + }, + currentGrid.features.length === 0, + ), + ); + section.appendChild(actions); +} + +function settingsEqual(a: S2GridSettings, b: S2GridSettings): boolean { + return Object.keys(a).every( + (key) => a[key as keyof S2GridSettings] === b[key as keyof S2GridSettings], + ); +} + +export const maplibreS2Plugin: GeoLibrePlugin = { + id: S2_PLUGIN_ID, + name: "S2 Grid", + version: "1.0.0", + activate: (app) => { + const activeMap = app.getMap?.(); + if (!activeMap) return false; + map = activeMap; + appRef = app; + moveHandler = () => scheduleRefresh(); + clickHandler = (event) => { + selectedCell = cellAtLonLat(event.lngLat.lng, event.lngLat.lat, effectiveLevel()); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }; + activeMap.on("moveend", moveHandler); + activeMap.on("click", clickHandler); + unsubscribeBasemap = app.onBasemapChange(() => { + cachedTextFont = null; + activeMap.once("idle", refresh); + }); + unregisterPanel = + app.registerRightPanel?.({ + id: PANEL_ID, + title: () => labels.getTitle?.() ?? labels.title, + dock: "right-of-style", + defaultWidth: 340, + render: (container) => renderPanel(container), + // Closing the panel ends the identify session: drop the clicked cell + // and, with it, the neighbor/parent overlays derived from it. + onClose: () => { + selectedCell = null; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }, + }) ?? null; + refresh(); + app.openRightPanel?.(PANEL_ID); + }, + deactivate: (app) => { + cancelScheduledRefresh(); + if (map && moveHandler) map.off("moveend", moveHandler); + if (map && clickHandler) map.off("click", clickHandler); + unsubscribeBasemap?.(); + unregisterPanel?.(); + if (map) removeLayers(map); + moveHandler = null; + clickHandler = null; + unsubscribeBasemap = null; + unregisterPanel = null; + panelContainer = null; + selectedCell = null; + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = null; + cachedTextFont = null; + map = null; + appRef = null; + app.closeRightPanel?.(PANEL_ID); + }, + getProjectState: () => + settingsEqual(settings, DEFAULT_S2_GRID_SETTINGS) ? undefined : { ...settings }, + applyProjectState: (_app, state) => { + const next = normalizeS2GridSettings(state); + if (settingsEqual(settings, next)) return false; + settings = next; + refresh(); + }, +}; diff --git a/packages/plugins/src/plugins/maplibre-tilecode.ts b/packages/plugins/src/plugins/maplibre-tilecode.ts new file mode 100644 index 000000000..3f0aa6d66 --- /dev/null +++ b/packages/plugins/src/plugins/maplibre-tilecode.ts @@ -0,0 +1,925 @@ +import type { Feature, FeatureCollection, Polygon } from "geojson"; +import type { GeoJSONSource, Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; +import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; + +export const TILECODE_PLUGIN_ID = "maplibre-tilecode"; + +const PANEL_ID = "geolibre-tilecode-panel"; +const SOURCE_ID = "geolibre-tilecode-grid-source"; +const FILL_LAYER_ID = "geolibre-tilecode-grid-fill"; +const LINE_LAYER_ID = "geolibre-tilecode-grid-line"; +const LABEL_LAYER_ID = "geolibre-tilecode-grid-label"; +const SELECTED_SOURCE_ID = "geolibre-tilecode-selected-source"; +const SELECTED_FILL_LAYER_ID = "geolibre-tilecode-selected-fill"; +const SELECTED_LINE_LAYER_ID = "geolibre-tilecode-selected-line"; +const PARENT_SOURCE_ID = "geolibre-tilecode-parent-source"; +const PARENT_LINE_LAYER_ID = "geolibre-tilecode-parent-line"; + +const SELECTED_LINE_WIDTH = 3; + +/** Prevent a deep zoom level over a large viewport from freezing the browser. */ +export const TILECODE_VIEWPORT_CELL_LIMIT = 20_000; + +/** Web-mercator tile zoom range (26 keeps x/y safely inside bitwise range). */ +export const MIN_TILECODE_ZOOM = 0; +export const MAX_TILECODE_ZOOM = 26; + +/** Every tile subdivides into 4 children (quadtree). */ +export const TILECODE_CHILDREN_PER_CELL = 4; + +/** Web-mercator latitude limit; tiles do not exist past it. */ +const MERCATOR_MAX_LAT = 85.0511287798066; + +const D2R = Math.PI / 180; +const R2D = 180 / Math.PI; + +export interface TilecodeGridSettings { + /** Derive the tile zoom from the map zoom instead of the manual slider. */ + autoResolution: boolean; + /** Tile zoom level ("resolution"): 0–26. */ + resolution: number; + fillColor: string; + fillOpacity: number; + lineColor: string; + lineWidth: number; + showLabels: boolean; + includeNeighbors: boolean; + includeParent: boolean; +} + +export const DEFAULT_TILECODE_GRID_SETTINGS: TilecodeGridSettings = { + autoResolution: true, + // Useful immediately at GeoLibre's default world view: zoom 1 tiles the + // mercator world with 4 tiles. + resolution: 1, + fillColor: "#0284c7", + fillOpacity: 0.08, + lineColor: "#0284c7", + lineWidth: 1, + showLabels: true, + includeNeighbors: false, + includeParent: false, +}; + +export interface TilecodeLabels { + title: string; + getTitle?: () => string; + controlTitle: string; + autoResolution: string; + resolution: string; + cellCount: (count: number) => string; + tooManyCells: (limit: number) => string; + fillColor: string; + fillOpacity: string; + lineColor: string; + lineWidth: string; + showLabels: string; + identifyHint: string; + selectedCell: string; + noSelection: string; + copyId: string; + quadkey: string; + parent: string; + children: string; + neighbors: string; + center: string; + zoomToCell: string; + addAsLayer: string; + exportGeoJson: string; + exportCsv: string; + includeNeighbors: string; + includeParent: string; +} + +export const DEFAULT_TILECODE_LABELS: TilecodeLabels = { + title: "Tilecode", + controlTitle: "Tilecode settings", + autoResolution: "Automatic resolution", + resolution: "Zoom level", + cellCount: (count) => `${count.toLocaleString()} tiles in view`, + tooManyCells: (limit) => + `This view exceeds the ${limit.toLocaleString()} tile limit. Zoom in or lower the zoom level.`, + fillColor: "Fill color", + fillOpacity: "Fill opacity", + lineColor: "Outline color", + lineWidth: "Outline width", + showLabels: "Show tile IDs", + identifyHint: "Click the map to identify a tile.", + selectedCell: "Selected tile", + noSelection: "No tile selected", + copyId: "Copy ID", + quadkey: "Quadkey", + parent: "Parent", + children: "Children", + neighbors: "Neighbors", + center: "Center", + zoomToCell: "Zoom to tile", + addAsLayer: "Add grid as layer", + exportGeoJson: "Export GeoJSON", + exportCsv: "Export CSV", + includeNeighbors: "Include selected tile neighbors", + includeParent: "Include selected tile parent", +}; + +let labels: TilecodeLabels = { ...DEFAULT_TILECODE_LABELS }; +let settings: TilecodeGridSettings = { ...DEFAULT_TILECODE_GRID_SETTINGS }; +let map: MapLibreMap | null = null; +let appRef: GeoLibreAppAPI | null = null; +let unregisterPanel: (() => void) | null = null; +let moveHandler: (() => void) | null = null; +let clickHandler: ((event: MapMouseEvent) => void) | null = null; +let unsubscribeBasemap: (() => void) | null = null; +let panelContainer: HTMLElement | null = null; +/** The selected tile's tilecode, e.g. "z8x203y112" (encodes x, y, and zoom). */ +let selectedCell: string | null = null; + +let currentGrid: FeatureCollection = { type: "FeatureCollection", features: [] }; +let currentError: string | null = null; +let cachedTextFont: string[] | null = null; +let pendingRefresh: number | null = null; + +/** [x, y, z] web-mercator tile coordinates. */ +export type Tile = [number, number, number]; + +/** Format a tile as vgrid-maplibre's tilecode ID. */ +export function tileToTilecode([x, y, z]: Tile): string { + return `z${z}x${x}y${y}`; +} + +/** Parse a tilecode ID back to tile coordinates (null when malformed). */ +export function tilecodeToTile(cell: string): Tile | null { + const match = /^z(\d+)x(\d+)y(\d+)$/.exec(cell); + if (!match) return null; + const z = Number(match[1]); + const x = Number(match[2]); + const y = Number(match[3]); + const size = 2 ** z; + return z <= MAX_TILECODE_ZOOM && x < size && y < size ? [x, y, z] : null; +} + +/** The Bing-style quadkey of a tile ("" for the z0 root). */ +export function tileToQuadkey([x, y, z]: Tile): string { + let key = ""; + for (let i = z; i > 0; i--) { + let digit = 0; + const mask = 1 << (i - 1); + if ((x & mask) !== 0) digit += 1; + if ((y & mask) !== 0) digit += 2; + key += digit.toString(); + } + return key; +} + +function tileToLng(x: number, z: number): number { + return (x / 2 ** z) * 360 - 180; +} + +function tileToLat(y: number, z: number): number { + const n = Math.PI - (2 * Math.PI * y) / 2 ** z; + return R2D * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +} + +/** The tile containing a point. Longitude wraps; latitude is clamped to mercator. */ +export function pointToTile(lat: number, lng: number, z: number): Tile { + const size = 2 ** z; + const clampedLat = Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, lat)); + const sin = Math.sin(clampedLat * D2R); + let x = Math.floor(size * (lng / 360 + 0.5)); + x = ((x % size) + size) % size; + const y = Math.min( + size - 1, + Math.max(0, Math.floor(size * (0.5 - (0.25 * Math.log((1 + sin) / (1 - sin))) / Math.PI))), + ); + return [x, y, z]; +} + +/** + * Coalesce viewport-driven rebuilds. Inertial pans emit `moveend` in bursts, + * and each rebuild materializes up to TILECODE_VIEWPORT_CELL_LIMIT tiles on + * the main thread. + */ +function scheduleRefresh(): void { + if (pendingRefresh !== null) return; + pendingRefresh = requestAnimationFrame(() => { + pendingRefresh = null; + refresh(); + }); +} + +function cancelScheduledRefresh(): void { + if (pendingRefresh === null) return; + cancelAnimationFrame(pendingRefresh); + pendingRefresh = null; +} + +/** Reuse a font already present in the active basemap to avoid glyph 404s. */ +function pickTextFont(activeMap: MapLibreMap): string[] { + if (cachedTextFont) return cachedTextFont; + let fallback: string[] | null = null; + for (const layer of activeMap.getStyle()?.layers ?? []) { + if (layer.id === LABEL_LAYER_ID || layer.type !== "symbol") continue; + const font = (layer.layout as { "text-font"?: string[] } | undefined)?.["text-font"]; + if (!Array.isArray(font) || font.length === 0) continue; + if (font.every((name) => !/italic|bold/i.test(name))) return (cachedTextFont = font); + fallback ??= font; + } + return (cachedTextFont = fallback ?? ["Open Sans Regular", "Arial Unicode MS Regular"]); +} + +export function setTilecodeLabels(next: Partial): void { + labels = { ...labels, ...next }; + if (panelContainer) renderPanel(panelContainer); +} + +export function getTilecodeGridSettings(): TilecodeGridSettings { + return { ...settings }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function color(value: unknown, fallback: string): string { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) + ? value.toLowerCase() + : fallback; +} + +/** + * The automatic zoom→tile-zoom rule, mirroring vgrid-maplibre's TilecodeGrid + * (https://www.npmjs.com/package/vgrid-maplibre): tiles one zoom level finer + * than the map, clamped to the supported range. + */ +export function tilecodeResolutionForZoom(zoom: number): number { + return Math.min(MAX_TILECODE_ZOOM, Math.max(MIN_TILECODE_ZOOM, Math.floor(zoom) + 1)); +} + +/** The tile zoom actually rendered: map-derived when automatic, else manual. */ +function effectiveResolution(): number { + return settings.autoResolution && map + ? tilecodeResolutionForZoom(map.getZoom()) + : settings.resolution; +} + +export function normalizeTilecodeGridSettings(value: unknown): TilecodeGridSettings { + const candidate = (value ?? {}) as Partial; + return { + autoResolution: + typeof candidate.autoResolution === "boolean" + ? candidate.autoResolution + : DEFAULT_TILECODE_GRID_SETTINGS.autoResolution, + resolution: Math.round( + clampNumber( + candidate.resolution, + MIN_TILECODE_ZOOM, + MAX_TILECODE_ZOOM, + DEFAULT_TILECODE_GRID_SETTINGS.resolution, + ), + ), + fillColor: color(candidate.fillColor, DEFAULT_TILECODE_GRID_SETTINGS.fillColor), + fillOpacity: clampNumber( + candidate.fillOpacity, + 0, + 1, + DEFAULT_TILECODE_GRID_SETTINGS.fillOpacity, + ), + lineColor: color(candidate.lineColor, DEFAULT_TILECODE_GRID_SETTINGS.lineColor), + lineWidth: clampNumber(candidate.lineWidth, 0.1, 8, DEFAULT_TILECODE_GRID_SETTINGS.lineWidth), + showLabels: + typeof candidate.showLabels === "boolean" + ? candidate.showLabels + : DEFAULT_TILECODE_GRID_SETTINGS.showLabels, + includeNeighbors: + typeof candidate.includeNeighbors === "boolean" + ? candidate.includeNeighbors + : DEFAULT_TILECODE_GRID_SETTINGS.includeNeighbors, + includeParent: + typeof candidate.includeParent === "boolean" + ? candidate.includeParent + : DEFAULT_TILECODE_GRID_SETTINGS.includeParent, + }; +} + +/** + * Avoid thousands of overlapping IDs when the grid is viewed globally. In + * automatic mode tiles are one zoom finer than the map, so labels are always + * on; the floor only matters for a fixed fine resolution at a wide view. + */ +export function tilecodeLabelMinZoom(resolution: number): number { + return Math.min(24, Math.max(2, Math.round(resolution) - 1)); +} + +export function setTilecodeGridSettings(patch: Partial): void { + const previousResolution = effectiveResolution(); + // Leaving automatic mode adopts the current map-derived tile zoom as the + // fixed one, so the grid stays put instead of jumping to the stale slider. + if (settings.autoResolution && patch.autoResolution === false && patch.resolution === undefined) { + patch = { ...patch, resolution: previousResolution }; + } + settings = normalizeTilecodeGridSettings({ ...settings, ...patch }); + const resolution = effectiveResolution(); + // Re-derive the selection only for an explicit zoom-level change; toggling + // automatic resolution (like zooming in automatic mode) keeps the clicked + // tile and its neighbors/parent as they are. + if (selectedCell && patch.resolution !== undefined && resolution !== previousResolution) { + const tile = tilecodeToTile(selectedCell); + if (tile) { + const [west, south, east, north] = tileBounds(tile); + selectedCell = tileToTilecode( + pointToTile((south + north) / 2, (west + east) / 2, resolution), + ); + } + } + // Only the rendered tile zoom changes the geometry, so a paint/layout-only + // edit skips rebuilding up to TILECODE_VIEWPORT_CELL_LIMIT features. + if (resolution !== previousResolution) { + refresh(); + } else { + applyStyle(); + updateSelectedSource(); + } + if (panelContainer) renderPanel(panelContainer); +} + +/** [west, south, east, north] of a tile in degrees. */ +function tileBounds([x, y, z]: Tile): [number, number, number, number] { + return [tileToLng(x, z), tileToLat(y + 1, z), tileToLng(x + 1, z), tileToLat(y, z)]; +} + +/** + * Convert a tile to a GeoJSON polygon with export attributes (tilecode and + * quadkey IDs, like vgrid-maplibre). `lngOffset` (a multiple of 360) places + * the ring in the world copy a dateline-crossing viewport is looking at. + */ +export function tilecodeCellFeature(cell: string, lngOffset = 0): Feature { + const tile = tilecodeToTile(cell); + if (!tile) throw new Error(`Invalid tilecode: ${cell}`); + const [west, south, east, north] = tileBounds(tile); + return { + type: "Feature", + id: cell, + properties: { + tilecode: cell, + quadkey: tileToQuadkey(tile), + resolution: tile[2], + center_lat: (south + north) / 2, + center_lng: (west + east) / 2, + }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [west + lngOffset, south], + [east + lngOffset, south], + [east + lngOffset, north], + [west + lngOffset, north], + [west + lngOffset, south], + ], + ], + }, + }; +} + +/** + * Fill a WGS84 bounding box with web-mercator tiles, mirroring + * vgrid-maplibre's TilecodeGrid. Longitudes may run past ±180 (MapLibre's + * continuous bounds); the tile columns walk the continuous range and each is + * normalized into [0, 2^z) for its ID but drawn in the viewport's world copy. + */ +export function tilecodeGridForBounds( + bounds: [number, number, number, number], + resolution: number, + limit = TILECODE_VIEWPORT_CELL_LIMIT, +): FeatureCollection { + let [west, south, east, north] = bounds; + south = Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, south)); + north = Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, north)); + if (east - west >= 360) { + west = -180; + east = 180 - 1e-9; + } + const size = 2 ** resolution; + // Unwrapped (continuous) tile columns so dateline-crossing views keep their + // world copy; rows come from the mercator projection of the lat range. + const minColumn = Math.floor(size * (west / 360 + 0.5)); + const maxColumn = Math.floor(size * (east / 360 + 0.5)); + const [, minRow] = pointToTile(north, 0, resolution); + const [, maxRow] = pointToTile(south, 0, resolution); + + if ((maxColumn - minColumn + 1) * (maxRow - minRow + 1) > limit * 1.2) { + throw new RangeError(`Tilecode tile limit exceeded: ${limit}`); + } + + const features: Feature[] = []; + for (let column = minColumn; column <= maxColumn; column++) { + const x = ((column % size) + size) % size; + const lngOffset = ((column - x) / size) * 360; + for (let y = minRow; y <= maxRow; y++) { + features.push(tilecodeCellFeature(tileToTilecode([x, y, resolution]), lngOffset)); + if (features.length > limit) { + throw new RangeError(`Tilecode tile limit exceeded: ${limit}`); + } + } + } + return { type: "FeatureCollection", features }; +} + +/** + * Tiles form a strict quadtree, so a tile has exactly one parent (null at + * the z0 root). + */ +export function tilecodeParentCell(cell: string): string | null { + const tile = tilecodeToTile(cell); + if (!tile || tile[2] <= MIN_TILECODE_ZOOM) return null; + return tileToTilecode([tile[0] >> 1, tile[1] >> 1, tile[2] - 1]); +} + +/** + * The tile plus its (up to 8) surrounding tiles: x wraps around the world, + * y is clipped at the mercator top and bottom rows. + */ +export function tilecodeNeighborCells(cell: string): string[] { + const tile = tilecodeToTile(cell); + if (!tile) return [cell]; + const [x, y, z] = tile; + const size = 2 ** z; + const ids = new Set([cell]); + for (const dx of [-1, 0, 1]) { + for (const dy of [-1, 0, 1]) { + if (dx === 0 && dy === 0) continue; + const ny = y + dy; + if (ny < 0 || ny >= size) continue; + const nx = (((x + dx) % size) + size) % size; + ids.add(tileToTilecode([nx, ny, z])); + } + } + return [...ids]; +} + +function removeLayers(activeMap: MapLibreMap): void { + for (const id of [ + SELECTED_LINE_LAYER_ID, + SELECTED_FILL_LAYER_ID, + PARENT_LINE_LAYER_ID, + LABEL_LAYER_ID, + LINE_LAYER_ID, + FILL_LAYER_ID, + ]) { + if (activeMap.getLayer(id)) activeMap.removeLayer(id); + } + for (const id of [SELECTED_SOURCE_ID, PARENT_SOURCE_ID, SOURCE_ID]) { + if (activeMap.getSource(id)) activeMap.removeSource(id); + } +} + +function ensureLayers(): void { + if (!map) return; + if (!map.getSource(SOURCE_ID)) { + map.addSource(SOURCE_ID, { type: "geojson", data: currentGrid }); + map.addLayer({ + id: FILL_LAYER_ID, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": settings.fillColor, "fill-opacity": settings.fillOpacity }, + }); + map.addLayer({ + id: LINE_LAYER_ID, + type: "line", + source: SOURCE_ID, + paint: { "line-color": settings.lineColor, "line-width": settings.lineWidth }, + }); + map.addLayer({ + id: LABEL_LAYER_ID, + type: "symbol", + source: SOURCE_ID, + minzoom: tilecodeLabelMinZoom(effectiveResolution()), + layout: { + "text-field": ["get", "tilecode"], + "text-font": pickTextFont(map), + "text-size": 10, + visibility: settings.showLabels ? "visible" : "none", + }, + paint: { + "text-color": settings.lineColor, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }, + }); + } + // Added before the selected layers so the selected tile stays on top of its + // (larger, surrounding) parent. + if (!map.getSource(PARENT_SOURCE_ID)) { + map.addSource(PARENT_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: PARENT_LINE_LAYER_ID, + type: "line", + source: PARENT_SOURCE_ID, + paint: { + "line-color": "#f59e0b", + "line-width": SELECTED_LINE_WIDTH * 2, + "line-dasharray": [2, 2], + }, + }); + } + if (!map.getSource(SELECTED_SOURCE_ID)) { + map.addSource(SELECTED_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: SELECTED_FILL_LAYER_ID, + type: "fill", + source: SELECTED_SOURCE_ID, + paint: { "fill-color": "#f59e0b", "fill-opacity": 0.25 }, + }); + map.addLayer({ + id: SELECTED_LINE_LAYER_ID, + type: "line", + source: SELECTED_SOURCE_ID, + paint: { "line-color": "#f59e0b", "line-width": SELECTED_LINE_WIDTH }, + }); + } +} + +function applyStyle(): void { + if (!map) return; + ensureLayers(); + map.setPaintProperty(FILL_LAYER_ID, "fill-color", settings.fillColor); + map.setPaintProperty(FILL_LAYER_ID, "fill-opacity", settings.fillOpacity); + map.setPaintProperty(LINE_LAYER_ID, "line-color", settings.lineColor); + map.setPaintProperty(LINE_LAYER_ID, "line-width", settings.lineWidth); + map.setPaintProperty(LABEL_LAYER_ID, "text-color", settings.lineColor); + map.setLayoutProperty(LABEL_LAYER_ID, "visibility", settings.showLabels ? "visible" : "none"); + map.setLayerZoomRange(LABEL_LAYER_ID, tilecodeLabelMinZoom(effectiveResolution()), 24); +} + +function refresh(): void { + if (!map) return; + const resolution = effectiveResolution(); + // The selected tile (and its neighbors/parent) deliberately stays at the + // zoom level it was clicked at: in automatic mode a zoom or pan changes the + // rendered grid, but re-deriving the selection would silently replace the + // tile the user identified. Only an explicit settings change re-indexes it + // (see setTilecodeGridSettings). + try { + const bounds = map.getBounds(); + currentGrid = tilecodeGridForBounds( + [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], + resolution, + ); + currentError = null; + } catch (error) { + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = + error instanceof RangeError + ? labels.tooManyCells(TILECODE_VIEWPORT_CELL_LIMIT) + : String(error); + } + applyStyle(); + (map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); +} + +function selectedCells(): string[] { + if (!selectedCell) return []; + return settings.includeNeighbors ? tilecodeNeighborCells(selectedCell) : [selectedCell]; +} + +function updateSelectedSource(): void { + const source = map?.getSource(SELECTED_SOURCE_ID) as GeoJSONSource | undefined; + source?.setData({ + type: "FeatureCollection", + features: selectedCells().map((cell) => tilecodeCellFeature(cell)), + }); + const parent = settings.includeParent && selectedCell ? tilecodeParentCell(selectedCell) : null; + const parentSource = map?.getSource(PARENT_SOURCE_ID) as GeoJSONSource | undefined; + parentSource?.setData({ + type: "FeatureCollection", + features: parent ? [tilecodeCellFeature(parent)] : [], + }); +} + +function gridCsv(grid: FeatureCollection): string { + const header = "tilecode,quadkey,resolution,center_lat,center_lng"; + const rows = grid.features.map((feature) => { + const p = feature.properties!; + return [p.tilecode, p.quadkey, p.resolution, p.center_lat, p.center_lng].join(","); + }); + return [header, ...rows].join("\n"); +} + +function fitSelected(): void { + if (!selectedCell || !appRef) return; + const tile = tilecodeToTile(selectedCell); + if (tile) appRef.fitBounds?.(tileBounds(tile)); +} + +function renderPanel(container: HTMLElement): void { + panelContainer = container; + container.replaceChildren(); + container.style.font = "13px/1.4 system-ui, sans-serif"; + + const section = document.createElement("div"); + section.style.display = "grid"; + section.style.gap = "10px"; + section.style.padding = "12px"; + container.appendChild(section); + + const row = (text: string, input: HTMLElement): void => { + const label = document.createElement("label"); + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.justifyContent = "space-between"; + label.style.gap = "12px"; + const span = document.createElement("span"); + span.textContent = text; + label.append(span, input); + section.appendChild(label); + }; + const button = (text: string, action: () => void, disabled = false): HTMLButtonElement => { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = text; + element.disabled = disabled; + element.style.padding = "6px 8px"; + element.style.border = "1px solid hsl(var(--border))"; + element.style.borderRadius = "6px"; + element.style.background = "hsl(var(--background))"; + element.style.color = "inherit"; + element.style.cursor = disabled ? "not-allowed" : "pointer"; + element.style.opacity = disabled ? "0.5" : "1"; + element.style.transition = "background-color 120ms ease, border-color 120ms ease"; + element.addEventListener("mouseenter", () => { + if (!element.disabled) element.style.background = "hsl(var(--muted))"; + }); + element.addEventListener("mouseleave", () => { + element.style.background = "hsl(var(--background))"; + }); + element.addEventListener("click", action); + return element; + }; + + const autoResolution = document.createElement("input"); + autoResolution.type = "checkbox"; + autoResolution.checked = settings.autoResolution; + autoResolution.addEventListener("change", () => + setTilecodeGridSettings({ autoResolution: autoResolution.checked }), + ); + row(labels.autoResolution, autoResolution); + + // In automatic mode the slider becomes a read-only indicator of the + // map-derived tile zoom; refresh() re-renders the panel on every moveend, + // so it tracks zoom gestures. + const shownResolution = effectiveResolution(); + const resolution = document.createElement("input"); + resolution.type = "range"; + resolution.min = String(MIN_TILECODE_ZOOM); + resolution.max = String(MAX_TILECODE_ZOOM); + resolution.value = String(shownResolution); + resolution.title = String(shownResolution); + resolution.disabled = settings.autoResolution; + resolution.addEventListener("input", () => { + resolution.title = resolution.value; + }); + resolution.addEventListener("change", () => + setTilecodeGridSettings({ resolution: Number(resolution.value) }), + ); + const resolutionWrap = document.createElement("span"); + resolutionWrap.style.display = "flex"; + resolutionWrap.style.alignItems = "center"; + resolutionWrap.style.gap = "6px"; + resolutionWrap.style.opacity = settings.autoResolution ? "0.6" : "1"; + const resolutionValue = document.createElement("strong"); + resolutionValue.textContent = String(shownResolution); + resolution.addEventListener("input", () => { + resolutionValue.textContent = resolution.value; + }); + resolutionWrap.append(resolution, resolutionValue); + row(labels.resolution, resolutionWrap); + + for (const [text, key] of [ + [labels.fillColor, "fillColor"], + [labels.lineColor, "lineColor"], + ] as const) { + const input = document.createElement("input"); + input.type = "color"; + input.value = settings[key]; + // `change` (not `input`): setTilecodeGridSettings re-renders the panel, + // which would destroy the picker mid-drag. + input.addEventListener("change", () => setTilecodeGridSettings({ [key]: input.value })); + row(text, input); + } + for (const [text, key, min, max, step] of [ + [labels.fillOpacity, "fillOpacity", 0, 1, 0.05], + [labels.lineWidth, "lineWidth", 0.1, 8, 0.1], + ] as const) { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.value = String(settings[key]); + input.style.width = "72px"; + input.addEventListener("change", () => setTilecodeGridSettings({ [key]: Number(input.value) })); + row(text, input); + } + for (const [text, key] of [ + [labels.showLabels, "showLabels"], + [labels.includeNeighbors, "includeNeighbors"], + [labels.includeParent, "includeParent"], + ] as const) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = settings[key]; + input.addEventListener("change", () => setTilecodeGridSettings({ [key]: input.checked })); + row(text, input); + } + + const status = document.createElement("div"); + status.textContent = currentError ?? labels.cellCount(currentGrid.features.length); + status.style.color = currentError ? "#dc2626" : ""; + section.appendChild(status); + + const hint = document.createElement("div"); + hint.textContent = labels.identifyHint; + hint.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(hint); + + const selectedHeading = document.createElement("strong"); + selectedHeading.textContent = labels.selectedCell; + section.appendChild(selectedHeading); + + const selectedTile = selectedCell ? tilecodeToTile(selectedCell) : null; + if (selectedCell && selectedTile) { + const [west, south, east, north] = tileBounds(selectedTile); + const details = document.createElement("dl"); + details.style.margin = "0"; + details.style.display = "grid"; + details.style.gridTemplateColumns = "auto 1fr"; + details.style.gap = "4px 10px"; + const addDetail = (term: string, value: string): void => { + const dt = document.createElement("dt"); + dt.textContent = term; + dt.style.color = "var(--muted-foreground, #6b7280)"; + const dd = document.createElement("dd"); + dd.textContent = value; + dd.style.margin = "0"; + dd.style.overflowWrap = "anywhere"; + details.append(dt, dd); + }; + addDetail("ID", selectedCell); + addDetail(labels.quadkey, tileToQuadkey(selectedTile) || "—"); + addDetail(labels.resolution, String(selectedTile[2])); + addDetail( + labels.center, + `${((south + north) / 2).toFixed(6)}, ${((west + east) / 2).toFixed(6)}`, + ); + const parent = tilecodeParentCell(selectedCell); + if (parent) addDetail(labels.parent, parent); + if (selectedTile[2] < MAX_TILECODE_ZOOM) { + addDetail(labels.children, String(TILECODE_CHILDREN_PER_CELL)); + } + addDetail(labels.neighbors, String(tilecodeNeighborCells(selectedCell).length - 1)); + section.appendChild(details); + } else { + const empty = document.createElement("div"); + empty.textContent = labels.noSelection; + empty.style.color = "var(--muted-foreground, #6b7280)"; + section.appendChild(empty); + } + + const actions = document.createElement("div"); + actions.style.display = "grid"; + actions.style.gridTemplateColumns = "1fr 1fr"; + actions.style.gap = "6px"; + actions.append( + button( + labels.copyId, + () => { + if (selectedCell) void navigator.clipboard?.writeText(selectedCell); + }, + !selectedCell, + ), + button(labels.zoomToCell, fitSelected, !selectedCell), + button( + labels.addAsLayer, + () => { + if (currentGrid.features.length) { + appRef?.addGeoJsonLayer(`Tilecode (zoom ${effectiveResolution()})`, currentGrid); + } + }, + currentGrid.features.length === 0, + ), + button( + labels.exportGeoJson, + () => { + appRef?.exportTextFile?.( + `tilecode-z${effectiveResolution()}.geojson`, + JSON.stringify(currentGrid, null, 2), + { + description: "GeoJSON", + extensions: ["geojson"], + mimeType: "application/geo+json", + promptName: true, + }, + ); + }, + currentGrid.features.length === 0, + ), + button( + labels.exportCsv, + () => { + appRef?.exportTextFile?.(`tilecode-z${effectiveResolution()}.csv`, gridCsv(currentGrid), { + description: "CSV", + extensions: ["csv"], + mimeType: "text/csv", + promptName: true, + }); + }, + currentGrid.features.length === 0, + ), + ); + section.appendChild(actions); +} + +function settingsEqual(a: TilecodeGridSettings, b: TilecodeGridSettings): boolean { + return Object.keys(a).every( + (key) => a[key as keyof TilecodeGridSettings] === b[key as keyof TilecodeGridSettings], + ); +} + +export const maplibreTilecodePlugin: GeoLibrePlugin = { + id: TILECODE_PLUGIN_ID, + name: "Tilecode", + version: "1.0.0", + activate: (app) => { + const activeMap = app.getMap?.(); + if (!activeMap) return false; + map = activeMap; + appRef = app; + moveHandler = () => scheduleRefresh(); + clickHandler = (event) => { + selectedCell = tileToTilecode( + pointToTile(event.lngLat.lat, event.lngLat.lng, effectiveResolution()), + ); + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }; + activeMap.on("moveend", moveHandler); + activeMap.on("click", clickHandler); + unsubscribeBasemap = app.onBasemapChange(() => { + cachedTextFont = null; + activeMap.once("idle", refresh); + }); + unregisterPanel = + app.registerRightPanel?.({ + id: PANEL_ID, + title: () => labels.getTitle?.() ?? labels.title, + dock: "right-of-style", + defaultWidth: 340, + render: (container) => renderPanel(container), + // Closing the panel ends the identify session: drop the clicked tile + // and, with it, the neighbor/parent overlays derived from it. + onClose: () => { + selectedCell = null; + updateSelectedSource(); + if (panelContainer) renderPanel(panelContainer); + }, + }) ?? null; + refresh(); + app.openRightPanel?.(PANEL_ID); + }, + deactivate: (app) => { + cancelScheduledRefresh(); + if (map && moveHandler) map.off("moveend", moveHandler); + if (map && clickHandler) map.off("click", clickHandler); + unsubscribeBasemap?.(); + unregisterPanel?.(); + if (map) removeLayers(map); + moveHandler = null; + clickHandler = null; + unsubscribeBasemap = null; + unregisterPanel = null; + panelContainer = null; + selectedCell = null; + currentGrid = { type: "FeatureCollection", features: [] }; + currentError = null; + cachedTextFont = null; + map = null; + appRef = null; + app.closeRightPanel?.(PANEL_ID); + }, + getProjectState: () => + settingsEqual(settings, DEFAULT_TILECODE_GRID_SETTINGS) ? undefined : { ...settings }, + applyProjectState: (_app, state) => { + const next = normalizeTilecodeGridSettings(state); + if (settingsEqual(settings, next)) return false; + settings = next; + refresh(); + }, +}; diff --git a/tests/a5-plugin.test.ts b/tests/a5-plugin.test.ts new file mode 100644 index 000000000..cb8853645 --- /dev/null +++ b/tests/a5-plugin.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + A5_VIEWPORT_CELL_LIMIT, + DEFAULT_A5_GRID_SETTINGS, + a5CellFeature, + a5GridForBounds, + a5LabelMinZoom, + a5ResolutionForZoom, + normalizeA5GridSettings, +} from "../packages/plugins/src/plugins/maplibre-a5"; + +describe("A5 grid plugin helpers", () => { + it("normalizes persisted settings", () => { + assert.deepEqual(normalizeA5GridSettings(undefined), DEFAULT_A5_GRID_SETTINGS); + assert.deepEqual( + normalizeA5GridSettings({ + autoResolution: true, + resolution: 99, + fillColor: "#ABCDEF", + fillOpacity: -1, + lineColor: "bad", + lineWidth: 100, + showLabels: false, + includeNeighbors: true, + includeParents: true, + }), + { + autoResolution: true, + resolution: 30, + fillColor: "#abcdef", + fillOpacity: 0, + lineColor: DEFAULT_A5_GRID_SETTINGS.lineColor, + lineWidth: 8, + showLabels: false, + includeNeighbors: true, + includeParents: true, + }, + ); + }); + + it("maps map zoom to an A5 resolution like vgrid-maplibre", () => { + assert.equal(a5ResolutionForZoom(-1), 0); + assert.equal(a5ResolutionForZoom(0.9), 0); + assert.equal(a5ResolutionForZoom(4), 4); + assert.equal(a5ResolutionForZoom(11.7), 11); + assert.equal(a5ResolutionForZoom(31), 30); + }); + + it("only displays labels once the grid has enough screen space", () => { + assert.equal(a5LabelMinZoom(0), 2); + assert.equal(a5LabelMinZoom(4), 5); + assert.equal(a5LabelMinZoom(12), 13); + assert.equal(a5LabelMinZoom(30), 18); + }); + + it("creates export-ready polygon features", () => { + // Derive a valid cell through the plugin's own fill rather than importing + // a5-js here: the test would resolve the repo root's older hoisted copy + // (deck.gl pins ^0.7), not the 0.9 the plugin uses. + const grid = a5GridForBounds([-122.52, 37.7, -122.35, 37.82], 12); + const cell = grid.features[0].properties?.a5 as string; + const feature = a5CellFeature(cell); + assert.equal(feature.geometry.type, "Polygon"); + assert.equal(feature.properties?.a5, cell); + assert.equal(feature.properties?.resolution, 12); + assert.equal(feature.geometry.coordinates[0][0].length, 2); + // Closed ring: the last vertex repeats the first. + const ring = feature.geometry.coordinates[0]; + assert.deepEqual(ring[0], ring[ring.length - 1]); + }); + + it("keeps antimeridian-crossing rings contiguous", () => { + const grid = a5GridForBounds([170, 50, 190, 65], 7); + assert.ok(grid.features.length > 0); + for (const feature of grid.features) { + const lons = feature.geometry.coordinates[0].map(([lng]) => lng); + assert.ok(Math.max(...lons) - Math.min(...lons) < 90, `ring spans ${lons.join(", ")}`); + } + }); + + it("fills a viewport with unique cells at the requested resolution", () => { + const grid = a5GridForBounds([-122.52, 37.7, -122.35, 37.82], 12); + assert.ok(grid.features.length > 0); + assert.ok(grid.features.length < A5_VIEWPORT_CELL_LIMIT); + assert.ok(grid.features.every((feature) => feature.properties?.resolution === 12)); + assert.equal( + new Set(grid.features.map((feature) => feature.properties?.a5)).size, + grid.features.length, + ); + }); + + it("covers full-world bounds", () => { + const grid = a5GridForBounds([-180, -85, 180, 85], 4); + // Resolution 4 has 3,840 cells globally; nearly all centers sit inside ±85°. + assert.ok(grid.features.length > 3_700); + assert.ok(grid.features.length < A5_VIEWPORT_CELL_LIMIT); + }); + + it("covers hemisphere-scale bounds without losing interior cells", () => { + // Large polygons degrade polygonToCells, so views this size take the + // enumerate-and-filter path; half the globe holds ~1,900 resolution-4 cells. + const grid = a5GridForBounds([-180, -85, 0, 85], 4); + assert.ok(grid.features.length > 1_800); + assert.ok(grid.features.length < 2_000); + }); + + it("rejects oversized grids before materializing them", () => { + assert.throws(() => a5GridForBounds([-180, -80, 180, 80], 8), /cell limit exceeded/i); + }); +}); diff --git a/tests/dggal-plugin.test.ts b/tests/dggal-plugin.test.ts new file mode 100644 index 000000000..17efb2d30 --- /dev/null +++ b/tests/dggal-plugin.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + DEFAULT_DGGAL_GRID_SETTINGS, + DGGAL_TYPES, + DGGAL_TYPE_NAMES, + DGGAL_VIEWPORT_CELL_LIMIT, + dggalGridForBounds, + dggalLabelMinZoom, + dggalParentZones, + dggalResolutionForZoom, + dggalZoneFeature, + loadDggal, + normalizeDggalGridSettings, +} from "../packages/plugins/src/plugins/maplibre-dggal"; + +describe("DGGAL plugin helpers", () => { + it("normalizes persisted settings", () => { + assert.deepEqual(normalizeDggalGridSettings(undefined), DEFAULT_DGGAL_GRID_SETTINGS); + assert.deepEqual( + normalizeDggalGridSettings({ + dggrsType: "HEALPix", + autoResolution: false, + resolution: 99, + fillColor: "#ABCDEF", + fillOpacity: -1, + lineColor: "bad", + lineWidth: 100, + showLabels: false, + includeNeighbors: true, + includeParents: true, + }), + { + dggrsType: "HEALPix", + autoResolution: false, + // Clamped to HEALPix's maximum resolution. + resolution: DGGAL_TYPES.HEALPix, + fillColor: "#abcdef", + fillOpacity: 0, + lineColor: DEFAULT_DGGAL_GRID_SETTINGS.lineColor, + lineWidth: 8, + showLabels: false, + includeNeighbors: true, + includeParents: true, + }, + ); + assert.equal( + normalizeDggalGridSettings({ dggrsType: "NotAGrid" }).dggrsType, + DEFAULT_DGGAL_GRID_SETTINGS.dggrsType, + ); + // Inherited Object.prototype keys must not pass as a grid type. + for (const key of ["constructor", "toString", "valueOf"]) { + const normalized = normalizeDggalGridSettings({ dggrsType: key }); + assert.equal(normalized.dggrsType, DEFAULT_DGGAL_GRID_SETTINGS.dggrsType, key); + assert.ok(Number.isInteger(normalized.resolution), key); + } + }); + + it("maps map zoom to a resolution like vgrid-maplibre's per-type rules", () => { + // floor(zoom * factor), clamped to the type's resolution range. + assert.equal(dggalResolutionForZoom(10, "ISEA3H"), 11); // 1.15 + assert.equal(dggalResolutionForZoom(10, "ISEA4R"), 9); // 0.95 + assert.equal(dggalResolutionForZoom(10, "HEALPix"), 9); // 0.95 + assert.equal(dggalResolutionForZoom(10, "ISEA7H"), 6); // 0.65 + assert.equal(dggalResolutionForZoom(10, "ISEA9R"), 6); // 0.6 + assert.equal(dggalResolutionForZoom(10, "rHEALPix"), 6); // 0.6 + assert.equal(dggalResolutionForZoom(10, "GNOSISGlobalGrid"), 10); // 1 + assert.equal(dggalResolutionForZoom(-1, "ISEA3H"), 0); + assert.equal(dggalResolutionForZoom(99, "ISEA9R"), DGGAL_TYPES.ISEA9R); + }); + + it("only displays labels once the grid has enough screen space", () => { + assert.equal(dggalLabelMinZoom(0), 2); + assert.equal(dggalLabelMinZoom(4), 5); + assert.equal(dggalLabelMinZoom(12), 13); + assert.equal(dggalLabelMinZoom(33), 18); + }); + + it("fills a bounding box with zones and creates export-ready features", async () => { + const dggal = await loadDggal(); + const dggrs = dggal.createDGGRS("ISEA3H"); + const grid = dggalGridForBounds(dggrs, [-122.52, 37.7, -122.35, 37.82], 10); + assert.ok(grid.features.length > 0); + const ids = grid.features.map((feature) => feature.properties?.dggal as string); + assert.equal(new Set(ids).size, ids.length); + + const feature = dggalZoneFeature(dggrs, ids[0]); + assert.equal(feature.geometry.type, "Polygon"); + const ring = feature.geometry.coordinates[0]; + assert.deepEqual(ring[0], ring[ring.length - 1]); + assert.equal(feature.properties?.resolution, 10); + assert.ok(Number.isFinite(feature.properties?.center_lat)); + assert.ok(Number.isFinite(feature.properties?.center_lng)); + // The clicked point's zone must be part of the viewport fill. + const D2R = Math.PI / 180; + const clicked = dggrs.getZoneTextID( + dggrs.getZoneFromWGS84Centroid(10, { lat: 37.76 * D2R, lon: -122.43 * D2R }), + ); + assert.ok(ids.includes(clicked)); + dggrs.delete(); + }); + + it("fills bounds for every offered grid type", async () => { + const dggal = await loadDggal(); + for (const name of DGGAL_TYPE_NAMES) { + const dggrs = dggal.createDGGRS(name); + const grid = dggalGridForBounds(dggrs, [-122.52, 37.7, -122.35, 37.82], 5); + assert.ok(grid.features.length > 0, name); + dggrs.delete(); + } + }); + + it("supports antimeridian-crossing bounds with contiguous rings", async () => { + const dggal = await loadDggal(); + const dggrs = dggal.createDGGRS("ISEA3H"); + const grid = dggalGridForBounds(dggrs, [179.6, -0.3, 180.4, 0.3], 8); + assert.ok(grid.features.length > 0); + for (const feature of grid.features) { + const lons = feature.geometry.coordinates[0].map(([lng]) => lng); + // Contiguous rings never span the world even when they cross ±180. + assert.ok(Math.max(...lons) - Math.min(...lons) < 180); + } + dggrs.delete(); + }); + + it("supports wrapped antimeridian bounds (east < west) without duplicates", async () => { + const dggal = await loadDggal(); + const dggrs = dggal.createDGGRS("ISEA3H"); + const grid = dggalGridForBounds(dggrs, [179.6, -0.3, -179.6, 0.3], 8); + assert.ok(grid.features.length > 0); + const ids = grid.features.map((feature) => feature.properties?.dggal as string); + assert.equal(new Set(ids).size, ids.length); + dggrs.delete(); + }); + + it("preserves unwrapped full-world longitude spans", async () => { + const dggal = await loadDggal(); + const dggrs = dggal.createDGGRS("ISEA3H"); + // [0, 360] must not collapse to a zero-width box after normalizeLon. + const grid = dggalGridForBounds(dggrs, [0, -85, 360, 85], 4); + const ids = grid.features.map((feature) => feature.properties?.dggal as string); + assert.equal(new Set(ids).size, ids.length); + assert.equal(ids.length, Number(dggrs.countZones(4))); + dggrs.delete(); + }); + + it("rejects viewports that would exceed the zone limit", async () => { + const dggal = await loadDggal(); + const dggrs = dggal.createDGGRS("ISEA3H"); + assert.throws( + () => dggalGridForBounds(dggrs, [-180, -85, 180, 85], 12, DGGAL_VIEWPORT_CELL_LIMIT), + RangeError, + ); + dggrs.delete(); + }); + + it("filters parent zones to the level above (the binding pads with garbage)", async () => { + const dggal = await loadDggal(); + const dggrs = dggal.createDGGRS("ISEA3H"); + const D2R = Math.PI / 180; + const cell = dggrs.getZoneTextID( + dggrs.getZoneFromWGS84Centroid(8, { lat: 10.8 * D2R, lon: 106.6 * D2R }), + ); + const parents = dggalParentZones(dggrs, cell); + assert.ok(parents.length >= 1); + for (const parent of parents) { + assert.equal(dggrs.getZoneLevel(dggrs.getZoneFromTextID(parent)), 7); + } + dggrs.delete(); + }); +}); diff --git a/tests/dggrid-plugin.test.ts b/tests/dggrid-plugin.test.ts new file mode 100644 index 000000000..c2be31446 --- /dev/null +++ b/tests/dggrid-plugin.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + DEFAULT_DGGRID_GRID_SETTINGS, + DGGRID_CONFIG, + DGGRID_VIEWPORT_CELL_LIMIT, + MAX_DGGRID_RESOLUTION, + dggridCellFeature, + dggridGridForBounds, + dggridLabelMinZoom, + dggridResolutionForZoom, + loadDggrid, + normalizeDggridGridSettings, +} from "../packages/plugins/src/plugins/maplibre-dggrid"; + +describe("DGGRID plugin helpers", () => { + it("normalizes persisted settings", () => { + assert.deepEqual(normalizeDggridGridSettings(undefined), DEFAULT_DGGRID_GRID_SETTINGS); + assert.deepEqual( + normalizeDggridGridSettings({ + topology: "HEXAGON", + projection: "FULLER", + aperture: 7, + autoResolution: false, + resolution: 99, + fillColor: "#ABCDEF", + fillOpacity: -1, + lineColor: "bad", + lineWidth: 100, + showLabels: false, + includeNeighbors: true, + includeParents: true, + }), + { + topology: "HEXAGON", + projection: "FULLER", + aperture: 7, + autoResolution: false, + resolution: MAX_DGGRID_RESOLUTION, + fillColor: "#abcdef", + fillOpacity: 0, + lineColor: DEFAULT_DGGRID_GRID_SETTINGS.lineColor, + lineWidth: 8, + showLabels: false, + includeNeighbors: true, + includeParents: true, + }, + ); + }); + + it("pins non-hexagon topologies to aperture 4 and rejects unknown values", () => { + // Any other aperture aborts the WASM engine for DIAMOND/TRIANGLE grids. + assert.equal(normalizeDggridGridSettings({ topology: "DIAMOND", aperture: 7 }).aperture, 4); + assert.equal(normalizeDggridGridSettings({ topology: "TRIANGLE", aperture: 3 }).aperture, 4); + const defaulted = normalizeDggridGridSettings({ + topology: "SQUARE", + projection: "MERCATOR", + aperture: 5, + }); + assert.equal(defaulted.topology, DEFAULT_DGGRID_GRID_SETTINGS.topology); + assert.equal(defaulted.projection, DEFAULT_DGGRID_GRID_SETTINGS.projection); + assert.equal(defaulted.aperture, DEFAULT_DGGRID_GRID_SETTINGS.aperture); + }); + + it("maps map zoom to a resolution like vgrid-maplibre's aperture rules", () => { + // floor(zoom * factor), factor 0.95 for aperture 4, clamped to [0, 21]. + assert.equal(dggridResolutionForZoom(-1), 0); + assert.equal(dggridResolutionForZoom(0.9), 0); + assert.equal(dggridResolutionForZoom(4), 3); + assert.equal(dggridResolutionForZoom(10), 9); + assert.equal(dggridResolutionForZoom(30), MAX_DGGRID_RESOLUTION); + // Aperture 3 subdivides more slowly (factor 1.15), aperture 7 faster (0.65). + assert.equal(dggridResolutionForZoom(10, 3), 11); + assert.equal(dggridResolutionForZoom(10, 7), 6); + }); + + it("only displays labels once the grid has enough screen space", () => { + assert.equal(dggridLabelMinZoom(0), 2); + assert.equal(dggridLabelMinZoom(4), 5); + assert.equal(dggridLabelMinZoom(12), 13); + assert.equal(dggridLabelMinZoom(21), 18); + }); + + it("fills a bounding box with cells and creates export-ready features", async () => { + const engine = await loadDggrid(); + const grid = dggridGridForBounds(engine, [-122.52, 37.7, -122.35, 37.82], 10); + assert.ok(grid.features.length > 0); + const ids = grid.features.map((feature) => feature.properties?.dggrid as string); + assert.equal(new Set(ids).size, ids.length); + + const feature = dggridCellFeature(engine, ids[0], 10); + assert.equal(feature.geometry.type, "Polygon"); + const ring = feature.geometry.coordinates[0]; + assert.deepEqual(ring[0], ring[ring.length - 1]); + assert.equal(feature.properties?.resolution, 10); + assert.ok(Number.isFinite(feature.properties?.center_lat)); + assert.ok(Number.isFinite(feature.properties?.center_lng)); + // The clicked point's cell must be part of the viewport fill. + const centerCell = engine.geoToSequenceNum([[-122.43, 37.76]], 10)[0].toString(); + assert.ok(ids.includes(centerCell)); + }); + + it("supports antimeridian-crossing bounds", async () => { + const engine = await loadDggrid(); + const grid = dggridGridForBounds(engine, [179.6, -0.3, -179.6, 0.3], 8); + assert.ok(grid.features.length > 0); + // A cell that only clips the 0.8° x 0.6° box must stay contiguous. + for (const feature of grid.features) { + const lons = feature.geometry.coordinates[0].map(([lng]) => lng); + assert.ok(Math.max(...lons) - Math.min(...lons) < 180); + } + // The box is tiny, so a normalization bug that collects distant cells fails here. + assert.ok(grid.features.length < 200, String(grid.features.length)); + }); + + it("fills bounds for the other cell types and projections", async () => { + const engine = await loadDggrid(); + const bounds: [number, number, number, number] = [-122.52, 37.7, -122.35, 37.82]; + const base = { poleCoordinates: { lat: 0, lng: 0 }, azimuth: 0 } as const; + for (const config of [ + { ...base, topology: "DIAMOND", projection: "ISEA", aperture: 4 }, + { ...base, topology: "HEXAGON", projection: "FULLER", aperture: 4 }, + { ...base, topology: "HEXAGON", projection: "ISEA", aperture: 3 }, + { ...base, topology: "HEXAGON", projection: "ISEA", aperture: 7 }, + // TRIANGLE has no neighbor lookup, exercising the sampling fallback. + { ...base, topology: "TRIANGLE", projection: "ISEA", aperture: 4 }, + ] as const) { + const grid = dggridGridForBounds(engine, bounds, 8, undefined, config); + assert.ok( + grid.features.length > 0, + `${config.topology}/${config.projection}/${config.aperture}`, + ); + const ids = grid.features.map((feature) => feature.properties?.dggrid as string); + assert.equal(new Set(ids).size, ids.length); + } + }); + + it("rejects viewports that would exceed the cell limit", async () => { + const engine = await loadDggrid(); + assert.throws( + () => dggridGridForBounds(engine, [-180, -85, 180, 85], 12, DGGRID_VIEWPORT_CELL_LIMIT), + RangeError, + ); + }); + + it("returns every overlapping direct parent, canonical first", async () => { + const engine = await loadDggrid(); + engine.setDggs({ ...DGGRID_CONFIG }, 5); + const [cell] = engine.geoToSequenceNum([[106.6, 10.8]], 5); + const parents = engine.sequenceNumAllParents([cell], 5)[0]; + assert.ok(parents.length >= 1); + assert.equal(parents[0], engine.sequenceNumParent([cell], 5)[0]); + }); +}); diff --git a/tests/geohash-plugin.test.ts b/tests/geohash-plugin.test.ts new file mode 100644 index 000000000..195856e3a --- /dev/null +++ b/tests/geohash-plugin.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import geohash from "ngeohash"; +import { + DEFAULT_GEOHASH_GRID_SETTINGS, + GEOHASH_CHILDREN_PER_CELL, + GEOHASH_VIEWPORT_CELL_LIMIT, + MAX_GEOHASH_PRECISION, + MIN_GEOHASH_PRECISION, + geohashCellFeature, + geohashGridForBounds, + geohashLabelMinZoom, + geohashNeighborCells, + geohashParentCell, + geohashResolutionForZoom, + normalizeGeohashGridSettings, +} from "../packages/plugins/src/plugins/maplibre-geohash"; + +describe("Geohash plugin helpers", () => { + it("normalizes persisted settings", () => { + assert.deepEqual(normalizeGeohashGridSettings(undefined), DEFAULT_GEOHASH_GRID_SETTINGS); + assert.deepEqual( + normalizeGeohashGridSettings({ + autoResolution: false, + resolution: 99, + fillColor: "#ABCDEF", + fillOpacity: -1, + lineColor: "bad", + lineWidth: 100, + showLabels: false, + includeNeighbors: true, + includeParent: true, + }), + { + autoResolution: false, + resolution: MAX_GEOHASH_PRECISION, + fillColor: "#abcdef", + fillOpacity: 0, + lineColor: DEFAULT_GEOHASH_GRID_SETTINGS.lineColor, + lineWidth: 8, + showLabels: false, + includeNeighbors: true, + includeParent: true, + }, + ); + assert.equal(normalizeGeohashGridSettings({ resolution: 0 }).resolution, MIN_GEOHASH_PRECISION); + }); + + it("maps map zoom to a precision like vgrid-maplibre", () => { + // floor(zoom * 0.45), clamped to [1, 12]. + assert.equal(geohashResolutionForZoom(0), 1); + assert.equal(geohashResolutionForZoom(2), 1); + assert.equal(geohashResolutionForZoom(5), 2); + assert.equal(geohashResolutionForZoom(10), 4); + assert.equal(geohashResolutionForZoom(20), 9); + assert.equal(geohashResolutionForZoom(30), 12); + }); + + it("only displays labels once the grid has enough screen space", () => { + assert.equal(geohashLabelMinZoom(1), 2); + assert.equal(geohashLabelMinZoom(4), 8); + assert.equal(geohashLabelMinZoom(12), 22); + }); + + it("fills a bounding box with cells and creates export-ready features", () => { + const grid = geohashGridForBounds([-122.52, 37.7, -122.35, 37.82], 5); + assert.ok(grid.features.length > 0); + const ids = grid.features.map((feature) => feature.properties?.geohash as string); + assert.equal(new Set(ids).size, ids.length); + + const feature = geohashCellFeature(ids[0]); + assert.equal(feature.geometry.type, "Polygon"); + const ring = feature.geometry.coordinates[0]; + assert.equal(ring.length, 5); + assert.deepEqual(ring[0], ring[ring.length - 1]); + assert.equal(feature.properties?.resolution, 5); + + // The clicked point's cell must be part of the viewport fill. + const clicked = geohash.encode(37.76, -122.43, 5); + assert.ok(ids.includes(clicked)); + + const epsilon = 1e-9; + const covers = (lat: number, lng: number) => + grid.features.some((f) => { + const [w, s] = f.geometry.coordinates[0][0]; + const [e, n] = f.geometry.coordinates[0][2]; + return lng >= w - epsilon && lng <= e + epsilon && lat >= s - epsilon && lat <= n + epsilon; + }); + assert.ok(covers(37.7, -122.52)); + assert.ok(covers(37.82, -122.35)); + }); + + it("fills bounds at every precision cheaply enough to render", () => { + for (let precision = MIN_GEOHASH_PRECISION; precision <= MAX_GEOHASH_PRECISION; precision++) { + const [south, west, north, east] = geohash.decode_bbox( + geohash.encode(10.78, 106.7, precision), + ); + const w = east - west; + const h = north - south; + // A ~7×7-cell viewport around the point; at precision 1 the 45° cells + // reach the poles, so the fill is clipped and returns fewer features. + const grid = geohashGridForBounds( + [106.7 - 3 * w, 10.78 - 3 * h, 106.7 + 3 * w, 10.78 + 3 * h], + precision, + ); + assert.ok(grid.features.length >= 16, `precision ${precision}: ${grid.features.length}`); + for (const feature of grid.features) { + assert.equal(feature.properties?.resolution, precision); + } + } + }); + + it("draws antimeridian-crossing viewports in the correct world copy", () => { + // MapLibre reports continuous bounds (east past 180) across the dateline. + const grid = geohashGridForBounds([179.3, -0.3, 180.7, 0.3], 3); + assert.ok(grid.features.length > 0); + const easternIds = new Set(); + for (const feature of grid.features) { + const lons = feature.geometry.coordinates[0].map(([lng]) => lng); + // Rings stay contiguous and inside the viewport's continuous range. + assert.ok(Math.max(...lons) - Math.min(...lons) < 180); + assert.ok(Math.min(...lons) >= 178 && Math.max(...lons) <= 182); + if (Math.max(...lons) > 180) easternIds.add(feature.properties?.geohash as string); + } + // Cells past the dateline carry the IDs of their normalized twins. + assert.ok(easternIds.has(geohash.encode(0.5, -179.5, 3))); + }); + + it("rejects viewports that would exceed the cell limit", () => { + assert.throws( + () => geohashGridForBounds([-180, -85, 180, 85], 6, GEOHASH_VIEWPORT_CELL_LIMIT), + RangeError, + ); + }); + + it("finds the single parent and neighbors of a cell", () => { + const cell = geohash.encode(10.78, 106.7, 6); + assert.equal(geohashParentCell(cell), cell.slice(0, -1)); + assert.equal(geohashParentCell(geohash.encode(10.78, 106.7, 1)), null); + + const neighbors = geohashNeighborCells(cell); + assert.ok(neighbors.includes(cell)); + assert.equal(neighbors.length, 9); + for (const neighbor of neighbors) { + assert.equal(neighbor.length, 6); + } + // Every cell subdivides into 32 children. + assert.equal(GEOHASH_CHILDREN_PER_CELL, 32); + }); +}); diff --git a/tests/h3-plugin.test.ts b/tests/h3-plugin.test.ts index 9614f3a32..33e46aee9 100644 --- a/tests/h3-plugin.test.ts +++ b/tests/h3-plugin.test.ts @@ -4,11 +4,11 @@ import { DEFAULT_H3_GRID_SETTINGS, H3_VIEWPORT_CELL_LIMIT, h3CellFeature, - h3BoundaryGeometry, + h3FixTransmeridianBoundary, h3GridForBounds, h3LabelMinZoom, + h3ResolutionForZoom, normalizeH3GridSettings, - unwrapH3Boundary, } from "../packages/plugins/src/plugins/maplibre-h3"; describe("H3 grid plugin helpers", () => { @@ -16,6 +16,7 @@ describe("H3 grid plugin helpers", () => { assert.deepEqual(normalizeH3GridSettings(undefined), DEFAULT_H3_GRID_SETTINGS); assert.deepEqual( normalizeH3GridSettings({ + autoResolution: true, resolution: 99, fillColor: "#ABCDEF", fillOpacity: -1, @@ -23,8 +24,11 @@ describe("H3 grid plugin helpers", () => { lineWidth: 100, showLabels: false, includeNeighbors: true, + includeParents: true, + showIcosahedron: true, }), { + autoResolution: true, resolution: 15, fillColor: "#abcdef", fillOpacity: 0, @@ -32,10 +36,21 @@ describe("H3 grid plugin helpers", () => { lineWidth: 8, showLabels: false, includeNeighbors: true, + includeParents: true, + showIcosahedron: true, }, ); }); + it("derives the automatic resolution from the map zoom (vgrid-maplibre rule)", () => { + assert.equal(h3ResolutionForZoom(0), 0); + assert.equal(h3ResolutionForZoom(3), 0); + assert.equal(h3ResolutionForZoom(5), 1); + assert.equal(h3ResolutionForZoom(10), 6); + assert.equal(h3ResolutionForZoom(20), 15); + assert.equal(h3ResolutionForZoom(30), 15); + }); + it("only displays labels once the grid has enough screen space", () => { assert.equal(h3LabelMinZoom(0), 3); assert.equal(h3LabelMinZoom(2), 5); @@ -68,47 +83,37 @@ describe("H3 grid plugin helpers", () => { assert.ok(grid.features.length < 100); }); - it("unwraps dateline cell boundaries instead of drawing across the world", () => { + it("keeps dateline cell boundaries contiguous instead of drawing across the world", () => { + // vgrid-maplibre's fix: a straddling ring becomes one contiguous Polygon + // around -180, with the eastern vertices shifted into the adjacent world + // copy (longitudes just below -180) rather than clipped at the seam. const feature = h3CellFeature("824797fffffffff"); - assert.equal(feature.geometry.type, "MultiPolygon"); - const coordinates = feature.geometry.coordinates.flat(2); - assert.ok(coordinates.every(([longitude]) => longitude >= -180 && longitude <= 180)); - for (const polygon of feature.geometry.coordinates) { - const longitudes = polygon[0].map(([longitude]) => longitude); - // A side that clips to nothing must be dropped, never emitted as an - // empty or unclosed linear ring. - assert.ok(polygon[0].length >= 4); - assert.deepEqual(polygon[0].at(0), polygon[0].at(-1)); - assert.ok(Math.max(...longitudes) - Math.min(...longitudes) < 10); - } + assert.equal(feature.geometry.type, "Polygon"); + const ring = feature.geometry.coordinates[0]; + assert.ok(ring.length >= 4); + assert.deepEqual(ring.at(0), ring.at(-1)); + const longitudes = ring.map(([longitude]) => longitude); + assert.ok(Math.max(...longitudes) - Math.min(...longitudes) < 10); assert.deepEqual( - unwrapH3Boundary( - [ - [179, 0], - [-179, 1], - [179, 0], - ], - 179.5, - ), - [ + h3FixTransmeridianBoundary([ [179, 0], - [181, 1], + [-179, 1], [179, 0], + ]), + [ + [-181, 0], + [-179, 1], + [-181, 0], ], ); - assert.equal( - h3BoundaryGeometry( - [ - [179, 0], - [-179, 1], - [-179, -1], - [179, 0], - ], - 179.5, - ).type, - "MultiPolygon", - ); + // Rings away from the seam pass through untouched. + const plain: [number, number][] = [ + [-122.5, 37.7], + [-122.4, 37.8], + [-122.5, 37.7], + ]; + assert.deepEqual(h3FixTransmeridianBoundary(plain), plain); }); it("does not collapse full-world bounds onto the antimeridian", () => { diff --git a/tests/olc-plugin.test.ts b/tests/olc-plugin.test.ts new file mode 100644 index 000000000..3ee7d1531 --- /dev/null +++ b/tests/olc-plugin.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import OpenLocationCodeModule from "open-location-code-typescript"; + +// Same CJS/ESM default-interop normalization the plugin performs. +const OpenLocationCode = ((OpenLocationCodeModule as { default?: unknown }).default ?? + OpenLocationCodeModule) as typeof OpenLocationCodeModule; +import { + DEFAULT_OLC_GRID_SETTINGS, + OLC_CODE_LENGTHS, + OLC_VIEWPORT_CELL_LIMIT, + olcCellFeature, + olcChildCount, + olcGridForBounds, + olcLabelMinZoom, + olcNeighborCells, + olcParentCell, + olcResolutionForZoom, + normalizeOlcGridSettings, +} from "../packages/plugins/src/plugins/maplibre-olc"; + +describe("OLC plugin helpers", () => { + it("normalizes persisted settings", () => { + assert.deepEqual(normalizeOlcGridSettings(undefined), DEFAULT_OLC_GRID_SETTINGS); + assert.deepEqual( + normalizeOlcGridSettings({ + autoResolution: false, + resolution: 99, + fillColor: "#ABCDEF", + fillOpacity: -1, + lineColor: "bad", + lineWidth: 100, + showLabels: false, + includeNeighbors: true, + includeParent: true, + }), + { + autoResolution: false, + resolution: 15, + fillColor: "#abcdef", + fillOpacity: 0, + lineColor: DEFAULT_OLC_GRID_SETTINGS.lineColor, + lineWidth: 8, + showLabels: false, + includeNeighbors: true, + includeParent: true, + }, + ); + // Odd lengths are not valid full codes; snap to the nearest valid one. + assert.equal(normalizeOlcGridSettings({ resolution: 9 }).resolution, 8); + assert.equal(normalizeOlcGridSettings({ resolution: 3 }).resolution, 2); + }); + + it("maps map zoom to a code length like vgrid-maplibre", () => { + assert.equal(olcResolutionForZoom(0), 2); + assert.equal(olcResolutionForZoom(6), 2); + assert.equal(olcResolutionForZoom(7), 4); + assert.equal(olcResolutionForZoom(12), 6); + assert.equal(olcResolutionForZoom(16), 8); + assert.equal(olcResolutionForZoom(20), 10); + assert.equal(olcResolutionForZoom(23), 11); + assert.equal(olcResolutionForZoom(30), 15); + }); + + it("only displays labels once the grid has enough screen space", () => { + assert.equal(olcLabelMinZoom(2), 2); + assert.equal(olcLabelMinZoom(8), 13); + assert.equal(olcLabelMinZoom(15), 24); + }); + + it("fills a bounding box with cells and creates export-ready features", () => { + const grid = olcGridForBounds([-122.52, 37.7, -122.35, 37.82], 6); + assert.ok(grid.features.length > 0); + const ids = grid.features.map((feature) => feature.properties?.olc as string); + assert.equal(new Set(ids).size, ids.length); + + const feature = olcCellFeature(ids[0]); + assert.equal(feature.geometry.type, "Polygon"); + const ring = feature.geometry.coordinates[0]; + assert.equal(ring.length, 5); + assert.deepEqual(ring[0], ring[ring.length - 1]); + assert.equal(feature.properties?.resolution, 6); + + // The clicked point's cell must be part of the viewport fill. + const clicked = OpenLocationCode.encode(37.76, -122.43, 6); + assert.ok(ids.includes(clicked)); + // Cells cover the whole viewport including its corners (decode + // reconstructs cell bounds with float jitter, hence the epsilon). + const epsilon = 1e-9; + const covers = (lat: number, lng: number) => + grid.features.some((f) => { + const [w, s] = f.geometry.coordinates[0][0]; + const [e, n] = f.geometry.coordinates[0][2]; + return lng >= w - epsilon && lng <= e + epsilon && lat >= s - epsilon && lat <= n + epsilon; + }); + assert.ok(covers(37.7, -122.52)); + assert.ok(covers(37.82, -122.35)); + }); + + it("fills bounds at every valid code length cheaply enough to render", () => { + for (const length of OLC_CODE_LENGTHS) { + // A viewport sized to the cells so the fill stays under the cap. + const area = OpenLocationCode.decode(OpenLocationCode.encode(10.78, 106.7, length)); + const w = area.getLongitudeWidth(); + const h = area.getLatitudeHeight(); + const grid = olcGridForBounds( + [106.7 - 3 * w, 10.78 - 3 * h, 106.7 + 3 * w, 10.78 + 3 * h], + length, + ); + assert.ok(grid.features.length >= 36, `length ${length}: ${grid.features.length}`); + for (const feature of grid.features) { + assert.equal(feature.properties?.resolution, length); + } + } + }); + + it("draws antimeridian-crossing viewports in the correct world copy", () => { + // MapLibre reports continuous bounds (east past 180) across the dateline. + const grid = olcGridForBounds([179.3, -0.3, 180.7, 0.3], 4); + assert.ok(grid.features.length > 0); + const easternIds = new Set(); + for (const feature of grid.features) { + const lons = feature.geometry.coordinates[0].map(([lng]) => lng); + // Rings stay contiguous and inside the viewport's continuous range. + assert.ok(Math.max(...lons) - Math.min(...lons) < 180); + assert.ok(Math.min(...lons) >= 178 && Math.max(...lons) <= 182); + if (Math.max(...lons) > 180) easternIds.add(feature.properties?.olc as string); + } + // Cells past the dateline carry the IDs of their normalized twins. + assert.ok(easternIds.has(OpenLocationCode.encode(0.5, -179.5, 4))); + }); + + it("rejects viewports that would exceed the cell limit", () => { + assert.throws( + () => olcGridForBounds([-180, -85, 180, 85], 8, OLC_VIEWPORT_CELL_LIMIT), + RangeError, + ); + }); + + it("finds the single parent, child count, and neighbors of a cell", () => { + const cell = OpenLocationCode.encode(10.78, 106.7, 8); + const parent = olcParentCell(cell); + assert.equal(parent, OpenLocationCode.encode(10.78, 106.7, 6)); + // Level-2 cells are the roots. + assert.equal(olcParentCell(OpenLocationCode.encode(10.78, 106.7, 2)), null); + // Grid-refinement lengths step back to the previous valid length. + assert.equal( + olcParentCell(OpenLocationCode.encode(10.78, 106.7, 11)), + OpenLocationCode.encode(10.78, 106.7, 10), + ); + + // Pair levels subdivide 20×20, grid-refinement levels 4×5. + assert.equal(olcChildCount(cell), 400); + assert.equal(olcChildCount(OpenLocationCode.encode(10.78, 106.7, 10)), 20); + assert.equal(olcChildCount(OpenLocationCode.encode(10.78, 106.7, 15)), 0); + + const neighbors = olcNeighborCells(cell); + assert.equal(neighbors.length, 9); + assert.ok(neighbors.includes(cell)); + for (const neighbor of neighbors) { + assert.equal(OpenLocationCode.decode(neighbor).codeLength, 8); + } + // At the north pole row there are no neighbors above. + const polar = OpenLocationCode.encode(89.9, 0, 4); + assert.equal(olcNeighborCells(polar).length, 6); + }); +}); diff --git a/tests/s2-plugin.test.ts b/tests/s2-plugin.test.ts new file mode 100644 index 000000000..484f05df0 --- /dev/null +++ b/tests/s2-plugin.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + DEFAULT_S2_GRID_SETTINGS, + S2_VIEWPORT_CELL_LIMIT, + normalizeS2GridSettings, + s2CellFeature, + s2GridForBounds, + s2LabelMinZoom, + s2LevelForZoom, +} from "../packages/plugins/src/plugins/maplibre-s2"; + +describe("S2 grid plugin helpers", () => { + it("normalizes persisted settings", () => { + assert.deepEqual(normalizeS2GridSettings(undefined), DEFAULT_S2_GRID_SETTINGS); + assert.deepEqual( + normalizeS2GridSettings({ + autoResolution: true, + resolution: 99, + fillColor: "#ABCDEF", + fillOpacity: -1, + lineColor: "bad", + lineWidth: 100, + showLabels: false, + includeNeighbors: true, + includeParents: true, + }), + { + autoResolution: true, + resolution: 30, + fillColor: "#abcdef", + fillOpacity: 0, + lineColor: DEFAULT_S2_GRID_SETTINGS.lineColor, + lineWidth: 8, + showLabels: false, + includeNeighbors: true, + includeParents: true, + }, + ); + }); + + it("maps map zoom to an S2 level like vgrid-maplibre", () => { + assert.equal(s2LevelForZoom(-1), 0); + assert.equal(s2LevelForZoom(0.9), 0); + assert.equal(s2LevelForZoom(4), 4); + assert.equal(s2LevelForZoom(11.7), 11); + assert.equal(s2LevelForZoom(31), 30); + }); + + it("only displays labels once the grid has enough screen space", () => { + assert.equal(s2LabelMinZoom(0), 2); + assert.equal(s2LabelMinZoom(4), 5); + assert.equal(s2LabelMinZoom(12), 13); + assert.equal(s2LabelMinZoom(30), 18); + }); + + it("creates export-ready polygon features", () => { + const grid = s2GridForBounds([-122.52, 37.7, -122.35, 37.82], 12); + const cell = grid.features[0].properties?.s2 as string; + const feature = s2CellFeature(cell); + assert.equal(feature.geometry.type, "Polygon"); + assert.equal(feature.properties?.s2, cell); + assert.equal(feature.properties?.resolution, 12); + // Closed quadrilateral ring: 4 corners plus the repeated first vertex. + assert.equal(feature.geometry.coordinates[0].length, 5); + assert.deepEqual(feature.geometry.coordinates[0][0], feature.geometry.coordinates[0][4]); + }); + + it("fills a viewport with unique cells at the requested level", () => { + const grid = s2GridForBounds([-122.52, 37.7, -122.35, 37.82], 12); + assert.ok(grid.features.length > 0); + assert.ok(grid.features.length < S2_VIEWPORT_CELL_LIMIT); + assert.ok(grid.features.every((feature) => feature.properties?.resolution === 12)); + assert.equal( + new Set(grid.features.map((feature) => feature.properties?.s2)).size, + grid.features.length, + ); + }); + + it("covers full-world bounds", () => { + const grid = s2GridForBounds([-180, -85, 180, 85], 3); + // Level 3 tiles the globe with 6 * 4^3 = 384 cells; the covering keeps + // every cell that intersects the ±85° band. + assert.ok(grid.features.length >= 380, `got ${grid.features.length}`); + assert.ok(grid.features.length <= 384); + }); + + it("keeps antimeridian-crossing rings contiguous", () => { + const grid = s2GridForBounds([170, 50, 190, 65], 7); + assert.ok(grid.features.length > 0); + for (const feature of grid.features) { + const lons = feature.geometry.coordinates[0].map(([lng]) => lng); + assert.ok(Math.max(...lons) - Math.min(...lons) < 90, `ring spans ${lons.join(", ")}`); + } + }); + + it("rejects oversized grids before materializing them", () => { + assert.throws(() => s2GridForBounds([-180, -80, 180, 80], 8), /cell limit exceeded/i); + }); +}); diff --git a/tests/tilecode-plugin.test.ts b/tests/tilecode-plugin.test.ts new file mode 100644 index 000000000..a65d709f6 --- /dev/null +++ b/tests/tilecode-plugin.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + DEFAULT_TILECODE_GRID_SETTINGS, + MAX_TILECODE_ZOOM, + MIN_TILECODE_ZOOM, + TILECODE_VIEWPORT_CELL_LIMIT, + normalizeTilecodeGridSettings, + pointToTile, + tilecodeCellFeature, + tilecodeGridForBounds, + tilecodeLabelMinZoom, + tilecodeNeighborCells, + tilecodeParentCell, + tilecodeResolutionForZoom, + tilecodeToTile, + tileToQuadkey, + tileToTilecode, +} from "../packages/plugins/src/plugins/maplibre-tilecode"; + +describe("Tilecode plugin helpers", () => { + it("normalizes persisted settings", () => { + assert.deepEqual(normalizeTilecodeGridSettings(undefined), DEFAULT_TILECODE_GRID_SETTINGS); + assert.deepEqual( + normalizeTilecodeGridSettings({ + autoResolution: false, + resolution: 99, + fillColor: "#ABCDEF", + fillOpacity: -1, + lineColor: "bad", + lineWidth: 100, + showLabels: false, + includeNeighbors: true, + includeParent: true, + }), + { + autoResolution: false, + resolution: MAX_TILECODE_ZOOM, + fillColor: "#abcdef", + fillOpacity: 0, + lineColor: DEFAULT_TILECODE_GRID_SETTINGS.lineColor, + lineWidth: 8, + showLabels: false, + includeNeighbors: true, + includeParent: true, + }, + ); + assert.equal(normalizeTilecodeGridSettings({ resolution: -5 }).resolution, MIN_TILECODE_ZOOM); + }); + + it("converts between tiles, tilecodes, and quadkeys", () => { + assert.equal(tileToTilecode([203, 112, 8]), "z8x203y112"); + assert.deepEqual(tilecodeToTile("z8x203y112"), [203, 112, 8]); + assert.equal(tilecodeToTile("nonsense"), null); + // Out-of-range x for the zoom level. + assert.equal(tilecodeToTile("z2x9y0"), null); + // Quadkey digits interleave the x/y bits; the z0 root is the empty key. + assert.equal(tileToQuadkey([203, 112, 8]), "13221011"); + assert.equal(tileToQuadkey([0, 0, 0]), ""); + // Round-trip through a point: encode then decode covers the same box. + const tile = pointToTile(10.78, 106.7, 12); + assert.equal(tile[2], 12); + const feature = tilecodeCellFeature(tileToTilecode(tile)); + const [w, s] = feature.geometry.coordinates[0][0]; + const [e, n] = feature.geometry.coordinates[0][2]; + assert.ok(w <= 106.7 && 106.7 <= e); + assert.ok(s <= 10.78 && 10.78 <= n); + }); + + it("maps map zoom to a tile zoom like vgrid-maplibre", () => { + // floor(zoom) + 1, clamped. + assert.equal(tilecodeResolutionForZoom(0), 1); + assert.equal(tilecodeResolutionForZoom(7.9), 8); + assert.equal(tilecodeResolutionForZoom(12.2), 13); + assert.equal(tilecodeResolutionForZoom(99), MAX_TILECODE_ZOOM); + assert.equal(tilecodeResolutionForZoom(-5), MIN_TILECODE_ZOOM); + }); + + it("only displays labels once the grid has enough screen space", () => { + assert.equal(tilecodeLabelMinZoom(0), 2); + assert.equal(tilecodeLabelMinZoom(8), 7); + assert.equal(tilecodeLabelMinZoom(26), 24); + }); + + it("fills a bounding box with tiles and creates export-ready features", () => { + const grid = tilecodeGridForBounds([-122.52, 37.7, -122.35, 37.82], 12); + assert.ok(grid.features.length > 0); + const ids = grid.features.map((feature) => feature.properties?.tilecode as string); + assert.equal(new Set(ids).size, ids.length); + + const feature = grid.features[0]; + assert.equal(feature.geometry.type, "Polygon"); + const ring = feature.geometry.coordinates[0]; + assert.equal(ring.length, 5); + assert.deepEqual(ring[0], ring[ring.length - 1]); + assert.equal(feature.properties?.resolution, 12); + assert.equal( + feature.properties?.quadkey, + tileToQuadkey(tilecodeToTile(feature.properties?.tilecode as string)!), + ); + + // The clicked point's tile must be part of the viewport fill. + const clicked = tileToTilecode(pointToTile(37.76, -122.43, 12)); + assert.ok(ids.includes(clicked)); + + // Tiles cover the whole viewport including its corners. + const epsilon = 1e-9; + const covers = (lat: number, lng: number) => + grid.features.some((f) => { + const [w, s] = f.geometry.coordinates[0][0]; + const [e, n] = f.geometry.coordinates[0][2]; + return lng >= w - epsilon && lng <= e + epsilon && lat >= s - epsilon && lat <= n + epsilon; + }); + assert.ok(covers(37.7, -122.52)); + assert.ok(covers(37.82, -122.35)); + }); + + it("draws antimeridian-crossing viewports in the correct world copy", () => { + // MapLibre reports continuous bounds (east past 180) across the dateline. + const grid = tilecodeGridForBounds([179.3, -0.3, 180.7, 0.3], 8); + assert.ok(grid.features.length > 0); + const easternIds = new Set(); + for (const feature of grid.features) { + const lons = feature.geometry.coordinates[0].map(([lng]) => lng); + // Rings stay contiguous and inside the viewport's continuous range. + assert.ok(Math.max(...lons) - Math.min(...lons) < 180); + assert.ok(Math.min(...lons) >= 178 && Math.max(...lons) <= 182); + if (Math.max(...lons) > 180) { + easternIds.add(feature.properties?.tilecode as string); + } + } + // Tiles past the dateline carry the IDs of their normalized twins (x=0). + assert.ok(easternIds.has(tileToTilecode(pointToTile(0.1, -179.9, 8)))); + }); + + it("rejects viewports that would exceed the tile limit", () => { + assert.throws( + () => tilecodeGridForBounds([-180, -85, 180, 85], 12, TILECODE_VIEWPORT_CELL_LIMIT), + RangeError, + ); + }); + + it("finds the single parent and neighbors of a tile", () => { + assert.equal(tilecodeParentCell("z8x203y112"), "z7x101y56"); + assert.equal(tilecodeParentCell("z0x0y0"), null); + + const neighbors = tilecodeNeighborCells("z8x203y112"); + assert.equal(neighbors.length, 9); + assert.ok(neighbors.includes("z8x203y112")); + // The x axis wraps around the world… + const wrapped = tilecodeNeighborCells("z4x0y7"); + assert.ok(wrapped.includes("z4x15y7")); + // …but the y axis clips at the top row (no tiles beyond the mercator cap). + const topRow = tilecodeNeighborCells("z4x7y0"); + assert.equal(topRow.length, 6); + }); +});