Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -612,10 +612,9 @@
useEffect(() => () => activeResizeCleanupRef.current?.(), []);
const mapControllerRef = useRef<MapController | null>(null);

// Frame the GeoJSON layers a `?data=` deep link added. Only those are listed:
// the raster, PMTiles, and GeoParquet loaders move the camera themselves. The
// extent comes from the store's own GeoJSON, which is already in memory by the
// time the hook publishes the ids, so this needs no wait for layer sync.
// Frame layers a `?data=` deep link added. Single non-GeoJSON datasets move
// the camera in their format-specific loader; a repeated `data` batch lists
// every added layer here so their stored extents are combined into one fit.
useEffect(() => {
const fitLayerIds = dataUrlLoadState?.fitLayerIds;
if (dataUrlLoadState?.status !== "loaded" || !fitLayerIds?.length) return;
Expand Down Expand Up @@ -1755,7 +1754,7 @@
addDroppedPhotos,
addGeoJsonLayer,
viewerReadOnly,
]);

Check warning on line 1757 in apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

View workflow job for this annotation

GitHub Actions / Build and test

React Hook useEffect has a missing dependency: 't'. Either include it or remove the dependency array

const handleDragEnter = useCallback(
(event: DragEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -1903,7 +1902,7 @@
addDroppedPhotos,
addGeoJsonLayer,
viewerReadOnly,
],

Check warning on line 1905 in apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

View workflow job for this annotation

GitHub Actions / Build and test

React Hook useCallback has a missing dependency: 't'. Either include it or remove the dependency array
);

// Escape hatch for a drop overlay that outlived its drag (issue #1664).
Expand Down
40 changes: 30 additions & 10 deletions apps/geolibre-desktop/src/hooks/useDataUrlLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ import {
import type { createAppAPI } from "./usePlugins";
import type { ProjectUrlLoadState } from "./useProjectUrlLoader";

/**
* `fitLayerIds` carries only the layers the shell still has to frame. The COG,
* PMTiles, and GeoParquet loaders move the camera themselves as part of adding
* their layer, so listing those here would fit twice and show a visible
* double-take; a store-added GeoJSON layer moves nothing on its own.
*/
/** `fitLayerIds` carries the layers the shell still has to frame. */
export type DataUrlLoadState = ProjectUrlLoadState & { fitLayerIds?: string[] };

export interface DataUrlLoadResult {
Expand Down Expand Up @@ -139,10 +134,35 @@ export function useDataUrlLoader(
if (!params || !mapAppAPI) return;
const controller = new AbortController();
setState({ error: null, message: "Loading data from URL...", status: "loading" });
void loadDataUrl(mapAppAPI, params.dataUrl, {
styleUrl: params.styleUrl,
signal: controller.signal,
})
const load = async () => {
// Preserve the established one-dataset behavior. For a batch, prevent
// each format-specific loader from moving the camera and let the shell
// frame the complete set once all entries have finished.
if (params.length === 1) {
const [entry] = params;
return loadDataUrl(mapAppAPI, entry.dataUrl, {
styleUrl: entry.styleUrl,
signal: controller.signal,
});
}
const layerIds: string[] = [];
try {
for (const entry of params) {
const result = await loadDataUrl(mapAppAPI, entry.dataUrl, {
styleUrl: entry.styleUrl,
signal: controller.signal,
fit: false,
});
layerIds.push(...result.layerIds);
}
} catch (error) {
const store = useAppStore.getState();
for (const layerId of layerIds) store.removeLayer(layerId);
throw error;
}
return { layerIds, fitLayerIds: layerIds };
};
void load()
.then(({ layerIds, fitLayerIds }) => {
if (controller.signal.aborted) return;
setState({
Expand Down
17 changes: 12 additions & 5 deletions apps/geolibre-desktop/src/lib/data-url.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FeatureCollection } from "geojson";
import { AsyncUnzipInflate, strFromU8, Unzip, UnzipPassThrough, type UnzipFile } from "fflate";

export interface DataUrlParameters {
export interface DataUrlParameter {
dataUrl: string;
styleUrl: string | null;
}
Expand Down Expand Up @@ -43,11 +43,18 @@ function httpUrl(value: string | null): string | null {
}
}

/** Read the remote layer deep link without claiming the project loader's `url` parameter. */
export function dataUrlParameters(search: string): DataUrlParameters | null {
/** Read remote-layer deep links without claiming the project loader's `url` parameter. */
export function dataUrlParameters(search: string): DataUrlParameter[] | null {
const params = new URLSearchParams(search);
const dataUrl = httpUrl(params.get("data"));
return dataUrl ? { dataUrl, styleUrl: httpUrl(params.get("style")) } : null;
const styles = params.getAll("style");
const entries = params
.getAll("data")
.map((value, index) => {
const dataUrl = httpUrl(value);
return dataUrl ? { dataUrl, styleUrl: httpUrl(styles[index] ?? null) } : null;
})
.filter((entry): entry is DataUrlParameter => entry !== null);
return entries.length ? entries : null;
}

export function remoteName(url: string): string {
Expand Down
11 changes: 11 additions & 0 deletions docs/user-guide/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ Use `data` to open public GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF
https://web.geolibre.app/?data=https://assets.geolibre.app/data/places.geojson&style=https://assets.geolibre.app/data/sample.style.json
```

Repeat `data` to load multiple independent datasets on the same map. Repeat
`style` in the same order to style each dataset; use an empty `style=` as a
placeholder when an earlier dataset should use its default style:

```text
https://web.geolibre.app/?data=https://example.com/roads.geojson&data=https://example.com/dem.tif&style=&style=https://example.com/dem.style.json
```

GeoLibre loads every entry and fits the map once to their combined stored
extents. A style applies only to the `data` value at the same position.

`data` may also point to a REST API endpoint that returns either a GeoJSON `FeatureCollection` or a ZIP containing multiple GeoJSON files. ZIP API responses are recognized from their `Content-Type`/`Content-Disposition` headers or their ZIP file signature, so the endpoint does not need a `.zip` suffix. An endpoint that takes its own query parameters is the case that does need percent-encoding, so its `&` separators are not read as GeoLibre's own:

```text
Expand Down
29 changes: 20 additions & 9 deletions packages/map/src/geojson-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,26 @@ export function detectGeometryProfile(fc: FeatureCollection): GeometryProfile {
}

export function getLayerBounds(layer: GeoLibreLayer): [number, number, number, number] | null {
if (!layer.geojson?.features?.length) return null;
const box = bbox(layer.geojson);
// A collection whose features all carry a null geometry (e.g. a delimited
// text file imported as an attribute table, or a non-spatial SQL result)
// yields a degenerate ±Infinity box. Report "no bounds" so callers such as
// fitLayer/"Zoom to layer" fall back or no-op instead of flying to an
// invalid extent.
if (!box.every((value) => Number.isFinite(value))) return null;
return box as [number, number, number, number];
if (layer.geojson?.features?.length) {
const box = bbox(layer.geojson);
// A collection whose features all carry a null geometry (e.g. a delimited
// text file imported as an attribute table, or a non-spatial SQL result)
// yields a degenerate ±Infinity box. Continue to the stored extent in
// that case instead of flying to invalid coordinates.
if (box.every((value) => Number.isFinite(value))) {
return box as [number, number, number, number];
}
}
for (const value of [layer.source.bounds, layer.metadata.bounds]) {
if (
Array.isArray(value) &&
value.length === 4 &&
value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate))
) {
return value as [number, number, number, number];
}
}
Comment on lines +51 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback (source.bounds then metadata.bounds, each validated as a 4-element finite-number array) duplicates MapController.getLayerMetadataBounds/normalizeLayerBounds in packages/map/src/map-controller.ts (lines ~2237-2278). Both of that file's call sites do getLayerBounds(layer) ?? this.getLayerMetadataBounds(layer) ?? this.getLayerSourceBounds(layer) — now that getLayerBounds already performs the same check getLayerMetadataBounds does, that middle fallback is effectively dead code (it can only be reached when getLayerBounds returned null, at which point the same source/metadata fields have already failed the same validation). Not a functional bug, but the duplicated validation logic can drift if one copy changes without the other; consider having map-controller.ts drop its now-redundant getLayerMetadataBounds call, or factor the shared validation into one exported helper.

Confidence: medium.

return null;
}

export function sourceId(layerId: string): string {
Expand Down
48 changes: 43 additions & 5 deletions tests/data-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ describe("data URL deep links", () => {
const parsed = dataUrlParameters(
`?data=${encodeURIComponent(endpoint)}&style=${encodeURIComponent(style)}`,
);
assert.equal(parsed?.dataUrl, endpoint);
assert.equal(parsed?.styleUrl, style);
assert.equal(parsed?.[0]?.dataUrl, endpoint);
assert.equal(parsed?.[0]?.styleUrl, style);
});

it("parses raw, unencoded data and style URLs as documented", () => {
Expand All @@ -120,13 +120,51 @@ describe("data URL deep links", () => {
"?data=https://assets.geolibre.app/data/places.geojson" +
"&style=https://assets.geolibre.app/data/sample.style.json",
);
assert.equal(parsed?.dataUrl, "https://assets.geolibre.app/data/places.geojson");
assert.equal(parsed?.styleUrl, "https://assets.geolibre.app/data/sample.style.json");
assert.equal(parsed?.[0]?.dataUrl, "https://assets.geolibre.app/data/places.geojson");
assert.equal(parsed?.[0]?.styleUrl, "https://assets.geolibre.app/data/sample.style.json");

// Only the first `=` of each `&`-delimited pair separates name from value,
// so a nested `=` survives unencoded — the docs tell readers not to escape it.
const nested = dataUrlParameters("?data=https://api.example.com/features?category=parks");
assert.equal(nested?.dataUrl, "https://api.example.com/features?category=parks");
assert.equal(nested?.[0]?.dataUrl, "https://api.example.com/features?category=parks");
});

it("parses repeated data URLs and pairs repeated styles by position", () => {
assert.deepEqual(
dataUrlParameters(
"?data=https://example.com/roads.geojson" +
"&data=https://example.com/buildings.parquet" +
"&style=https://example.com/roads.style.json" +
"&style=https://example.com/buildings.style.json",
),
[
{
dataUrl: "https://example.com/roads.geojson",
styleUrl: "https://example.com/roads.style.json",
},
{
dataUrl: "https://example.com/buildings.parquet",
styleUrl: "https://example.com/buildings.style.json",
},
],
);
});

it("allows an empty positional style when only a later dataset is styled", () => {
assert.deepEqual(
dataUrlParameters(
"?data=https://example.com/roads.geojson" +
"&data=https://example.com/dem.tif" +
"&style=&style=https://example.com/dem.style.json",
),
[
{ dataUrl: "https://example.com/roads.geojson", styleUrl: null },
{
dataUrl: "https://example.com/dem.tif",
styleUrl: "https://example.com/dem.style.json",
},
],
);
});

it("rejects non-http data URLs", () => {
Expand Down
14 changes: 14 additions & 0 deletions tests/layer-bounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,18 @@ describe("getLayerBounds", () => {
it("returns null when there is no geojson", () => {
assert.equal(getLayerBounds(layerWith(undefined)), null);
});

it("uses stored source bounds for a non-GeoJSON layer", () => {
const layer = layerWith(undefined);
layer.type = "raster";
layer.source = { type: "raster", bounds: [-80, 30, -70, 40] };
assert.deepEqual(getLayerBounds(layer), [-80, 30, -70, 40]);
});

it("falls back to metadata bounds when source bounds are invalid", () => {
const layer = layerWith(undefined);
layer.source.bounds = [-80, Number.NaN, -70, 40];
layer.metadata.bounds = [-10, -5, 10, 5];
assert.deepEqual(getLayerBounds(layer), [-10, -5, 10, 5]);
});
});
Loading