Skip to content

Commit 9bcf079

Browse files
committed
feat(stac): add PMTiles assets to the map
1 parent dcf9166 commit 9bcf079

5 files changed

Lines changed: 95 additions & 22 deletions

File tree

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3424,7 +3424,7 @@
34243424
"download": "Download",
34253425
"adding": "Adding {{asset}}…",
34263426
"added": "Added {{asset}} to the map.",
3427-
"addUnsupported": "Only GeoTIFF/COG and GeoJSON assets can be added to the map",
3427+
"addUnsupported": "Only GeoTIFF/COG, GeoJSON, and PMTiles assets can be added to the map",
34283428
"addFailed": "Could not add asset",
34293429
"cogUnsupported": "This GeoLibre host cannot visualize remote GeoTIFF assets"
34303430
},

packages/plugins/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ export {
476476
type StacLabels,
477477
} from "./plugins/maplibre-stac";
478478
export {
479+
assetFormat,
479480
connectStac,
480481
isVisualizableAsset,
481482
itemBbox,
@@ -492,6 +493,7 @@ export {
492493
type StacIndexCatalog,
493494
type StacItem,
494495
type StacNextPage,
496+
type StacAssetFormat,
495497
type StacSearchOptions,
496498
type StacSearchResult,
497499
} from "./plugins/stac-api";

packages/plugins/src/plugins/maplibre-stac.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { fillLayerId, lineLayerId } from "@geolibre/map";
33
import type { FeatureCollection, Geometry } from "geojson";
44
import type { GeoJSONSource, MapMouseEvent, Map as MapLibreMap } from "maplibre-gl";
55
import type { GeoLibreAppAPI, GeoLibreCogLayerOptions, GeoLibrePlugin } from "../types";
6+
import { addPMTilesLayerFromUrl } from "./maplibre-components";
67
import {
8+
assetFormat,
79
connectStac,
810
horizontalBbox,
911
isVisualizableAsset,
@@ -209,7 +211,7 @@ let labels: StacLabels = {
209211
zoom: "Zoom",
210212
add: "Add",
211213
download: "Download",
212-
addUnsupported: "Only GeoTIFF/COG and GeoJSON assets can be added to the map",
214+
addUnsupported: "Only GeoTIFF/COG, GeoJSON, and PMTiles assets can be added to the map",
213215
addFailed: "Could not add asset",
214216
cogUnsupported: "This GeoLibre host cannot visualize remote GeoTIFF assets",
215217
showing: (count) => `Showing ${count} items.`,
@@ -505,19 +507,36 @@ async function visualizeAsset(
505507
signal?: AbortSignal,
506508
): Promise<void> {
507509
const name = `${item.id}${assetLabel(key, asset)}`;
508-
const value = `${asset.type ?? ""} ${asset.href}`.toLowerCase();
509-
if (value.includes("geo+json") || /\.geojson($|\?)/i.test(asset.href)) {
510-
const response = await fetch(asset.href, {
511-
headers: { Accept: "application/geo+json, application/json" },
512-
signal,
513-
});
514-
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
515-
const data = (await response.json()) as FeatureCollection;
516-
appRef?.addGeoJsonLayer(name, data, asset.href);
517-
} else if (appRef?.addCogLayer) {
518-
await appRef.addCogLayer(name, asset.href, cogOptions);
519-
} else {
520-
throw new Error(labels.cogUnsupported);
510+
const format = assetFormat(asset);
511+
switch (format) {
512+
case "pmtiles": {
513+
// The same door the Source Cooperative browser uses, so an archive reaches the map one way.
514+
if (appRef) await addPMTilesLayerFromUrl(appRef, asset.href, { fit: false, name });
515+
return;
516+
}
517+
case "geojson": {
518+
const response = await fetch(asset.href, {
519+
headers: { Accept: "application/geo+json, application/json" },
520+
signal,
521+
});
522+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
523+
const data = (await response.json()) as FeatureCollection;
524+
appRef?.addGeoJsonLayer(name, data, asset.href);
525+
return;
526+
}
527+
case "cog": {
528+
if (!appRef?.addCogLayer) throw new Error(labels.cogUnsupported);
529+
await appRef.addCogLayer(name, asset.href, cogOptions);
530+
return;
531+
}
532+
case null:
533+
throw new Error(labels.addUnsupported);
534+
default: {
535+
// A format added to VISUALIZABLE_FORMATS with no branch here fails to compile, rather than
536+
// falling through to a renderer that cannot read it.
537+
const unhandled: never = format;
538+
throw new Error(`Unhandled asset format: ${String(unhandled)}`);
539+
}
521540
}
522541
}
523542

packages/plugins/src/plugins/stac-api.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -549,12 +549,35 @@ export function itemBbox(item: StacItem): [number, number, number, number] | und
549549
return horizontalBbox(item.bbox);
550550
}
551551

552+
/**
553+
* The formats the panel can put on the map: a marker within the asset's media type, and the
554+
* extension to fall back on when a catalog leaves the type off or writes it as octet-stream.
555+
* Tested in order, so a format whose extension another could claim comes first.
556+
*/
557+
const VISUALIZABLE_FORMATS = {
558+
pmtiles: { mediaType: "pmtiles", extension: /\.pmtiles($|\?)/i },
559+
geojson: { mediaType: "geo+json", extension: /\.geojson($|\?)/i },
560+
cog: { mediaType: "geotiff", extension: /\.tiff?($|\?)/i },
561+
} as const;
562+
563+
/** A format {@link assetFormat} recognizes, and {@link visualizeAsset} knows how to add. */
564+
export type StacAssetFormat = keyof typeof VISUALIZABLE_FORMATS;
565+
566+
/**
567+
* Which format an asset is, or null when the panel cannot draw it. The single answer both the
568+
* enabled state of Add and the routing behind it read, so the button and the click cannot
569+
* disagree about what a catalog is offering.
570+
*/
571+
export function assetFormat(asset: StacAsset): StacAssetFormat | null {
572+
const mediaType = (asset.type ?? "").toLowerCase();
573+
for (const [format, match] of Object.entries(VISUALIZABLE_FORMATS)) {
574+
if (mediaType.includes(match.mediaType) || match.extension.test(asset.href)) {
575+
return format as StacAssetFormat;
576+
}
577+
}
578+
return null;
579+
}
580+
552581
export function isVisualizableAsset(asset: StacAsset): boolean {
553-
const value = `${asset.type ?? ""} ${asset.href}`.toLowerCase();
554-
return (
555-
value.includes("geotiff") ||
556-
/\.tiff?($|\?)/i.test(asset.href) ||
557-
value.includes("geo+json") ||
558-
/\.geojson($|\?)/i.test(asset.href)
559-
);
582+
return assetFormat(asset) !== null;
560583
}

tests/stac-api.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import test from "node:test";
33
import {
44
browserAssetHref,
55
connectStac,
6+
assetFormat,
67
isVisualizableAsset,
78
itemBbox,
89
openCatalogNode,
@@ -1139,6 +1140,34 @@ test("a document that never reads leaves the search without a total", async () =
11391140
assert.equal(result.matched, undefined);
11401141
});
11411142

1143+
test("an asset's format comes from its media type, or its extension when there is none", async () => {
1144+
// The registered type is what a catalog following the spec sends; the extension covers the
1145+
// ones that send nothing useful.
1146+
assert.equal(
1147+
assetFormat({ href: "https://example.com/a.pmtiles", type: "application/vnd.pmtiles" }),
1148+
"pmtiles",
1149+
);
1150+
assert.equal(assetFormat({ href: "https://example.com/a.PMTILES" }), "pmtiles");
1151+
assert.equal(assetFormat({ href: "https://example.com/a.pmtiles?token=1" }), "pmtiles");
1152+
assert.equal(assetFormat({ href: "https://example.com/tiles?id=7&f=pmtiles" }), null);
1153+
assert.equal(assetFormat({ href: "https://example.com/a.tif", type: "image/tiff" }), "cog");
1154+
assert.equal(
1155+
assetFormat({ href: "https://example.com/a.json", type: "application/geo+json" }),
1156+
"geojson",
1157+
);
1158+
assert.equal(assetFormat({ href: "https://example.com/data.bin" }), null);
1159+
1160+
// A catalog that stores its archives under a directory named for another format is read by
1161+
// what the asset is, not by what the path says.
1162+
assert.equal(assetFormat({ href: "https://example.com/geotiff/a.pmtiles" }), "pmtiles");
1163+
1164+
// Which is the same answer that decides whether Add is enabled.
1165+
assert.equal(
1166+
isVisualizableAsset({ href: "https://example.com/a.pmtiles", type: "application/vnd.pmtiles" }),
1167+
true,
1168+
);
1169+
});
1170+
11421171
test("asset and bbox helpers recognize common STAC data", () => {
11431172
assert.equal(isVisualizableAsset({ href: "https://example.com/a.TIF?download=1" }), true);
11441173
assert.equal(isVisualizableAsset({ href: "https://example.com/data.bin" }), false);

0 commit comments

Comments
 (0)