Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
15 changes: 15 additions & 0 deletions apps/geolibre-desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { DirectionProvider } from "@geolibre/ui";
import { useTranslation } from "react-i18next";
import { useCallback, useState } from "react";
import { DesktopShell } from "./components/layout/DesktopShell";
import { OnboardingDialog } from "./components/layout/OnboardingDialog";
import { UpdateNotificationModal } from "./components/layout/UpdateNotificationModal";
import { useDesktopSettingsPersistence } from "./hooks/useDesktopSettings";
import { useLayoutOptions } from "./hooks/useLayoutOptions";
import { useProjectUrlLoader } from "./hooks/useProjectUrlLoader";
import { useDataUrlLoader } from "./hooks/useDataUrlLoader";
import { useBeforeUnloadGuard } from "./hooks/useBeforeUnloadGuard";
import { useRecentProjectsPersistence } from "./hooks/useRecentProjectsPersistence";
import { useLayerLibraryPersistence } from "./hooks/useLayerLibraryPersistence";
Expand All @@ -18,6 +20,7 @@ import { useThemeScheme } from "./hooks/useThemeScheme";
import { useUiProfileBootstrap } from "./hooks/useUiProfileBootstrap";
import { useUndoRedoShortcuts } from "./hooks/useUndoRedoShortcuts";
import { useWhiteboxToolUrl } from "./hooks/useWhiteboxToolUrl";
import { createAppAPI } from "./hooks/usePlugins";
import { languageDirection } from "./i18n/languages";

export default function App() {
Expand All @@ -26,7 +29,17 @@ export default function App() {
const { i18n } = useTranslation();
const layoutOptions = useLayoutOptions();
const { themeMode, toggleThemeMode } = useThemeMode();
// `onMapReady` fires again on every basemap swap (MapCanvas re-emits
// controller-ready from its `style.load` handler) and hands back a freshly
// built API object each time. Keep the first one: the identity feeds the
// `?data=` loader's effect deps, and a changing identity would re-run that
// one-shot import and duplicate its layers.
const [mapAppAPI, setMapAppAPI] = useState<ReturnType<typeof createAppAPI> | null>(null);
const handleMapReady = useCallback((api: ReturnType<typeof createAppAPI>) => {
setMapAppAPI((current) => current ?? api);
}, []);
const projectUrlLoadState = useProjectUrlLoader();
const dataUrlLoadState = useDataUrlLoader(mapAppAPI);
const { showOnboarding, dismissOnboarding } = useUiProfileBootstrap();
const { pending: pendingUpdate, remindLater, skipVersion } = useStartupUpdateCheck();

Expand All @@ -45,8 +58,10 @@ export default function App() {
<DesktopShell
layoutOptions={layoutOptions}
projectUrlLoadState={projectUrlLoadState}
dataUrlLoadState={dataUrlLoadState}
themeMode={themeMode}
onToggleThemeMode={toggleThemeMode}
onMapReady={handleMapReady}
/>
<OnboardingDialog open={showOnboarding} onClose={dismissOnboarding} />
<UpdateNotificationModal
Expand Down
46 changes: 44 additions & 2 deletions apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { useAppStore, type GeoLibreLayer } from "@geolibre/core";
import type { FeatureCollection } from "geojson";
import type { MapController, MapDiagnosticEvent } from "@geolibre/map";
import { MapCanvas, setExternalDeckLayerOrderHandler } from "@geolibre/map";
import { getLayerBounds, MapCanvas, setExternalDeckLayerOrderHandler } from "@geolibre/map";
import { useTranslation } from "react-i18next";
import {
addRasterToMap,
Expand Down Expand Up @@ -107,6 +107,7 @@
useSwipeSplitViewExclusivity,
useTimeSliderAutoClose,
} from "../../hooks/usePlugins";
import type { DataUrlLoadState } from "../../hooks/useDataUrlLoader";
import { registerKmlSuperOverlayProtocol } from "../../lib/kml-super-overlay";
import { registerMbtilesProtocol } from "../../lib/mbtiles";
import { hasReverseGeocodeConsent } from "../../lib/reverse-geocode-consent";
Expand Down Expand Up @@ -483,8 +484,10 @@
interface DesktopShellProps {
layoutOptions: LayoutOptions;
projectUrlLoadState?: ProjectUrlLoadState;
dataUrlLoadState?: DataUrlLoadState;
themeMode: ThemeMode;
onToggleThemeMode: () => void;
onMapReady?: (app: ReturnType<typeof createAppAPI>) => void;
}

function hasDroppedFiles(event: DragEvent<HTMLElement>): boolean {
Expand Down Expand Up @@ -536,8 +539,10 @@
export function DesktopShell({
layoutOptions,
projectUrlLoadState,
dataUrlLoadState,
themeMode,
onToggleThemeMode,
onMapReady,
}: DesktopShellProps) {
const { t } = useTranslation();
const shellRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -600,6 +605,30 @@
const activeResizeCleanupRef = useRef<(() => void) | null>(null);
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.
useEffect(() => {
const fitLayerIds = dataUrlLoadState?.fitLayerIds;
if (dataUrlLoadState?.status !== "loaded" || !fitLayerIds?.length) return;
const controller = mapControllerRef.current;
if (!controller) return;
const bounds = useAppStore
.getState()
.layers.filter((layer) => fitLayerIds.includes(layer.id))
.map(getLayerBounds)
.filter((value) => value !== null);
if (!bounds.length) return;
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])),
]);
}, [dataUrlLoadState?.fitLayerIds, dataUrlLoadState?.status]);

const projectHistory = useProjectHistory(mapControllerRef);
const [projectHistoryOpen, setProjectHistoryOpen] = useState(false);
// The place shown in the Wikipedia knowledge card, or null when it is closed.
Expand Down Expand Up @@ -1236,7 +1265,8 @@

const handleMapControllerReady = useCallback(() => {
setMapReadyGeneration((generation) => generation + 1);
}, []);
onMapReady?.(createAppAPI(mapControllerRef));
}, [onMapReady]);

// Keep the on-map compass (reset pitch/bearing) control's tooltip translated.
// Re-runs when the controller (re)initialises (mapReadyGeneration) and on
Expand Down Expand Up @@ -1711,7 +1741,7 @@
disposed = true;
unlisten?.();
};
}, [

Check warning on line 1744 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 @@ -1859,7 +1889,7 @@
clearDropMessageLater();
}
},
[

Check warning on line 1892 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 Expand Up @@ -2725,6 +2755,18 @@
{projectUrlLoadState.error}
</div>
) : null}
{dataUrlLoadState?.error ? (
// A link can carry both `url=` and `data=`, and both loaders can fail.
// Drop below the project banner so neither message is covered.
<div
aria-live="assertive"
className={`pointer-events-none absolute left-1/2 z-50 max-w-[min(90vw,32rem)] -translate-x-1/2 rounded-md border bg-background px-3 py-2 text-center text-sm text-destructive shadow-lg ${
projectUrlLoadState?.error ? "top-28" : "top-14"
}`}
>
{dataUrlLoadState.error}
</div>
) : null}
Comment thread
giswqs marked this conversation as resolved.
{crsWarning ? (
<div
data-testid="crs-warning"
Expand Down
53 changes: 50 additions & 3 deletions apps/geolibre-desktop/src/components/panels/LayerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ import {
applyQmlImport,
applySldImport,
buildMapboxStyle,
buildGeoLibreQueryStyle,
buildQml,
buildSld,
isPlaceholderLayer,
mapboxStyleToJson,
geoLibreStyleSourceName,
parseMapboxStyle,
parseQml,
parseSld,
Expand Down Expand Up @@ -1668,6 +1670,40 @@ export function LayerPanel({
[exportLayerStyle, t],
);

// Export the compact style consumed by `?data=…&style=…`. Its render-layer
// source is the original data filename stem, which also lets one style file
// target individual GeoJSON members of a ZIP archive.
const handleExportGeoLibreStyle = useCallback(
(layer: GeoLibreLayer) =>
exportLayerStyle(
layer,
(geojson) => {
if (!geojson) {
return {
error:
geojsonVectorSourceId(layer) !== null
? t("layers.exportStyleDataNotReady")
: t("layers.exportStyleNeedsFeatures"),
};
}
const result = buildGeoLibreQueryStyle(layer, geojson);
return { text: mapboxStyleToJson(result), warnings: result.warnings };
},
{
defaultName: `${sanitizeExportFileName(geoLibreStyleSourceName(layer))}.geolibre.style.json`,
filters: [{ name: "GeoLibre URL style", extensions: ["json"] }],
browserTypes: [
{
description: "GeoLibre URL style",
accept: { "application/json": [".json"] },
},
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
mimeType: "application/json",
},
),
[exportLayerStyle, t],
);

// Export a vector layer's symbology as an OGC SLD document, the interchange
// format QGIS, GeoServer, MapServer, and ArcGIS speak. Unlike the Mapbox
// export, SLD carries no data, so a layer whose features are not readable can
Expand Down Expand Up @@ -1721,7 +1757,8 @@ export function LayerPanel({
[exportLayerStyle],
);

// Import a symbology file (Mapbox GL / MapLibre style JSON or an OGC SLD) and
// Import a symbology file (including GeoLibre URL and Mapbox/MapLibre style
// JSON, or an OGC SLD/QGIS QML) and
// apply it to a vector layer, so cartography authored elsewhere (QGIS,
// GeoServer, another map, or a style exported from GeoLibre) can be brought
// back in instead of being rebuilt by hand. The format is detected from the
Expand All @@ -1734,7 +1771,7 @@ export function LayerPanel({
const picked = await openLocalDataFileWithFallback({
filters: [
{
name: "Style (Mapbox GL / SLD / QML)",
name: "Style (GeoLibre URL / Mapbox GL / SLD / QML)",
extensions: ["json", "sld", "qml", "xml"],
},
],
Expand All @@ -1750,7 +1787,9 @@ export function LayerPanel({
// Detect the format from the content, which is more reliable than the
// file extension (a `.xml` can hold either XML dialect): a QGIS QML has
// a `<qgis>`/`renderer-v2` root, an SLD a `StyledLayerDescriptor` root,
// and everything else is parsed as a Mapbox GL style JSON.
// and everything else (including a `.geolibre.style.json` export) is
// parsed as Mapbox GL style JSON. Its source binding is intentionally
// irrelevant here: importing applies symbology to the selected layer.
const trimmed = picked.text.trimStart();
const isXml = trimmed.startsWith("<");
const isQml = isXml && isQmlStyleXml(picked.text);
Expand Down Expand Up @@ -3706,6 +3745,14 @@ export function LayerPanel({
<DropdownMenuSubContent>
{canExportLayer && (
<>
<DropdownMenuItem
onSelect={() => {
void handleExportGeoLibreStyle(layer);
}}
>
<Download className="me-2 h-3.5 w-3.5" />
{t("layers.exportGeoLibreStyle")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void handleExportStyle(layer);
Expand Down
Loading
Loading