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
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