Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export default function App() {
layoutOptions={layoutOptions}
projectUrlLoadState={projectUrlLoadState}
dataUrlLoadState={dataUrlLoadState}
mapAppAPI={mapAppAPI}
themeMode={themeMode}
onToggleThemeMode={toggleThemeMode}
onMapReady={handleMapReady}
Expand Down
4 changes: 3 additions & 1 deletion apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@
layoutOptions: LayoutOptions;
projectUrlLoadState?: ProjectUrlLoadState;
dataUrlLoadState?: DataUrlLoadState;
mapAppAPI: ReturnType<typeof createAppAPI> | null;
themeMode: ThemeMode;
onToggleThemeMode: () => void;
onMapReady?: (app: ReturnType<typeof createAppAPI>) => void;
Expand Down Expand Up @@ -542,6 +543,7 @@
layoutOptions,
projectUrlLoadState,
dataUrlLoadState,
mapAppAPI,
themeMode,
onToggleThemeMode,
onMapReady,
Expand Down Expand Up @@ -888,7 +890,7 @@
// Runtime postMessage API for a third-party host page that frames the app
// (fly to a record, highlight it, open a tool; selection/view/tool events back
// out). Off unless the deployment configured GEOLIBRE_EMBED_ORIGINS.
useEmbedApi(mapControllerRef);
useEmbedApi(mapControllerRef, mapAppAPI);
// Same scripting surface, reached over the desktop Jupyter server's relay, so
// a kernel driven from an EXTERNAL client (VS Code's Jupyter extension) can
// control the map too. Inert until that server is running.
Expand Down Expand Up @@ -1746,7 +1748,7 @@
disposed = true;
unlisten?.();
};
}, [

Check warning on line 1751 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
clearDropMessageLater,
finishDrop,
addDroppedRasters,
Expand Down Expand Up @@ -1894,7 +1896,7 @@
clearDropMessageLater();
}
},
[

Check warning on line 1899 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
clearDropMessageLater,
finishDrop,
addDroppedRasters,
Expand Down
194 changes: 100 additions & 94 deletions apps/geolibre-desktop/src/hooks/useDataUrlLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,107 @@ import type { ProjectUrlLoadState } from "./useProjectUrlLoader";
*/
export type DataUrlLoadState = ProjectUrlLoadState & { fitLayerIds?: string[] };

export interface DataUrlLoadResult {
layerIds: string[];
fitLayerIds: string[];
}

/** Whether a store layer records the given URL as the data it was loaded from. */
function layerPointsAt(layer: GeoLibreLayer, url: string): boolean {
if (layer.sourcePath === url) return true;
const source = layer.source as { url?: unknown };
return source.url === url;
}

/** Load one URL through the same remote-data pipeline used by the `?data=` deep link. */
export async function loadDataUrl(
mapAppAPI: ReturnType<typeof createAppAPI>,
dataUrl: string,
options: { styleUrl?: string | null; signal?: AbortSignal; fit?: boolean } = {},
): Promise<DataUrlLoadResult> {
const fit = options.fit ?? true;
const [remote, rawStyle] = await Promise.all([
fetchRemoteData(dataUrl, { signal: options.signal }),
options.styleUrl ? fetchRemoteStyle(options.styleUrl, { signal: options.signal }) : null,
]);
if (options.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
Comment thread
giswqs marked this conversation as resolved.
const store = useAppStore.getState();
const layerIds: string[] = [];
const fitLayerIds: string[] = [];
if (remote.kind === "cog") {
const rasterStyle = rawStyle === null ? null : parseRasterUrlStyle(rawStyle);
const id = await addRasterToMap(mapAppAPI, remote.url, {
name: remote.name,
defaults: { engine: "maplibre-gl-raster" },
zoomTo: fit,
...(rasterStyle ? { state: rasterStyle } : {}),
});
if (options.signal?.aborted) {
store.removeLayer(id);
throw new DOMException("The operation was aborted", "AbortError");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
layerIds.push(id);
} else if (remote.kind === "pmtiles" || remote.kind === "vector") {
const styleResult = rawStyle === null ? null : parseMapboxStyle(rawStyle);
if (styleResult && styleResult.matchedLayerCount === 0) {
throw new Error("The remote style has no supported vector style layers.");
}
const previousIds = new Set(store.layers.map((layer) => layer.id));
const added =
remote.kind === "pmtiles"
? await addPMTilesLayerFromUrl(mapAppAPI, remote.url, { fit })
: await addVectorLayerFromUrl(mapAppAPI, remote.url, {
name: remote.name,
fitBounds: fit,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!added) throw new Error(`Could not add ${remote.name} to the map.`);
const addedLayers = useAppStore
.getState()
.layers.filter((layer) => !previousIds.has(layer.id) && layerPointsAt(layer, remote.url));
Comment on lines +68 to +79

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.

The previousIds snapshot + layerPointsAt(layer, remote.url) matching (carried over from the old inline code) was written to guard against exactly one race: a concurrent ?url= project load replacing the whole layer array while this deep-link data load is in flight. useEmbedApi's new addData handler now serializes concurrent addData calls against each other via queueDataLoad, but that queue is local to useEmbedApi — it does nothing to serialize an addData call against this hook's own loadDataUrl invocation (useDataUrlLoader's ?data= deep-link load), which now runs as an independent, unqueued caller of the same loadDataUrl function.

Both hooks fire from effects gated on the same mapAppAPI becoming non-null, so in practice they can be in flight at the same time. If a host calls addData with the same URL as the page's ?data= parameter while that deep-link load is still pending, both calls' previousIds snapshots can straddle each other's addPMTilesLayerFromUrl/addVectorLayerFromUrl add, and since layerPointsAt matches purely by URL, one call could end up claiming (and then possibly restyling/removing) the layer the other call added. Narrow window, but worth a sentence noting the assumption (or a shared lock) if it's not intentional.

if (!addedLayers.length) {
throw new Error(
`The ${remote.kind === "pmtiles" ? "PMTiles" : "GeoParquet"} loader did not create a layer for ${remote.url}.`,
);
}
if (options.signal?.aborted) {
for (const layer of addedLayers) store.removeLayer(layer.id);
throw new DOMException("The operation was aborted", "AbortError");
}
if (styleResult && addedLayers.some((layer) => layer.metadata.tileType === "raster")) {
for (const layer of addedLayers) store.removeLayer(layer.id);
throw new Error("MapLibre vector styles cannot be applied to a raster PMTiles archive.");
}
if (styleResult) {
for (const layer of addedLayers) {
store.setLayerStyle(layer.id, applyMapboxStyleImport(layer.style, styleResult));
}
}
layerIds.push(...addedLayers.map((layer) => layer.id));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
const imports = remote.layers.map((layer) => {
if (rawStyle === null) return { layer, styleResult: null };
const styleResult = parseMapboxStyle(mapboxStyleForDataLayer(rawStyle, layer.name));
if (styleResult.matchedLayerCount === 0) {
throw new Error(
`The remote style has no supported layers for "${layer.name}.geojson". ` +
`Set each style layer's source to the matching filename stem (for example, "${layer.name}").`,
);
}
return { layer, styleResult };
});
for (const { layer, styleResult } of imports) {
const id = store.addGeoJsonLayer(layer.name, layer.data, layer.sourcePath);
layerIds.push(id);
fitLayerIds.push(id);
if (styleResult) {
const current = useAppStore.getState().layers.find((candidate) => candidate.id === id);
if (current) store.setLayerStyle(id, applyMapboxStyleImport(current.style, styleResult));
}
}
}
return { layerIds, fitLayerIds };
}

export function useDataUrlLoader(
mapAppAPI: ReturnType<typeof createAppAPI> | null,
): DataUrlLoadState {
Expand All @@ -45,104 +139,16 @@ export function useDataUrlLoader(
if (!params || !mapAppAPI) return;
const controller = new AbortController();
setState({ error: null, message: "Loading data from URL...", status: "loading" });
void Promise.all([
fetchRemoteData(params.dataUrl, { signal: controller.signal }),
params.styleUrl ? fetchRemoteStyle(params.styleUrl, { signal: controller.signal }) : null,
])
.then(async ([remote, rawStyle]) => {
void loadDataUrl(mapAppAPI, params.dataUrl, {
styleUrl: params.styleUrl,
signal: controller.signal,
})
.then(({ layerIds, fitLayerIds }) => {
if (controller.signal.aborted) return;
const store = useAppStore.getState();
const fitLayerIds: string[] = [];
let count = 0;
if (remote.kind === "cog") {
const rasterStyle = rawStyle === null ? null : parseRasterUrlStyle(rawStyle);
await addRasterToMap(mapAppAPI, remote.url, {
name: remote.name,
defaults: { engine: "maplibre-gl-raster" },
...(rasterStyle ? { state: rasterStyle } : {}),
});
count = 1;
} else if (remote.kind === "pmtiles" || remote.kind === "vector") {
// Validate the style before invoking a native loader. Those controls
// assign their own ids, so collect the newly synchronized store
// layers after the awaited add completes.
const styleResult = rawStyle === null ? null : parseMapboxStyle(rawStyle);
if (styleResult && styleResult.matchedLayerCount === 0) {
throw new Error("The remote style has no supported vector style layers.");
}
const previousIds = new Set(store.layers.map((layer) => layer.id));
const added =
remote.kind === "pmtiles"
? await addPMTilesLayerFromUrl(mapAppAPI, remote.url)
: await addVectorLayerFromUrl(mapAppAPI, remote.url, {
name: remote.name,
fitBounds: true,
});
if (!added) throw new Error(`Could not add ${remote.name} to the map.`);
// These loaders assign their own ids, so the added layers have to be
// recovered from the store. Identify them by the data URL they record
// and not by an id diff alone: a concurrent `?url=` project load
// replaces the whole layer array, which would make every project
// layer look new here and hand this branch someone else's layers to
// restyle or remove. No match is treated as "could not identify",
// never as "take whatever is new".
const addedLayers = useAppStore
.getState()
.layers.filter(
(layer) => !previousIds.has(layer.id) && layerPointsAt(layer, remote.url),
);
if (!addedLayers.length) {
throw new Error(
`The ${remote.kind === "pmtiles" ? "PMTiles" : "GeoParquet"} loader did not create a layer for ${remote.url}.`,
);
}
// Check every added layer before styling any of them: the archive's
// tile type is only known once the control has read it, so a raster
// archive paired with a vector style has to be rolled back rather
// than left behind by a deep link that reports failure.
if (styleResult && addedLayers.some((layer) => layer.metadata.tileType === "raster")) {
for (const layer of addedLayers) store.removeLayer(layer.id);
throw new Error(
"MapLibre vector styles cannot be applied to a raster PMTiles archive.",
);
}
if (styleResult) {
for (const layer of addedLayers) {
store.setLayerStyle(layer.id, applyMapboxStyleImport(layer.style, styleResult));
}
}
count = addedLayers.length;
} else {
// Resolve and validate every per-file style before mutating the store.
// This keeps a misspelled source name from producing a partial import.
const imports = remote.layers.map((layer) => {
if (rawStyle === null) return { layer, styleResult: null };
const styleResult = parseMapboxStyle(mapboxStyleForDataLayer(rawStyle, layer.name));
if (styleResult.matchedLayerCount === 0) {
throw new Error(
`The remote style has no supported layers for "${layer.name}.geojson". ` +
`Set each style layer's source to the matching filename stem (for example, "${layer.name}").`,
);
}
return { layer, styleResult };
});
for (const { layer, styleResult } of imports) {
const id = store.addGeoJsonLayer(layer.name, layer.data, layer.sourcePath);
fitLayerIds.push(id);
if (styleResult) {
const current = useAppStore
.getState()
.layers.find((candidate) => candidate.id === id);
if (current)
store.setLayerStyle(id, applyMapboxStyleImport(current.style, styleResult));
}
count += 1;
}
}
setState({
error: null,
fitLayerIds,
message: `Loaded ${count} layer${count === 1 ? "" : "s"} from URL`,
message: `Loaded ${layerIds.length} layer${layerIds.length === 1 ? "" : "s"} from URL`,
status: "loaded",
});
timeoutRef.current = window.setTimeout(() => {
Expand Down
56 changes: 52 additions & 4 deletions apps/geolibre-desktop/src/hooks/useEmbedApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAppStore } from "@geolibre/core";
import { type RefObject, useEffect } from "react";
import type { MapController } from "@geolibre/map";
import { getLayerBounds, type MapController } from "@geolibre/map";
import { captureMapImage } from "../lib/print-layout-export";
import {
buildEmbedEvent,
Expand All @@ -20,6 +20,8 @@ import {
import { fetchProjectFromUrl, projectUrlFromLocation } from "../lib/project-url";
import { resolveProjectXyzLayers } from "../lib/xyz-url";
import { isKnownWhiteboxToolId } from "../lib/whitebox-tool-url";
import { loadDataUrl } from "./useDataUrlLoader";
import type { createAppAPI } from "./usePlugins";

// Runtime `postMessage` API for a host page that frames GeoLibre (issue #1462).
// Where `?url=`, `?maponly`, and `?tool=` configure the app once at load time,
Expand Down Expand Up @@ -52,9 +54,16 @@ const VIEW_THROTTLE_MS = 250;
* @param mapControllerRef - Ref to the live map controller (shared with
* MapCanvas and the other bridges), used to drive and read the camera.
*/
export function useEmbedApi(mapControllerRef: RefObject<MapController | null>): void {
export function useEmbedApi(
mapControllerRef: RefObject<MapController | null>,
mapAppAPI: ReturnType<typeof createAppAPI> | null,
): void {
useEffect(() => {
if (typeof window === "undefined") return;
// `ready` promises that every command is usable. Plugin-backed data loaders
// join the API only after the map has initialized, so do not advertise the
// bridge during the earlier render where that API is still absent.
if (!mapAppAPI) return;
const allowedOrigins = readEmbedOrigins();
if (allowedOrigins.length === 0) return;
const host = window.parent;
Expand Down Expand Up @@ -112,6 +121,17 @@ export function useEmbedApi(mapControllerRef: RefObject<MapController | null>):
// host can tell its own load from one the user triggered.
let projectSourceUrl: string | null = projectUrlFromLocation();
let loadAbort: AbortController | null = null;
const dataLoadAborts = new Set<AbortController>();
let dataLoadQueue: Promise<void> = Promise.resolve();

const queueDataLoad = <T>(operation: () => Promise<T>): Promise<T> => {
const next = dataLoadQueue.then(operation, operation);
dataLoadQueue = next.then(
() => undefined,
() => undefined,
);
return next;
};

const loadProjectFromUrl = async (url: string) => {
// A second load supersedes the first; otherwise a slow fetch could land
Expand Down Expand Up @@ -225,6 +245,33 @@ export function useEmbedApi(mapControllerRef: RefObject<MapController | null>):
state.addLayer(layer, command.spec.beforeId);
return layer.id;
}
case "addData": {
const abort = new AbortController();
dataLoadAborts.add(abort);
const result = await queueDataLoad(() =>
loadDataUrl(mapAppAPI, command.url, {
styleUrl: command.styleUrl,
signal: abort.signal,
fit: command.fit,
}),
).finally(() => dataLoadAborts.delete(abort));
if (command.fit) {
const bounds = useAppStore
Comment thread
giswqs marked this conversation as resolved.
.getState()
.layers.filter((layer) => result.fitLayerIds.includes(layer.id))
.map(getLayerBounds)
.filter((value) => value !== null);
if (bounds.length) {
controller()?.fitBounds([
Math.min(...bounds.map((value) => value[0])),
Math.min(...bounds.map((value) => value[1])),
Math.max(...bounds.map((value) => value[2])),
Math.max(...bounds.map((value) => value[3])),
]);
}
Comment thread
giswqs marked this conversation as resolved.
}
return result.layerIds;
}
Comment thread
giswqs marked this conversation as resolved.
case "exportImage": {
const map = controller()?.getMap();
if (!map) throw new Error("The map is not ready yet");
Expand Down Expand Up @@ -365,11 +412,12 @@ export function useEmbedApi(mapControllerRef: RefObject<MapController | null>):
window.removeEventListener("message", handleMessage);
unsubscribe();
loadAbort?.abort();
for (const abort of dataLoadAborts) abort.abort();
dataLoadAborts.clear();
if (rafId !== null) cancelAnimationFrame(rafId);
if (trailingTimer !== null) window.clearTimeout(trailingTimer);
viewMap?.off("move", onMapMove);
viewMap?.off("moveend", onMapMove);
};
// Mount-only: mapControllerRef is a stable ref read lazily inside handlers.
}, [mapControllerRef]);
}, [mapControllerRef, mapAppAPI]);
}
24 changes: 24 additions & 0 deletions apps/geolibre-desktop/src/lib/embed-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export type EmbedCommand =
| { type: "setFilter"; layerId: string; expression: unknown[] | null }
| { type: "getViewport" }
| { type: "addLayer"; spec: AddLayerSpec }
| { type: "addData"; url: string; styleUrl: string | null; fit: boolean }
| { type: "exportImage" };

/** A parsed inbound message: the command plus the host's correlation id. */
Expand Down Expand Up @@ -583,6 +584,29 @@ export function parseEmbedRequest(
requestId,
};
}
case "addData": {
if (!isFetchableUrl(payload.url) || !/^https?:/i.test(payload.url)) {
return fail("addData: url must be an http(s) URL");
}
if (
payload.styleUrl !== undefined &&
(!isFetchableUrl(payload.styleUrl) || !/^https?:/i.test(payload.styleUrl))
) {
return fail("addData: styleUrl must be an http(s) URL");
}
if (payload.fit !== undefined && typeof payload.fit !== "boolean") {
return fail("addData: fit must be a boolean");
}
return {
command: {
type: "addData",
url: payload.url,
styleUrl: typeof payload.styleUrl === "string" ? payload.styleUrl : null,
fit: payload.fit ?? true,
},
requestId,
};
}
case "exportImage":
return { command: { type: "exportImage" }, requestId };
default:
Expand Down
Loading
Loading