Skip to content

Commit 04ee08d

Browse files
committed
fix(stac): let a declared media type outrank an extension, and fail a refused PMTiles add
1 parent 3969cfe commit 04ee08d

8 files changed

Lines changed: 92 additions & 53 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4808,7 +4808,7 @@ export function resolvePMTilesLayerName(layerInfo: PMTilesLayerInfo, id: string)
48084808
const queue = pendingPMTilesNames.get(layerInfo.url);
48094809
const pending = queue?.shift();
48104810
if (queue?.length === 0) pendingPMTilesNames.delete(layerInfo.url);
4811-
if (pending) return pending.name;
4811+
if (pending?.name) return pending.name;
48124812
return layerInfo.name || layerNameFromUrl(layerInfo.url, id);
48134813
}
48144814

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,10 @@ async function visualizeAsset(
512512
case "pmtiles": {
513513
if (!appRef) throw new Error(labels.addFailed);
514514
// The same door the Source Cooperative browser uses, so an archive reaches the map one way.
515-
await addPMTilesLayerFromUrl(appRef, asset.href, { fit: false, name });
515+
// It answers false when the control will not mount, which is a failure like any other.
516+
if (!(await addPMTilesLayerFromUrl(appRef, asset.href, { fit: false, name }))) {
517+
throw new Error(labels.addFailed);
518+
}
516519
return;
517520
}
518521
case "geojson": {

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

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -554,14 +554,21 @@ export function itemBbox(item: StacItem): [number, number, number, number] | und
554554
* extension to fall back on when a catalog leaves the type off or writes it as octet-stream.
555555
* Tested in order, so a format whose extension another could claim comes first.
556556
*/
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-
563557
/** A format {@link assetFormat} recognizes, and {@link visualizeAsset} knows how to add. */
564-
export type StacAssetFormat = keyof typeof VISUALIZABLE_FORMATS;
558+
export type StacAssetFormat = "pmtiles" | "geojson" | "cog";
559+
560+
interface AssetFormatRule {
561+
format: StacAssetFormat;
562+
/** Matched within the asset's media type, which catalogs write with varying parameters. */
563+
mediaType: string;
564+
extension: RegExp;
565+
}
566+
567+
const VISUALIZABLE_FORMATS: readonly AssetFormatRule[] = [
568+
{ format: "pmtiles", mediaType: "pmtiles", extension: /\.pmtiles($|\?)/i },
569+
{ format: "geojson", mediaType: "geo+json", extension: /\.geojson($|\?)/i },
570+
{ format: "cog", mediaType: "geotiff", extension: /\.tiff?($|\?)/i },
571+
];
565572

566573
/**
567574
* Which format an asset is, or null when the panel cannot draw it. The single answer both the
@@ -570,12 +577,13 @@ export type StacAssetFormat = keyof typeof VISUALIZABLE_FORMATS;
570577
*/
571578
export function assetFormat(asset: StacAsset): StacAssetFormat | null {
572579
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;
580+
581+
// Every declared media type outranks every extension: a catalog that calls a `.pmtiles` href
582+
// GeoJSON is describing its own asset, and reading the extension first would overrule it.
583+
const declared = VISUALIZABLE_FORMATS.find((rule) => mediaType.includes(rule.mediaType));
584+
if (declared) return declared.format;
585+
586+
return VISUALIZABLE_FORMATS.find((rule) => rule.extension.test(asset.href))?.format ?? null;
579587
}
580588

581589
export function isVisualizableAsset(asset: StacAsset): boolean {

tests/pmtiles-control-layer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ function controlLayer(patch: Partial<PMTilesLayerInfo> = {}): PMTilesLayerInfo {
1616
opacity: 0.8,
1717
pickable: true,
1818
...patch,
19-
} as PMTilesLayerInfo;
19+
};
2020
}
2121

2222
describe("the store layer the PMTiles control's layeradd produces", () => {

tests/pmtiles-layer-name.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ import {
88

99
function layerInfo(patch: Partial<PMTilesLayerInfo> = {}): PMTilesLayerInfo {
1010
return {
11+
id: "pmtiles-1",
1112
url: "https://example.org/warehouse/units.pmtiles",
1213
name: "",
14+
tileType: "vector",
15+
sourceLayers: ["units"],
16+
layerIds: ["pmtiles-1-units-fill"],
17+
opacity: 1,
18+
pickable: true,
1319
...patch,
14-
} as PMTilesLayerInfo;
20+
};
1521
}
1622

1723
describe("naming a PMTiles layer a caller asked for", () => {
@@ -61,6 +67,13 @@ describe("naming a PMTiles layer a caller asked for", () => {
6167
assert.equal(resolvePMTilesLayerName(info, "layer-1"), "second item");
6268
});
6369

70+
it("falls back when a caller queues an empty name, rather than naming the layer nothing", () => {
71+
const info = layerInfo({ name: "Named by the panel" });
72+
setPendingPMTilesName(info.url, "");
73+
74+
assert.equal(resolvePMTilesLayerName(info, "layer-1"), "Named by the panel");
75+
});
76+
6477
it("keeps the control's own name when the caller supplied none", () => {
6578
assert.equal(
6679
resolvePMTilesLayerName(layerInfo({ name: "Named by the panel" }), "layer-1"),

tests/pmtiles-layer-sync.test.ts

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
33
import { syncLayer } from "../packages/map/src/layer-sync";
4-
import { createPMTilesStoreLayer } from "../packages/map/src/pmtiles-layer";
4+
import {
5+
createPMTilesStoreLayer,
6+
type PMTilesStoreLayerOptions,
7+
} from "../packages/map/src/pmtiles-layer";
58

6-
interface MapCall {
7-
method: string;
8-
args: unknown[];
9-
}
9+
/** A MapLibre layer definition as the sync passes it, read by key in the assertions below. */
10+
type AddedLayer = Record<string, unknown> & { id: string };
1011

1112
/** Enough of a MapLibre map to record what a sync pass adds, starting from an empty style. */
1213
function makeMapStub() {
13-
const calls: MapCall[] = [];
14+
const addedSources: { id: string; source: Record<string, unknown> }[] = [];
15+
const addedLayers: AddedLayer[] = [];
1416
const sources = new Set<string>();
1517
const layers = new Set<string>();
1618
const map = {
1719
getStyle: () => ({ layers: [...layers].map((id) => ({ id, type: "fill" })) }),
1820
getLayer: (id: string) => (layers.has(id) ? { id, type: "fill" } : undefined),
1921
getSource: (id: string) => (sources.has(id) ? { id } : undefined),
20-
addSource: (id: string, source: unknown) => {
22+
addSource: (id: string, source: Record<string, unknown>) => {
2123
sources.add(id);
22-
calls.push({ method: "addSource", args: [id, source] });
24+
addedSources.push({ id, source });
2325
},
24-
addLayer: (layer: { id: string }) => {
26+
addLayer: (layer: AddedLayer) => {
2527
layers.add(layer.id);
26-
calls.push({ method: "addLayer", args: [layer] });
28+
addedLayers.push(layer);
2729
},
2830
removeLayer: () => {},
2931
removeSource: () => {},
@@ -32,69 +34,69 @@ function makeMapStub() {
3234
setPaintProperty: () => {},
3335
setLayerZoomRange: () => {},
3436
};
35-
const added = (method: string) => calls.filter((call) => call.method === method);
36-
return { map, added };
37+
return { map, addedSources, addedLayers };
3738
}
3839

39-
const archive = {
40+
const archive: PMTilesStoreLayerOptions = {
4041
id: "layer-1",
4142
name: "Geologic units",
4243
url: "https://example.org/units.pmtiles",
43-
tileType: "vector" as const,
44+
tileType: "vector",
4445
sourceLayers: ["units"],
4546
};
4647

4748
describe("syncing a layer from createPMTilesStoreLayer", () => {
4849
it("adds the archive as a vector source and one MapLibre layer per declared id", () => {
4950
const layer = createPMTilesStoreLayer(archive);
50-
const { map, added } = makeMapStub();
51+
const { map, addedSources, addedLayers } = makeMapStub();
5152

5253
syncLayer(map as never, layer);
5354

54-
assert.deepEqual(added("addSource")[0]?.args, [
55-
"layer-1",
56-
{ type: "vector", url: "pmtiles://https://example.org/units.pmtiles" },
57-
]);
55+
assert.deepEqual(addedSources[0], {
56+
id: "layer-1",
57+
source: { type: "vector", url: "pmtiles://https://example.org/units.pmtiles" },
58+
});
5859
// The ids the builder promised are exactly the ones the sync creates: a layer whose
5960
// nativeLayerIds name something else renders nothing while claiming it renders.
6061
assert.deepEqual(
61-
added("addLayer").map((call) => (call.args[0] as { id: string }).id),
62+
addedLayers.map((added) => added.id),
6263
layer.metadata.nativeLayerIds,
6364
);
64-
for (const call of added("addLayer")) {
65-
assert.equal((call.args[0] as Record<string, unknown>)["source-layer"], "units");
66-
assert.equal((call.args[0] as Record<string, unknown>).source, "layer-1");
65+
for (const added of addedLayers) {
66+
assert.equal(added["source-layer"], "units");
67+
assert.equal(added.source, "layer-1");
6768
}
6869
});
6970

7071
it("adds a raster archive as a raster source and its single layer", () => {
7172
const layer = createPMTilesStoreLayer({ ...archive, tileType: "raster", sourceLayers: [] });
72-
const { map, added } = makeMapStub();
73+
const { map, addedSources, addedLayers } = makeMapStub();
7374

7475
syncLayer(map as never, layer);
7576

76-
assert.equal((added("addSource")[0]?.args[1] as { type: string }).type, "raster");
77+
assert.equal(addedSources[0]?.source.type, "raster");
7778
assert.deepEqual(
78-
added("addLayer").map((call) => (call.args[0] as { id: string }).id),
79+
addedLayers.map((added) => added.id),
7980
["layer-1-raster"],
8081
);
8182
});
8283

8384
it("renders a source layer whose name needs encoding in a layer id", () => {
8485
const layer = createPMTilesStoreLayer({ ...archive, sourceLayers: ["water lines"] });
85-
const { map, added } = makeMapStub();
86+
const { map, addedLayers } = makeMapStub();
8687

8788
syncLayer(map as never, layer);
8889

8990
assert.deepEqual(
90-
added("addLayer").map((call) => (call.args[0] as { id: string }).id),
91+
addedLayers.map((added) => added.id),
9192
layer.metadata.nativeLayerIds,
9293
);
9394
// The source-layer keeps the archive's own name; only the id is encoded.
94-
assert.equal(
95-
(added("addLayer")[0]?.args[0] as Record<string, unknown>)["source-layer"],
96-
"water lines",
97-
);
98-
assert.equal((layer.metadata.nativeLayerIds as string[])[0], "layer-1-water_20lines-fill");
95+
assert.equal(addedLayers[0]?.["source-layer"], "water lines");
96+
assert.deepEqual(layer.metadata.nativeLayerIds, [
97+
"layer-1-water_20lines-fill",
98+
"layer-1-water_20lines-line",
99+
"layer-1-water_20lines-circle",
100+
]);
99101
});
100102
});

tests/pmtiles-store-layer.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
33
import { createEmptyProject, parseProject, serializeProject } from "@geolibre/core";
4-
import { createPMTilesStoreLayer } from "../packages/map/src/pmtiles-layer";
4+
import {
5+
createPMTilesStoreLayer,
6+
type PMTilesStoreLayerOptions,
7+
} from "../packages/map/src/pmtiles-layer";
58
import { isPlaceholderLayer } from "../packages/map/src/placeholders";
69

7-
const archive = {
10+
const archive: PMTilesStoreLayerOptions = {
811
id: "layer-1",
912
name: "Geologic units",
1013
url: "https://example.org/units.pmtiles",
11-
tileType: "vector" as const,
14+
tileType: "vector",
1215
sourceLayers: ["units"],
1316
};
1417

tests/stac-api.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,16 @@ test("an asset's format comes from its media type, or its extension when there i
11611161
// what the asset is, not by what the path says.
11621162
assert.equal(assetFormat({ href: "https://example.com/geotiff/a.pmtiles" }), "pmtiles");
11631163

1164+
// And a declared media type wins over any extension, whichever format each of them names.
1165+
assert.equal(
1166+
assetFormat({ href: "https://example.com/a.pmtiles", type: "application/geo+json" }),
1167+
"geojson",
1168+
);
1169+
assert.equal(
1170+
assetFormat({ href: "https://example.com/a.geojson", type: "application/vnd.pmtiles" }),
1171+
"pmtiles",
1172+
);
1173+
11641174
// Which is the same answer that decides whether Add is enabled.
11651175
assert.equal(
11661176
isVisualizableAsset({ href: "https://example.com/a.pmtiles", type: "application/vnd.pmtiles" }),

0 commit comments

Comments
 (0)