From dcc930c5afe255a64d681e11822a40d019476fe6 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 17:43:48 -0400 Subject: [PATCH 1/3] fix(desktop): save the Print Layout composer in the project The Print Layout composer kept every setting in the dialog's own component state, and the dialog is mounted once for the life of the app. Nothing about the composed page was written to `.geolibre.json`, so reopening a project lost the title, page size, orientation and every other setting, while the composer went on showing whatever the previously open project had been composing. Add a `printLayout` section to the project format, backed by a `PrintLayoutConfig` in `@geolibre/core`: - The composer's controls seed from the open project's saved config, and the dialog is remounted on every project load, so an opened project's layout reaches the controls (and the previous project's does not). - Composer edits flow back into the store, so Save writes them. A write that changes nothing is ignored, so opening the composer does not mark the project dirty. - The section is written only once a setting differs from the defaults, so a project that never opened the composer serializes exactly as before. - A hand-edited or partial section is filled out from the defaults field by field, and a data or atlas block naming a layer the project no longer carries opens cleared rather than dangling. A blank title now follows the project name at draw time instead of being seeded into the field. Seeding it wrote to the layout (dirtying the project) just because the composer was opened, and a title seeded once went stale when the project was renamed. The Title field's placeholder shows the project name it falls back to. Per-session state stays out of the project: the captured map image, the current atlas page, export/clipboard notices and the dialog's panel widths. Refs https://github.com/opengeos/GeoLibre/discussions/1992 --- .../components/layout/PrintLayoutDialog.tsx | 405 +++++++++++---- .../src/components/layout/TopToolbar.tsx | 4 + .../src/hooks/useProjectFileActions.ts | 1 + .../src/lib/build-project-snapshot.ts | 1 + docs/project-format.md | 43 ++ packages/core/src/index.ts | 1 + packages/core/src/print-layout-config.ts | 489 ++++++++++++++++++ packages/core/src/project.ts | 21 + packages/core/src/store.ts | 19 + packages/core/src/types.ts | 7 + tests/core-project.test.ts | 94 ++++ tests/print-layout-config.test.ts | 229 ++++++++ 12 files changed, 1218 insertions(+), 96 deletions(-) create mode 100644 packages/core/src/print-layout-config.ts create mode 100644 tests/print-layout-config.test.ts diff --git a/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx b/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx index 92272bd181..72e60593d1 100644 --- a/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx @@ -5,6 +5,7 @@ import { getVectorColorRamp, useAppStore, VECTOR_COLOR_RAMPS, + type PrintLayoutConfig, } from "@geolibre/core"; import { loadMarkerSvgImage, type MapController } from "@geolibre/map"; import { GRATICULE_LABEL_LAYER_ID } from "@geolibre/plugins"; @@ -187,40 +188,53 @@ export function PrintLayoutDialog({ // Follow the map's scale-bar unit preference so the printed bar matches the // on-screen one (metric / imperial / nautical). const scaleUnit = useAppStore((s) => s.preferences.map.scaleUnit); - - const [title, setTitle] = useState(""); - const [subtitle, setSubtitle] = useState(""); - const [titlePlacement, setTitlePlacement] = useState<"outside" | "inside">("outside"); - const [titleAlign, setTitleAlign] = useState<"left" | "center" | "right">("center"); - const [paperSize, setPaperSize] = useState("a4"); - const [orientation, setOrientation] = useState("landscape"); - const [customWidth, setCustomWidth] = useState(1280); - const [customHeight, setCustomHeight] = useState(720); - const [customUnit, setCustomUnit] = useState("px"); - const [showTitle, setShowTitle] = useState(true); - const [showSubtitle, setShowSubtitle] = useState(true); - const [showLegend, setShowLegend] = useState(true); - const [showScaleBar, setShowScaleBar] = useState(true); - const [showNorthArrow, setShowNorthArrow] = useState(true); - const [navigationGrouped, setNavigationGrouped] = useState(true); - const [showFooter, setShowFooter] = useState(false); - const [footerText, setFooterText] = useState(""); - const [showDate, setShowDate] = useState(true); - const [dateText, setDateText] = useState(""); - const [showAttribution, setShowAttribution] = useState(true); - const [pageMargin, setPageMargin] = useState<"normal" | "narrow" | "none">("normal"); - const [showPageBorder, setShowPageBorder] = useState(false); - const [pageBorderColor, setPageBorderColor] = useState("#111827"); - const [pageBorderWidth, setPageBorderWidth] = useState(2); + const setPrintLayout = useAppStore((s) => s.setPrintLayout); + // The composer's settings belong to the project, so the controls start from + // what it was saved with. Read once per mount: the dialog is remounted on + // every project load (see the `key` at its render site), which is what makes + // an opened project's layout reach these controls instead of the previous + // project's (GeoLibre discussion #1992). + const [initialLayout] = useState(() => useAppStore.getState().printLayout); + + const [title, setTitle] = useState(initialLayout.title); + const [subtitle, setSubtitle] = useState(initialLayout.subtitle); + const [titlePlacement, setTitlePlacement] = useState<"outside" | "inside">( + initialLayout.titlePlacement, + ); + const [titleAlign, setTitleAlign] = useState<"left" | "center" | "right">( + initialLayout.titleAlign, + ); + const [paperSize, setPaperSize] = useState(initialLayout.paperSize); + const [orientation, setOrientation] = useState(initialLayout.orientation); + const [customWidth, setCustomWidth] = useState(initialLayout.customWidth); + const [customHeight, setCustomHeight] = useState(initialLayout.customHeight); + const [customUnit, setCustomUnit] = useState(initialLayout.customUnit); + const [showTitle, setShowTitle] = useState(initialLayout.showTitle); + const [showSubtitle, setShowSubtitle] = useState(initialLayout.showSubtitle); + const [showLegend, setShowLegend] = useState(initialLayout.showLegend); + const [showScaleBar, setShowScaleBar] = useState(initialLayout.showScaleBar); + const [showNorthArrow, setShowNorthArrow] = useState(initialLayout.showNorthArrow); + const [navigationGrouped, setNavigationGrouped] = useState(initialLayout.navigationGrouped); + const [showFooter, setShowFooter] = useState(initialLayout.showFooter); + const [footerText, setFooterText] = useState(initialLayout.footerText); + const [showDate, setShowDate] = useState(initialLayout.showDate); + const [dateText, setDateText] = useState(initialLayout.dateText); + const [showAttribution, setShowAttribution] = useState(initialLayout.showAttribution); + const [pageMargin, setPageMargin] = useState<"normal" | "narrow" | "none">( + initialLayout.pageMargin, + ); + const [showPageBorder, setShowPageBorder] = useState(initialLayout.showPageBorder); + const [pageBorderColor, setPageBorderColor] = useState(initialLayout.pageBorderColor); + const [pageBorderWidth, setPageBorderWidth] = useState(initialLayout.pageBorderWidth); // Map frame (the border around the map body). Width is a 0–10 scale; 0 hides // the frame. Defaults match the original hardcoded hairline (GH #749). - const [mapBorderColor, setMapBorderColor] = useState("#9ca3af"); - const [mapBorderWidth, setMapBorderWidth] = useState(1); - const [mapBackground, setMapBackground] = useState("#e5e7eb"); + const [mapBorderColor, setMapBorderColor] = useState(initialLayout.mapBorderColor); + const [mapBorderWidth, setMapBorderWidth] = useState(initialLayout.mapBorderWidth); + const [mapBackground, setMapBackground] = useState(initialLayout.mapBackground); // Draft for the free-form hex field; only complete #RGB / #RRGGBB values are // committed to mapBackground (which also drives and the // canvas fillStyle), so a half-typed "#" never corrupts the layout colour. - const [mapBackgroundDraft, setMapBackgroundDraft] = useState("#e5e7eb"); + const [mapBackgroundDraft, setMapBackgroundDraft] = useState(initialLayout.mapBackground); const commitMapBackground = useCallback((value: string) => { setMapBackgroundDraft(value); if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value.trim())) { @@ -228,29 +242,33 @@ export function PrintLayoutDialog({ } }, []); // Native colorbar composed in the dialog (GH follow-up). - const [showColorbar, setShowColorbar] = useState(false); - const [colorbarRamp, setColorbarRamp] = useState("viridis"); - const [colorbarMin, setColorbarMin] = useState("0"); - const [colorbarMax, setColorbarMax] = useState("100"); - const [colorbarLabel, setColorbarLabel] = useState(""); + const [showColorbar, setShowColorbar] = useState(initialLayout.showColorbar); + const [colorbarRamp, setColorbarRamp] = useState(initialLayout.colorbarRamp); + const [colorbarMin, setColorbarMin] = useState(initialLayout.colorbarMin); + const [colorbarMax, setColorbarMax] = useState(initialLayout.colorbarMax); + const [colorbarLabel, setColorbarLabel] = useState(initialLayout.colorbarLabel); const [colorbarOrientation, setColorbarOrientation] = useState<"vertical" | "horizontal">( - "vertical", + initialLayout.colorbarOrientation, ); // Bar length as a percentage of the body width/height. - const [colorbarLength, setColorbarLength] = useState(34); + const [colorbarLength, setColorbarLength] = useState(initialLayout.colorbarLength); // User-defined legend composed in the dialog (like Controls -> Legend). - const [showCustomLegend, setShowCustomLegend] = useState(false); - const [customLegendTitle, setCustomLegendTitle] = useState("Legend"); + const [showCustomLegend, setShowCustomLegend] = useState(initialLayout.showCustomLegend); + const [customLegendTitle, setCustomLegendTitle] = useState(initialLayout.customLegendTitle); const [customLegendEntries, setCustomLegendEntries] = useState< { id: string; label: string; color: string }[] - >([ - { id: "cl-1", label: "Class 1", color: "#2563eb" }, - { id: "cl-2", label: "Class 2", color: "#16a34a" }, - ]); + >(initialLayout.customLegendEntries); const [customLegendPosition, setCustomLegendPosition] = useState< "top-left" | "top-right" | "bottom-left" | "bottom-right" - >("top-left"); - const customLegendId = useRef(2); + >(initialLayout.customLegendPosition); + // Continue the id sequence past whatever the project restored, so a new + // swatch never collides with a saved one. + const customLegendId = useRef( + initialLayout.customLegendEntries.reduce((max, entry) => { + const parsed = Number(/^cl-(\d+)$/.exec(entry.id)?.[1]); + return Number.isFinite(parsed) && parsed > max ? parsed : max; + }, initialLayout.customLegendEntries.length), + ); const [legendDict, setLegendDict] = useState(""); const [legendDictError, setLegendDictError] = useState(null); @@ -283,58 +301,70 @@ export function PrintLayoutDialog({ // Default away from the bottom-right nav duo and top-left legend. const [colorbarPosition, setColorbarPosition] = useState< "top-left" | "top-right" | "bottom-left" | "bottom-right" - >("top-right"); + >(initialLayout.colorbarPosition); // Data blocks: attribute table + chart composed on the page (GH #1324). - const [showDataTable, setShowDataTable] = useState(false); - const [tableLayerId, setTableLayerId] = useState(""); - const [tableTitle, setTableTitle] = useState(""); + const [showDataTable, setShowDataTable] = useState(initialLayout.showDataTable); + const [tableLayerId, setTableLayerId] = useState(initialLayout.tableLayerId); + const [tableTitle, setTableTitle] = useState(initialLayout.tableTitle); // Explicitly checked columns; empty = the layer's first few fields. - const [tableColumns, setTableColumns] = useState([]); - const [tableSortField, setTableSortField] = useState(""); - const [tableSortDesc, setTableSortDesc] = useState(false); - const [tableMaxRows, setTableMaxRows] = useState(DEFAULT_TABLE_ROWS); - const [tableFitRows, setTableFitRows] = useState(false); - const [tablePosition, setTablePosition] = useState("bottom-left"); - const [tablePageFilter, setTablePageFilter] = useState("contained"); - const [tableFilterToAtlasFeature, setTableFilterToAtlasFeature] = useState(false); - const [showDataChart, setShowDataChart] = useState(false); - const [chartLayerId, setChartLayerId] = useState(""); - const [chartTitle, setChartTitle] = useState(""); - const [chartType, setChartType] = useState("bar"); - const [chartCategoryField, setChartCategoryField] = useState(""); - const [chartAggregation, setChartAggregation] = useState("count"); - const [chartValueField, setChartValueField] = useState(""); + const [tableColumns, setTableColumns] = useState(initialLayout.tableColumns); + const [tableSortField, setTableSortField] = useState(initialLayout.tableSortField); + const [tableSortDesc, setTableSortDesc] = useState(initialLayout.tableSortDesc); + const [tableMaxRows, setTableMaxRows] = useState(initialLayout.tableMaxRows); + const [tableFitRows, setTableFitRows] = useState(initialLayout.tableFitRows); + const [tablePosition, setTablePosition] = useState(initialLayout.tablePosition); + const [tablePageFilter, setTablePageFilter] = useState( + initialLayout.tablePageFilter, + ); + const [tableFilterToAtlasFeature, setTableFilterToAtlasFeature] = useState( + initialLayout.tableFilterToAtlasFeature, + ); + const [showDataChart, setShowDataChart] = useState(initialLayout.showDataChart); + const [chartLayerId, setChartLayerId] = useState(initialLayout.chartLayerId); + const [chartTitle, setChartTitle] = useState(initialLayout.chartTitle); + const [chartType, setChartType] = useState(initialLayout.chartType); + const [chartCategoryField, setChartCategoryField] = useState(initialLayout.chartCategoryField); + const [chartAggregation, setChartAggregation] = useState( + initialLayout.chartAggregation, + ); + const [chartValueField, setChartValueField] = useState(initialLayout.chartValueField); // Top-right by default: the scale bar + north arrow duo occupies the // bottom-right corner out of the box. - const [chartPosition, setChartPosition] = useState("top-right"); - const [chartPageFilter, setChartPageFilter] = useState("contained"); + const [chartPosition, setChartPosition] = useState(initialLayout.chartPosition); + const [chartPageFilter, setChartPageFilter] = useState( + initialLayout.chartPageFilter, + ); // Cartographic title block ("stempel") fields (GH #522). - const [showInfoBlock, setShowInfoBlock] = useState(false); - const [author, setAuthor] = useState(""); - const [projectNumber, setProjectNumber] = useState(""); - const [crs, setCrs] = useState(""); - const [revision, setRevision] = useState(""); + const [showInfoBlock, setShowInfoBlock] = useState(initialLayout.showInfoBlock); + const [author, setAuthor] = useState(initialLayout.author); + const [projectNumber, setProjectNumber] = useState(initialLayout.projectNumber); + const [crs, setCrs] = useState(initialLayout.crs); + const [revision, setRevision] = useState(initialLayout.revision); // Custom print extent drawn on the map (GH #523). - const [captureMode, setCaptureMode] = useState<"viewport" | "extent">("viewport"); - const [extentBbox, setExtentBbox] = useState(null); + const [captureMode, setCaptureMode] = useState<"viewport" | "extent">(initialLayout.captureMode); + const [extentBbox, setExtentBbox] = useState(initialLayout.extentBbox); const [drawingExtent, setDrawingExtent] = useState(false); // Atlas / map series: one page per coverage-layer feature (GH #1291). - const [atlasEnabled, setAtlasEnabled] = useState(false); - const [atlasLayerId, setAtlasLayerId] = useState(""); + const [atlasEnabled, setAtlasEnabled] = useState(initialLayout.atlasEnabled); + const [atlasLayerId, setAtlasLayerId] = useState(initialLayout.atlasLayerId); // Coverage strategy: one page per feature, or pages tiling the layer's line // features in fixed-length stretches (GH #1291 follow-up). - const [atlasCoverage, setAtlasCoverage] = useState<"features" | "line">("features"); - const [atlasSegmentKm, setAtlasSegmentKm] = useState("20"); - const [atlasNameField, setAtlasNameField] = useState(""); - const [atlasExtentMode, setAtlasExtentMode] = useState<"margin" | "scale">("margin"); - const [atlasMarginPct, setAtlasMarginPct] = useState(10); - const [atlasMaskEnabled, setAtlasMaskEnabled] = useState(false); - const [atlasScale, setAtlasScale] = useState("50000"); - const [atlasSortField, setAtlasSortField] = useState(""); - const [atlasSortDescending, setAtlasSortDescending] = useState(false); - const [atlasFilter, setAtlasFilter] = useState(""); + const [atlasCoverage, setAtlasCoverage] = useState<"features" | "line">( + initialLayout.atlasCoverage, + ); + const [atlasSegmentKm, setAtlasSegmentKm] = useState(initialLayout.atlasSegmentKm); + const [atlasNameField, setAtlasNameField] = useState(initialLayout.atlasNameField); + const [atlasExtentMode, setAtlasExtentMode] = useState<"margin" | "scale">( + initialLayout.atlasExtentMode, + ); + const [atlasMarginPct, setAtlasMarginPct] = useState(initialLayout.atlasMarginPct); + const [atlasMaskEnabled, setAtlasMaskEnabled] = useState(initialLayout.atlasMaskEnabled); + const [atlasScale, setAtlasScale] = useState(initialLayout.atlasScale); + const [atlasSortField, setAtlasSortField] = useState(initialLayout.atlasSortField); + const [atlasSortDescending, setAtlasSortDescending] = useState(initialLayout.atlasSortDescending); + const [atlasFilter, setAtlasFilter] = useState(initialLayout.atlasFilter); const [atlasFilenamePattern, setAtlasFilenamePattern] = useState( - "{atlas.pagenumber}-{atlas.name}", + initialLayout.atlasFilenamePattern, ); const [atlasIndex, setAtlasIndex] = useState(0); // True while the atlas is driving the live map (stepping or exporting), so @@ -621,9 +651,9 @@ export function PrintLayoutDialog({ [mapControllerRef, t, captureMode, extentBbox], ); - // Capture the map and seed defaults only on the closed -> open transition, so - // a background project-name change while the dialog is open does not replace - // the snapshot the user is composing. + // Capture the map only on the closed -> open transition, so a background + // change while the dialog is open does not replace the snapshot the user is + // composing. useEffect(() => { const map = mapControllerRef.current?.getMap(); if (open && !wasOpenRef.current) { @@ -640,8 +670,6 @@ export function PrintLayoutDialog({ copiedTimeoutRef.current = null; } setCopied(false); - setTitle((prev) => prev || (projectName ?? "").trim()); - setDateText((prev) => prev || new Date().toLocaleDateString()); // Re-show a previously drawn extent box while composing. if (map && extentBbox) showPrintExtent(map, extentBbox); // With an active atlas persisting from a prior session, skip the plain @@ -657,7 +685,7 @@ export function PrintLayoutDialog({ } } wasOpenRef.current = open; - }, [open, projectName, recapture, mapControllerRef, extentBbox]); + }, [open, recapture, mapControllerRef, extentBbox]); // Clean up if the dialog unmounts: abort an in-progress draw (so its window // listeners are torn down and it does not setState on an unmounted component) @@ -693,10 +721,195 @@ export function PrintLayoutDialog({ [isCustom, customWidth, customHeight, customUnit], ); - const options = useMemo( + // Everything the composer holds that describes the project's map document, + // in the shape the project file stores. The literal is checked against + // `PrintLayoutConfig` both ways: assigning these control values in, and the + // seeding above assigning them back out, so this and the storage contract in + // `@geolibre/core` cannot drift apart without failing the build. + const layoutConfig = useMemo( () => ({ title, subtitle, + titlePlacement, + titleAlign, + paperSize, + orientation, + customWidth, + customHeight, + customUnit, + pageMargin, + showPageBorder, + pageBorderColor, + pageBorderWidth, + mapBorderColor, + mapBorderWidth, + mapBackground, + showTitle, + showSubtitle, + showLegend, + showScaleBar, + showNorthArrow, + navigationGrouped, + showFooter, + footerText, + showDate, + dateText, + showAttribution, + showColorbar, + colorbarRamp, + colorbarMin, + colorbarMax, + colorbarLabel, + colorbarOrientation, + colorbarLength, + colorbarPosition, + showCustomLegend, + customLegendTitle, + customLegendEntries, + customLegendPosition, + showDataTable, + tableLayerId, + tableTitle, + tableColumns, + tableSortField, + tableSortDesc, + tableMaxRows, + tableFitRows, + tablePosition, + tablePageFilter, + tableFilterToAtlasFeature, + showDataChart, + chartLayerId, + chartTitle, + chartType, + chartCategoryField, + chartAggregation, + chartValueField, + chartPosition, + chartPageFilter, + showInfoBlock, + author, + projectNumber, + crs, + revision, + captureMode, + extentBbox, + atlasEnabled, + atlasLayerId, + atlasCoverage, + atlasSegmentKm, + atlasNameField, + atlasExtentMode, + atlasMarginPct, + atlasMaskEnabled, + atlasScale, + atlasSortField, + atlasSortDescending, + atlasFilter, + atlasFilenamePattern, + }), + [ + title, + subtitle, + titlePlacement, + titleAlign, + paperSize, + orientation, + customWidth, + customHeight, + customUnit, + pageMargin, + showPageBorder, + pageBorderColor, + pageBorderWidth, + mapBorderColor, + mapBorderWidth, + mapBackground, + showTitle, + showSubtitle, + showLegend, + showScaleBar, + showNorthArrow, + navigationGrouped, + showFooter, + footerText, + showDate, + dateText, + showAttribution, + showColorbar, + colorbarRamp, + colorbarMin, + colorbarMax, + colorbarLabel, + colorbarOrientation, + colorbarLength, + colorbarPosition, + showCustomLegend, + customLegendTitle, + customLegendEntries, + customLegendPosition, + showDataTable, + tableLayerId, + tableTitle, + tableColumns, + tableSortField, + tableSortDesc, + tableMaxRows, + tableFitRows, + tablePosition, + tablePageFilter, + tableFilterToAtlasFeature, + showDataChart, + chartLayerId, + chartTitle, + chartType, + chartCategoryField, + chartAggregation, + chartValueField, + chartPosition, + chartPageFilter, + showInfoBlock, + author, + projectNumber, + crs, + revision, + captureMode, + extentBbox, + atlasEnabled, + atlasLayerId, + atlasCoverage, + atlasSegmentKm, + atlasNameField, + atlasExtentMode, + atlasMarginPct, + atlasMaskEnabled, + atlasScale, + atlasSortField, + atlasSortDescending, + atlasFilter, + atlasFilenamePattern, + ], + ); + + // Push composer edits into the project so Save writes them and reopening the + // project restores them. `setPrintLayout` ignores a config equal to the one + // already stored, so this effect's first run (which replays exactly what the + // controls were seeded with) does not mark the project dirty. + useEffect(() => { + setPrintLayout(layoutConfig); + }, [layoutConfig, setPrintLayout]); + + // Blank title / date follow the project rather than being written into the + // controls: seeding them on open would edit the saved layout (and mark the + // project dirty) just because the composer was opened, and a title seeded + // once would go stale when the project is renamed. + const resolvedTitle = title.trim() ? title : (projectName ?? "").trim(); + const resolvedDateText = dateText.trim() ? dateText : new Date().toLocaleDateString(); + + const options = useMemo( + () => ({ + title: resolvedTitle, + subtitle, paperSize, orientation, customSize, @@ -712,7 +925,7 @@ export function PrintLayoutDialog({ showFooter, footerText, showDate, - dateText, + dateText: resolvedDateText, showAttribution, pageMargin, showPageBorder, @@ -770,7 +983,7 @@ export function PrintLayoutDialog({ mapFit, }), [ - title, + resolvedTitle, subtitle, paperSize, orientation, @@ -787,7 +1000,7 @@ export function PrintLayoutDialog({ showFooter, footerText, showDate, - dateText, + resolvedDateText, showAttribution, pageMargin, showPageBorder, @@ -1858,7 +2071,7 @@ export function PrintLayoutDialog({ id="layout-title" value={title} onChange={(e) => setTitle(e.target.value)} - placeholder={t("printLayout.titlePlaceholder")} + placeholder={(projectName ?? "").trim() || t("printLayout.titlePlaceholder")} />
diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 37821e55a1..2f9d43a88d 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -2000,7 +2000,11 @@ export function TopToolbar({ mapControllerRef={mapControllerRef} /> )} + {/* Remount on every project load so the composer starts from the opened + project's saved layout instead of keeping the previous project's + settings and captured map (GeoLibre discussion #1992). */} Print Layout) belongs to the project, so +the page it composes reopens as it was saved. The section is written only once +a setting differs from the defaults, so a project that never opened the composer +carries no `printLayout` key. + +```json +{ + "title": "Dentists by region", + "subtitle": "2026", + "paperSize": "a3", + "orientation": "portrait", + "pageMargin": "normal", + "showLegend": true, + "showScaleBar": true, + "showNorthArrow": true, + "showDataTable": true, + "tableLayerId": "layer-a", + "tableColumns": ["name", "count"], + "atlasEnabled": false +} +``` + +- Title block: `title`, `subtitle`, `titlePlacement`, `titleAlign`. A blank + `title` follows the project name rather than freezing a copy of it. +- Page: `paperSize` (`a4`, `a3`, `letter`, `legal`, `tabloid`, `fullhd`, `hd`, + `uhd4k`, `square`, `custom`), `orientation`, `customWidth` / `customHeight` / + `customUnit` for `custom`, `pageMargin`, and the page/map frame colours. +- Elements: the `show*` toggles for legend, scale bar, north arrow, footer, + date, attribution, colorbar, custom legend and info block, with their + per-element settings. +- Data blocks: `tableLayerId` / `chartLayerId` name a project layer; a block + whose layer is missing from the project opens cleared rather than blank. +- Atlas: `atlasEnabled` plus the coverage layer, extent mode, sorting, filter + and filename pattern for the map series. + +Unknown or malformed values fall back to the default for that field, so a +hand-edited file never leaves the composer in an unusable state. Per-session +state (the captured map image, the current atlas page, the dialog's panel +widths) is deliberately not stored. + ## Story map A story map turns the project into a scroll-driven narrative. Each chapter diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c957e86c21..5d3874eec0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export * from "./layer-library"; export * from "./layer-defaults"; export * from "./layer-style-clipboard"; export * from "./layer-groups"; +export * from "./print-layout-config"; export { createSampleStoryMap } from "./storymap-sample"; export { scrubWidgetsForRemovedLayers, diff --git a/packages/core/src/print-layout-config.ts b/packages/core/src/print-layout-config.ts new file mode 100644 index 0000000000..cc0b4fcf7a --- /dev/null +++ b/packages/core/src/print-layout-config.ts @@ -0,0 +1,489 @@ +/** + * Print Layout composer settings that belong to a project. + * + * The Print Layout dialog composes a printable page out of the current map: + * a title block, page size and orientation, the cartographic furniture + * (legend, scale bar, north arrow), optional data blocks, and an atlas / + * map-series definition. All of that describes the *project's* map document, + * so it round-trips through `.geolibre.json` (GeoLibre discussion #1992 — + * before this, reopening a project lost the title, page format and + * orientation, and the dialog kept showing the previous project's settings). + * + * Deliberately **not** in here: everything that is per-session rather than + * part of the document — the captured map image, export progress, error and + * clipboard notices, the current atlas page, and the dialog's own chrome + * (panel widths, dialog size). + * + * The unions below are the storage contract. Their app-side counterparts + * (`PaperSizeId`, `Orientation`, `BodyCorner`, ... in + * `apps/geolibre-desktop/src/lib/`) are checked against these by ordinary + * assignment in the dialog's snapshot/restore paths, so adding a paper size or + * a corner on one side without the other fails the build. + */ + +/** A page corner an overlay block can be pinned to. */ +export type PrintLayoutCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +/** Which features a data block covers when the atlas paginates the map. */ +export type PrintLayoutPageFilter = "all" | "contained" | "intersecting"; + +/** One hand-authored swatch in the composer's user-defined legend. */ +export interface PrintLayoutLegendEntry { + id: string; + label: string; + color: string; +} + +export interface PrintLayoutConfig { + // Title block + title: string; + subtitle: string; + titlePlacement: "outside" | "inside"; + titleAlign: "left" | "center" | "right"; + + // Page + paperSize: + | "a4" + | "a3" + | "letter" + | "legal" + | "tabloid" + | "fullhd" + | "hd" + | "uhd4k" + | "square" + | "custom"; + orientation: "portrait" | "landscape"; + customWidth: number; + customHeight: number; + customUnit: "mm" | "px"; + pageMargin: "normal" | "narrow" | "none"; + showPageBorder: boolean; + pageBorderColor: string; + pageBorderWidth: number; + + // Map frame + mapBorderColor: string; + mapBorderWidth: number; + mapBackground: string; + + // Map elements + showTitle: boolean; + showSubtitle: boolean; + showLegend: boolean; + showScaleBar: boolean; + showNorthArrow: boolean; + navigationGrouped: boolean; + showFooter: boolean; + footerText: string; + showDate: boolean; + /** Blank means "today", resolved when the page is drawn. */ + dateText: string; + showAttribution: boolean; + + // Colorbar + showColorbar: boolean; + colorbarRamp: string; + /** Kept as typed text so a half-entered value survives a round trip. */ + colorbarMin: string; + colorbarMax: string; + colorbarLabel: string; + colorbarOrientation: "vertical" | "horizontal"; + colorbarLength: number; + colorbarPosition: PrintLayoutCorner; + + // User-defined legend + showCustomLegend: boolean; + customLegendTitle: string; + customLegendEntries: PrintLayoutLegendEntry[]; + customLegendPosition: PrintLayoutCorner; + + // Attribute-table block + showDataTable: boolean; + tableLayerId: string; + tableTitle: string; + tableColumns: string[]; + tableSortField: string; + tableSortDesc: boolean; + tableMaxRows: number; + tableFitRows: boolean; + tablePosition: PrintLayoutCorner; + tablePageFilter: PrintLayoutPageFilter; + tableFilterToAtlasFeature: boolean; + + // Chart block + showDataChart: boolean; + chartLayerId: string; + chartTitle: string; + chartType: "bar" | "pie" | "line"; + chartCategoryField: string; + chartAggregation: "count" | "sum" | "mean"; + chartValueField: string; + chartPosition: PrintLayoutCorner; + chartPageFilter: PrintLayoutPageFilter; + + // Info block ("title block" / cartouche) + showInfoBlock: boolean; + author: string; + projectNumber: string; + crs: string; + revision: string; + + // What the page captures + captureMode: "viewport" | "extent"; + /** Drawn print extent as `[west, south, east, north]`; null when unset. */ + extentBbox: [number, number, number, number] | null; + + // Atlas / map series + atlasEnabled: boolean; + atlasLayerId: string; + atlasCoverage: "features" | "line"; + atlasSegmentKm: string; + atlasNameField: string; + atlasExtentMode: "margin" | "scale"; + atlasMarginPct: number; + atlasMaskEnabled: boolean; + atlasScale: string; + atlasSortField: string; + atlasSortDescending: boolean; + atlasFilter: string; + atlasFilenamePattern: string; +} + +const CORNERS = ["top-left", "top-right", "bottom-left", "bottom-right"] as const; +const PAGE_FILTERS = ["all", "contained", "intersecting"] as const; + +/** + * Settings a project starts with, and the shape every partial or hand-edited + * `printLayout` is filled out against. Also the dialog's initial state, so + * "new project" and "a project saved before this feature" compose identically. + */ +export const DEFAULT_PRINT_LAYOUT: PrintLayoutConfig = { + title: "", + subtitle: "", + titlePlacement: "outside", + titleAlign: "center", + + paperSize: "a4", + orientation: "landscape", + customWidth: 1280, + customHeight: 720, + customUnit: "px", + pageMargin: "normal", + showPageBorder: false, + pageBorderColor: "#111827", + pageBorderWidth: 2, + + mapBorderColor: "#9ca3af", + mapBorderWidth: 1, + mapBackground: "#e5e7eb", + + showTitle: true, + showSubtitle: true, + showLegend: true, + showScaleBar: true, + showNorthArrow: true, + navigationGrouped: true, + showFooter: false, + footerText: "", + showDate: true, + dateText: "", + showAttribution: true, + + showColorbar: false, + colorbarRamp: "viridis", + colorbarMin: "0", + colorbarMax: "100", + colorbarLabel: "", + colorbarOrientation: "vertical", + colorbarLength: 34, + colorbarPosition: "top-right", + + showCustomLegend: false, + customLegendTitle: "Legend", + customLegendEntries: [ + { id: "cl-1", label: "Class 1", color: "#2563eb" }, + { id: "cl-2", label: "Class 2", color: "#16a34a" }, + ], + customLegendPosition: "top-left", + + showDataTable: false, + tableLayerId: "", + tableTitle: "", + tableColumns: [], + tableSortField: "", + tableSortDesc: false, + tableMaxRows: 10, + tableFitRows: false, + tablePosition: "bottom-left", + tablePageFilter: "contained", + tableFilterToAtlasFeature: false, + + showDataChart: false, + chartLayerId: "", + chartTitle: "", + chartType: "bar", + chartCategoryField: "", + chartAggregation: "count", + chartValueField: "", + chartPosition: "top-right", + chartPageFilter: "contained", + + showInfoBlock: false, + author: "", + projectNumber: "", + crs: "", + revision: "", + + captureMode: "viewport", + extentBbox: null, + + atlasEnabled: false, + atlasLayerId: "", + atlasCoverage: "features", + atlasSegmentKm: "20", + atlasNameField: "", + atlasExtentMode: "margin", + atlasMarginPct: 10, + atlasMaskEnabled: false, + atlasScale: "50000", + atlasSortField: "", + atlasSortDescending: false, + atlasFilter: "", + atlasFilenamePattern: "{atlas.pagenumber}-{atlas.name}", +}; + +/** A fresh copy of the defaults, safe to hand to a store or a component. */ +export function createDefaultPrintLayout(): PrintLayoutConfig { + return { + ...DEFAULT_PRINT_LAYOUT, + tableColumns: [], + customLegendEntries: DEFAULT_PRINT_LAYOUT.customLegendEntries.map((entry) => ({ ...entry })), + }; +} + +function str(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} + +function bool(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function num(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, value)); +} + +function oneOf(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === "string" && (allowed as readonly string[]).includes(value) + ? (value as T) + : fallback; +} + +function stringList(value: unknown, fallback: string[]): string[] { + if (!Array.isArray(value)) return fallback; + return value.filter((item): item is string => typeof item === "string"); +} + +function legendEntries( + value: unknown, + fallback: PrintLayoutLegendEntry[], +): PrintLayoutLegendEntry[] { + if (!Array.isArray(value)) return fallback.map((entry) => ({ ...entry })); + const entries: PrintLayoutLegendEntry[] = []; + for (const [index, item] of value.entries()) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const entry = item as Partial; + // A hand-edited file can omit the id; synthesize one rather than drop an + // otherwise usable swatch, since ids are internal to the editor list. + entries.push({ + id: typeof entry.id === "string" && entry.id.trim() ? entry.id : `cl-${index + 1}`, + label: str(entry.label, ""), + color: str(entry.color, "#2563eb"), + }); + } + return entries; +} + +function extent(value: unknown): [number, number, number, number] | null { + if (!Array.isArray(value) || value.length !== 4) return null; + if (!value.every((n) => typeof n === "number" && Number.isFinite(n))) return null; + const [west, south, east, north] = value as number[]; + return [west, south, east, north]; +} + +/** + * Coerce an untrusted (possibly hand-edited, possibly partial) `printLayout` + * into a complete {@link PrintLayoutConfig}, filling every missing or + * malformed field from {@link DEFAULT_PRINT_LAYOUT}. + * + * @param value - The raw `printLayout` value read from a project file. + * @returns The normalized config, or null when the project carries none, so + * callers can tell "absent" from "explicitly default". + */ +export function normalizePrintLayoutConfig(value: unknown): PrintLayoutConfig | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const raw = value as Record; + const d = DEFAULT_PRINT_LAYOUT; + return { + title: str(raw.title, d.title), + subtitle: str(raw.subtitle, d.subtitle), + titlePlacement: oneOf(raw.titlePlacement, ["outside", "inside"] as const, d.titlePlacement), + titleAlign: oneOf(raw.titleAlign, ["left", "center", "right"] as const, d.titleAlign), + + paperSize: oneOf( + raw.paperSize, + ["a4", "a3", "letter", "legal", "tabloid", "fullhd", "hd", "uhd4k", "square", "custom"], + d.paperSize, + ), + orientation: oneOf(raw.orientation, ["portrait", "landscape"] as const, d.orientation), + customWidth: num(raw.customWidth, d.customWidth, 1, 100000), + customHeight: num(raw.customHeight, d.customHeight, 1, 100000), + customUnit: oneOf(raw.customUnit, ["mm", "px"] as const, d.customUnit), + pageMargin: oneOf(raw.pageMargin, ["normal", "narrow", "none"] as const, d.pageMargin), + showPageBorder: bool(raw.showPageBorder, d.showPageBorder), + pageBorderColor: str(raw.pageBorderColor, d.pageBorderColor), + pageBorderWidth: num(raw.pageBorderWidth, d.pageBorderWidth, 0, 20), + + mapBorderColor: str(raw.mapBorderColor, d.mapBorderColor), + mapBorderWidth: num(raw.mapBorderWidth, d.mapBorderWidth, 0, 10), + mapBackground: str(raw.mapBackground, d.mapBackground), + + showTitle: bool(raw.showTitle, d.showTitle), + showSubtitle: bool(raw.showSubtitle, d.showSubtitle), + showLegend: bool(raw.showLegend, d.showLegend), + showScaleBar: bool(raw.showScaleBar, d.showScaleBar), + showNorthArrow: bool(raw.showNorthArrow, d.showNorthArrow), + navigationGrouped: bool(raw.navigationGrouped, d.navigationGrouped), + showFooter: bool(raw.showFooter, d.showFooter), + footerText: str(raw.footerText, d.footerText), + showDate: bool(raw.showDate, d.showDate), + dateText: str(raw.dateText, d.dateText), + showAttribution: bool(raw.showAttribution, d.showAttribution), + + showColorbar: bool(raw.showColorbar, d.showColorbar), + colorbarRamp: str(raw.colorbarRamp, d.colorbarRamp), + colorbarMin: str(raw.colorbarMin, d.colorbarMin), + colorbarMax: str(raw.colorbarMax, d.colorbarMax), + colorbarLabel: str(raw.colorbarLabel, d.colorbarLabel), + colorbarOrientation: oneOf( + raw.colorbarOrientation, + ["vertical", "horizontal"] as const, + d.colorbarOrientation, + ), + colorbarLength: num(raw.colorbarLength, d.colorbarLength, 1, 100), + colorbarPosition: oneOf(raw.colorbarPosition, CORNERS, d.colorbarPosition), + + showCustomLegend: bool(raw.showCustomLegend, d.showCustomLegend), + customLegendTitle: str(raw.customLegendTitle, d.customLegendTitle), + customLegendEntries: legendEntries(raw.customLegendEntries, d.customLegendEntries), + customLegendPosition: oneOf(raw.customLegendPosition, CORNERS, d.customLegendPosition), + + showDataTable: bool(raw.showDataTable, d.showDataTable), + tableLayerId: str(raw.tableLayerId, d.tableLayerId), + tableTitle: str(raw.tableTitle, d.tableTitle), + tableColumns: stringList(raw.tableColumns, d.tableColumns), + tableSortField: str(raw.tableSortField, d.tableSortField), + tableSortDesc: bool(raw.tableSortDesc, d.tableSortDesc), + tableMaxRows: num(raw.tableMaxRows, d.tableMaxRows, 1, 10000), + tableFitRows: bool(raw.tableFitRows, d.tableFitRows), + tablePosition: oneOf(raw.tablePosition, CORNERS, d.tablePosition), + tablePageFilter: oneOf(raw.tablePageFilter, PAGE_FILTERS, d.tablePageFilter), + tableFilterToAtlasFeature: bool(raw.tableFilterToAtlasFeature, d.tableFilterToAtlasFeature), + + showDataChart: bool(raw.showDataChart, d.showDataChart), + chartLayerId: str(raw.chartLayerId, d.chartLayerId), + chartTitle: str(raw.chartTitle, d.chartTitle), + chartType: oneOf(raw.chartType, ["bar", "pie", "line"] as const, d.chartType), + chartCategoryField: str(raw.chartCategoryField, d.chartCategoryField), + chartAggregation: oneOf( + raw.chartAggregation, + ["count", "sum", "mean"] as const, + d.chartAggregation, + ), + chartValueField: str(raw.chartValueField, d.chartValueField), + chartPosition: oneOf(raw.chartPosition, CORNERS, d.chartPosition), + chartPageFilter: oneOf(raw.chartPageFilter, PAGE_FILTERS, d.chartPageFilter), + + showInfoBlock: bool(raw.showInfoBlock, d.showInfoBlock), + author: str(raw.author, d.author), + projectNumber: str(raw.projectNumber, d.projectNumber), + crs: str(raw.crs, d.crs), + revision: str(raw.revision, d.revision), + + captureMode: oneOf(raw.captureMode, ["viewport", "extent"] as const, d.captureMode), + extentBbox: extent(raw.extentBbox), + + atlasEnabled: bool(raw.atlasEnabled, d.atlasEnabled), + atlasLayerId: str(raw.atlasLayerId, d.atlasLayerId), + atlasCoverage: oneOf(raw.atlasCoverage, ["features", "line"] as const, d.atlasCoverage), + atlasSegmentKm: str(raw.atlasSegmentKm, d.atlasSegmentKm), + atlasNameField: str(raw.atlasNameField, d.atlasNameField), + atlasExtentMode: oneOf(raw.atlasExtentMode, ["margin", "scale"] as const, d.atlasExtentMode), + atlasMarginPct: num(raw.atlasMarginPct, d.atlasMarginPct, 0, 100), + atlasMaskEnabled: bool(raw.atlasMaskEnabled, d.atlasMaskEnabled), + atlasScale: str(raw.atlasScale, d.atlasScale), + atlasSortField: str(raw.atlasSortField, d.atlasSortField), + atlasSortDescending: bool(raw.atlasSortDescending, d.atlasSortDescending), + atlasFilter: str(raw.atlasFilter, d.atlasFilter), + atlasFilenamePattern: str(raw.atlasFilenamePattern, d.atlasFilenamePattern), + }; +} + +/** + * Whether a config is still the untouched default, so a project that never + * opened the composer serializes without a `printLayout` key and stays + * byte-identical to one saved before this feature. + */ +export function isDefaultPrintLayout(config: PrintLayoutConfig): boolean { + return printLayoutConfigsEqual(config, DEFAULT_PRINT_LAYOUT); +} + +/** + * Structural equality for two configs. Used to skip no-op store writes, so + * merely opening the Print Layout dialog never marks a project dirty. + */ +export function printLayoutConfigsEqual(a: PrintLayoutConfig, b: PrintLayoutConfig): boolean { + if (a === b) return true; + for (const key of Object.keys(DEFAULT_PRINT_LAYOUT) as (keyof PrintLayoutConfig)[]) { + const left = a[key]; + const right = b[key]; + if (Array.isArray(left) || Array.isArray(right)) { + if (JSON.stringify(left) !== JSON.stringify(right)) return false; + } else if (left !== right) { + return false; + } + } + return true; +} + +/** + * Drop references to layers the project no longer carries, so a composer that + * pointed at a since-deleted layer opens with the block cleared instead of + * rendering nothing from a dangling id. Mirrors the widget/comment/legend + * scrubbing in `applyProjectToStore`. + * + * @param config - The config to scrub. + * @param existingLayerIds - Ids of the layers present in the project. + * @returns The same object when nothing referenced a missing layer. + */ +export function scrubPrintLayoutForLayers( + config: PrintLayoutConfig, + existingLayerIds: ReadonlySet, +): PrintLayoutConfig { + const missing = (id: string) => id !== "" && !existingLayerIds.has(id); + if ( + !missing(config.tableLayerId) && + !missing(config.chartLayerId) && + !missing(config.atlasLayerId) + ) { + return config; + } + return { + ...config, + ...(missing(config.tableLayerId) ? { tableLayerId: "", showDataTable: false } : {}), + ...(missing(config.chartLayerId) ? { chartLayerId: "", showDataChart: false } : {}), + ...(missing(config.atlasLayerId) ? { atlasLayerId: "", atlasEnabled: false } : {}), + }; +} diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index a99c03aad7..5a00b0a350 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -51,6 +51,13 @@ import { } from "./types"; import { DEFAULT_LAYER_GROUP_OPACITY, normalizeGroupContiguity } from "./layer-groups"; import { normalizeStyleLibraryEntries } from "./style-library"; +import { + createDefaultPrintLayout, + isDefaultPrintLayout, + normalizePrintLayoutConfig, + scrubPrintLayoutForLayers, + type PrintLayoutConfig, +} from "./print-layout-config"; import { getEllipsoid } from "./ellipsoids"; import { scrubWidgetsForRemovedLayers, @@ -280,6 +287,7 @@ export function parseProject(json: string): GeoLibreProject { preferences: normalizeProjectPreferences(data.preferences), plugins: normalizeProjectPlugins(data.plugins) ?? undefined, legend: normalizeLegendConfig(data.legend), + printLayout: normalizePrintLayoutConfig(data.printLayout) ?? undefined, storymap: normalizeStoryMap(data.storymap) ?? undefined, models: normalizeModels(data.models) ?? undefined, processingHistory: normalizeProcessingHistory(data.processingHistory) ?? undefined, @@ -1428,6 +1436,7 @@ export function projectFromStore(state: { preferences: ProjectPreferences; plugins?: ProjectPluginState | null; legend?: LegendConfig | null; + printLayout?: PrintLayoutConfig | null; storymap?: StoryMap | null; models?: ProcessingModel[] | null; processingHistory?: ProcessingRun[] | null; @@ -1447,6 +1456,9 @@ export function projectFromStore(state: { } const plugins = normalizeProjectPlugins(state.plugins); const legend = normalizeLegendConfig(state.legend); + // Persist the composer only once it differs from the defaults, so a project + // that never opened Print Layout keeps its previous byte-for-byte shape. + const printLayout = normalizePrintLayoutConfig(state.printLayout); const storymap = normalizeStoryMap(state.storymap); const models = normalizeModels(state.models); const processingHistory = normalizeProcessingHistory(state.processingHistory); @@ -1493,6 +1505,7 @@ export function projectFromStore(state: { preferences: state.preferences, ...(plugins ? { plugins } : {}), ...(legend ? { legend } : {}), + ...(printLayout && !isDefaultPrintLayout(printLayout) ? { printLayout } : {}), ...(storymap ? { storymap } : {}), ...(models ? { models } : {}), ...(processingHistory ? { processingHistory } : {}), @@ -1611,6 +1624,7 @@ export function applyProjectToStore(project: GeoLibreProject): { preferences: ProjectPreferences; projectPlugins: ProjectPluginState | null; legend: LegendConfig; + printLayout: PrintLayoutConfig; storymap: StoryMap | null; models: ProcessingModel[]; processingHistory: ProcessingRun[]; @@ -1689,6 +1703,12 @@ export function applyProjectToStore(project: GeoLibreProject): { orphanIds.size > 0 ? scrubCommentsForRemovedLayers(comments, orphanIds) : comments; const scrubbedLegend = orphanIds.size > 0 ? scrubLegendForRemovedLayers(legend, orphanIds) : legend; + // The composer's data/atlas blocks name a layer directly rather than through + // `allReferencedIds`, so they are scrubbed against the surviving layer set. + const printLayout = scrubPrintLayoutForLayers( + normalizePrintLayoutConfig(project.printLayout) ?? createDefaultPrintLayout(), + existingLayerIds, + ); return { projectName: project.name, @@ -1701,6 +1721,7 @@ export function applyProjectToStore(project: GeoLibreProject): { preferences: normalizeProjectPreferences(project.preferences), projectPlugins: normalizeProjectPlugins(project.plugins), legend: scrubbedLegend, + printLayout, storymap: normalizeStoryMap(project.storymap), models: normalizeModels(project.models) ?? [], processingHistory: normalizeProcessingHistory(project.processingHistory) ?? [], diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index bdd867cc19..731056a027 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -17,6 +17,11 @@ import { DEFAULT_PROJECT_NAME, } from "./project"; import { initialLayerStyle } from "./layer-defaults"; +import { + createDefaultPrintLayout, + printLayoutConfigsEqual, + type PrintLayoutConfig, +} from "./print-layout-config"; import { DEFAULT_LAYER_GROUP_OPACITY, normalizeGroupContiguity, @@ -208,6 +213,8 @@ export interface AppState { preferences: ProjectPreferences; projectPlugins: ProjectPluginState | null; legend: LegendConfig; + /** Print Layout composer settings for the open project (discussion #1992). */ + printLayout: PrintLayoutConfig; storymap: StoryMap | null; /** Saved processing pipelines (batch/model chaining; issue #344). */ models: ProcessingModel[]; @@ -413,6 +420,12 @@ export interface AppState { setBasemapOpacity: (opacity: number) => void; setPreferences: (preferences: ProjectPreferences) => void; setLegend: (legend: LegendConfig) => void; + /** + * Replace the Print Layout composer settings. A config equal to the current + * one is ignored, so re-opening the composer (or a project load seeding the + * dialog) never marks the project dirty. + */ + setPrintLayout: (printLayout: PrintLayoutConfig) => void; setProjectPlugins: (projectPlugins: ProjectPluginState | null, shouldMarkDirty?: boolean) => void; selectLayer: (id: string | null) => void; selectFeature: (id: string | null) => void; @@ -992,6 +1005,7 @@ export const useAppStore = create()( preferences: DEFAULT_PROJECT_PREFERENCES, projectPlugins: null, legend: { ...DEFAULT_LEGEND_CONFIG }, + printLayout: createDefaultPrintLayout(), storymap: null, models: [], styleLibrary: [], @@ -1283,6 +1297,11 @@ export const useAppStore = create()( setBasemapOpacity: (opacity) => set({ basemapOpacity: opacity, isDirty: true }), setPreferences: (preferences) => set({ preferences, isDirty: true }), setLegend: (legend) => set({ legend, isDirty: true }), + + setPrintLayout: (printLayout) => + set((s) => + printLayoutConfigsEqual(s.printLayout, printLayout) ? s : { printLayout, isDirty: true }, + ), // When shouldMarkDirty is false the existing dirty flag is preserved rather // than set; it cannot clear the flag (only markSaved() does that). setProjectPlugins: (projectPlugins, shouldMarkDirty = true) => diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 90941e3c5b..b490bcb75e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,5 @@ import type { FeatureCollection } from "geojson"; +import type { PrintLayoutConfig } from "./print-layout-config"; export const OPENFREEMAP_BASEMAPS = [ { @@ -1861,6 +1862,12 @@ export interface GeoLibreProject { plugins?: ProjectPluginState; /** User customizations for the Print Layout legend. */ legend?: LegendConfig; + /** + * Print Layout composer settings (title, page size, orientation, blocks, + * atlas). Omitted while the composer is untouched, so projects that never + * opened it are unaffected (GeoLibre discussion #1992). + */ + printLayout?: PrintLayoutConfig; storymap?: StoryMap; /** Saved processing pipelines (batch/model chaining; issue #344). */ models?: ProcessingModel[]; diff --git a/tests/core-project.test.ts b/tests/core-project.test.ts index 29fa6b8830..e698f9a074 100644 --- a/tests/core-project.test.ts +++ b/tests/core-project.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_BASEMAP, DEFAULT_LAYER_STYLE, DEFAULT_STORY_MAP, + createDefaultPrintLayout, createEmptyProject, createSampleStoryMap, parseProject, @@ -1472,3 +1473,96 @@ describe("primary mapView normalization", () => { assert.equal(applied.mapView.bearing, 270); }); }); + +describe("print layout persistence", () => { + beforeEach(() => { + useAppStore.getState().newProject({ name: "Layout Project" }); + }); + + it("omits an untouched composer so the saved file is unchanged by this feature", () => { + const project = projectFromStore({ + ...useAppStore.getState(), + metadata: {}, + }); + assert.equal(project.printLayout, undefined); + }); + + it("saves the composer settings once they differ from the defaults", () => { + useAppStore.getState().setPrintLayout({ + ...createDefaultPrintLayout(), + title: "Filière dentaire par régions", + paperSize: "a3", + orientation: "portrait", + }); + const saved = parseProject( + serializeProject(projectFromStore({ ...useAppStore.getState(), metadata: {} })), + ); + assert.equal(saved.printLayout?.title, "Filière dentaire par régions"); + assert.equal(saved.printLayout?.paperSize, "a3"); + assert.equal(saved.printLayout?.orientation, "portrait"); + }); + + it("restores the saved composer settings when the project is loaded", () => { + const project = { + ...createEmptyProject("Saved layout"), + printLayout: { + ...createDefaultPrintLayout(), + title: "Saved title", + orientation: "portrait" as const, + showNorthArrow: false, + }, + }; + useAppStore.getState().loadProject(project); + const restored = useAppStore.getState().printLayout; + assert.equal(restored.title, "Saved title"); + assert.equal(restored.orientation, "portrait"); + assert.equal(restored.showNorthArrow, false); + }); + + it("resets to the defaults for a project saved without a layout", () => { + useAppStore.getState().setPrintLayout({ + ...createDefaultPrintLayout(), + title: "Previous project", + paperSize: "a3", + }); + // The bug behind discussion #1992: opening another project must not leave + // the previous project's composer settings in place. + useAppStore.getState().loadProject(createEmptyProject("Next")); + assert.deepEqual(useAppStore.getState().printLayout, createDefaultPrintLayout()); + + useAppStore.getState().setPrintLayout({ + ...createDefaultPrintLayout(), + title: "Previous project", + }); + useAppStore.getState().newProject({ name: "Fresh" }); + assert.deepEqual(useAppStore.getState().printLayout, createDefaultPrintLayout()); + }); + + it("clears composer blocks that name a layer the loaded project does not carry", () => { + const layer = geojsonLayer({ id: "kept" }); + const applied = applyProjectToStore({ + ...createEmptyProject("Orphans"), + layers: [layer], + printLayout: { + ...createDefaultPrintLayout(), + showDataTable: true, + tableLayerId: "deleted", + showDataChart: true, + chartLayerId: "kept", + }, + }); + assert.equal(applied.printLayout.tableLayerId, ""); + assert.equal(applied.printLayout.showDataTable, false); + assert.equal(applied.printLayout.chartLayerId, "kept"); + }); + + it("ignores a write that changes nothing, so opening the composer is not an edit", () => { + assert.equal(useAppStore.getState().isDirty, false); + // The dialog replays its seeded values into the store on mount. + useAppStore.getState().setPrintLayout(createDefaultPrintLayout()); + assert.equal(useAppStore.getState().isDirty, false); + + useAppStore.getState().setPrintLayout({ ...createDefaultPrintLayout(), title: "Edited" }); + assert.equal(useAppStore.getState().isDirty, true); + }); +}); diff --git a/tests/print-layout-config.test.ts b/tests/print-layout-config.test.ts new file mode 100644 index 0000000000..e3126ef7b8 --- /dev/null +++ b/tests/print-layout-config.test.ts @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + DEFAULT_PRINT_LAYOUT, + createDefaultPrintLayout, + isDefaultPrintLayout, + normalizePrintLayoutConfig, + printLayoutConfigsEqual, + scrubPrintLayoutForLayers, + type PrintLayoutConfig, +} from "../packages/core/src/print-layout-config"; + +// The Print Layout composer's settings describe the project's map document, so +// they round-trip through `.geolibre.json`. Before GeoLibre discussion #1992 +// they lived only in the dialog's component state: reopening a project lost the +// title, page format and orientation, and the composer kept showing the +// settings of whichever project had been open before. + +const withOverrides = (overrides: Partial): PrintLayoutConfig => ({ + ...createDefaultPrintLayout(), + ...overrides, +}); + +describe("normalizePrintLayoutConfig", () => { + it("reports no config for a project that carries none", () => { + assert.equal(normalizePrintLayoutConfig(undefined), null); + assert.equal(normalizePrintLayoutConfig(null), null); + assert.equal(normalizePrintLayoutConfig("a4"), null); + // An array is an object but never a config. + assert.equal(normalizePrintLayoutConfig([]), null); + }); + + it("fills a partial config out from the defaults", () => { + const config = normalizePrintLayoutConfig({ title: "Dentists by region", paperSize: "a3" }); + assert.ok(config); + assert.equal(config.title, "Dentists by region"); + assert.equal(config.paperSize, "a3"); + // Untouched fields keep their defaults rather than arriving undefined. + assert.equal(config.orientation, DEFAULT_PRINT_LAYOUT.orientation); + assert.equal(config.showNorthArrow, DEFAULT_PRINT_LAYOUT.showNorthArrow); + assert.deepEqual(config.tableColumns, []); + }); + + it("round-trips every field of a fully populated config", () => { + const saved = withOverrides({ + title: "Filière dentaire", + subtitle: "2026", + titlePlacement: "inside", + titleAlign: "left", + paperSize: "custom", + orientation: "portrait", + customWidth: 900, + customHeight: 1600, + customUnit: "mm", + showLegend: false, + showColorbar: true, + colorbarRamp: "magma", + colorbarPosition: "bottom-left", + showCustomLegend: true, + customLegendEntries: [{ id: "cl-7", label: "max : 9133", color: "#f97316" }], + showDataTable: true, + tableLayerId: "layer-a", + tableColumns: ["name", "count"], + tableMaxRows: 25, + showDataChart: true, + chartLayerId: "layer-b", + chartType: "pie", + captureMode: "extent", + extentBbox: [-5, 41, 9, 52], + atlasEnabled: true, + atlasLayerId: "layer-c", + atlasMarginPct: 15, + }); + assert.deepEqual(normalizePrintLayoutConfig(saved), saved); + }); + + it("falls back to the default for an unknown enum value", () => { + const config = normalizePrintLayoutConfig({ + paperSize: "a0", + orientation: "sideways", + tablePosition: "middle", + chartType: "sunburst", + }); + assert.ok(config); + assert.equal(config.paperSize, DEFAULT_PRINT_LAYOUT.paperSize); + assert.equal(config.orientation, DEFAULT_PRINT_LAYOUT.orientation); + assert.equal(config.tablePosition, DEFAULT_PRINT_LAYOUT.tablePosition); + assert.equal(config.chartType, DEFAULT_PRINT_LAYOUT.chartType); + }); + + it("clamps out-of-range numbers and rejects non-finite ones", () => { + const config = normalizePrintLayoutConfig({ + colorbarLength: 400, + atlasMarginPct: -8, + mapBorderWidth: Number.NaN, + tableMaxRows: 0, + }); + assert.ok(config); + assert.equal(config.colorbarLength, 100); + assert.equal(config.atlasMarginPct, 0); + assert.equal(config.mapBorderWidth, DEFAULT_PRINT_LAYOUT.mapBorderWidth); + assert.equal(config.tableMaxRows, 1); + }); + + it("keeps only well-formed custom legend entries and supplies missing ids", () => { + const config = normalizePrintLayoutConfig({ + customLegendEntries: [ + { id: "cl-4", label: "Class 4", color: "#123456" }, + { label: "No id", color: "#654321" }, + "not an entry", + null, + ], + }); + assert.ok(config); + assert.deepEqual(config.customLegendEntries, [ + { id: "cl-4", label: "Class 4", color: "#123456" }, + { id: "cl-2", label: "No id", color: "#654321" }, + ]); + }); + + it("drops a malformed print extent rather than drawing from it", () => { + assert.equal(normalizePrintLayoutConfig({ extentBbox: [1, 2, 3] })?.extentBbox, null); + assert.equal(normalizePrintLayoutConfig({ extentBbox: [1, 2, "3", 4] })?.extentBbox, null); + assert.deepEqual( + normalizePrintLayoutConfig({ extentBbox: [1, 2, 3, 4] })?.extentBbox, + [1, 2, 3, 4], + ); + }); + + it("filters non-string table columns", () => { + const config = normalizePrintLayoutConfig({ tableColumns: ["name", 7, null, "count"] }); + assert.deepEqual(config?.tableColumns, ["name", "count"]); + }); +}); + +describe("createDefaultPrintLayout", () => { + it("hands out copies, so one project's edits cannot leak into another", () => { + const first = createDefaultPrintLayout(); + const second = createDefaultPrintLayout(); + first.customLegendEntries[0].label = "Edited"; + first.tableColumns.push("name"); + assert.equal(second.customLegendEntries[0].label, "Class 1"); + assert.deepEqual(second.tableColumns, []); + assert.equal(DEFAULT_PRINT_LAYOUT.customLegendEntries[0].label, "Class 1"); + }); +}); + +describe("isDefaultPrintLayout", () => { + it("recognizes an untouched composer, so the project file stays free of the key", () => { + assert.equal(isDefaultPrintLayout(createDefaultPrintLayout()), true); + }); + + it("recognizes any edit, including one inside an array field", () => { + assert.equal(isDefaultPrintLayout(withOverrides({ orientation: "portrait" })), false); + assert.equal(isDefaultPrintLayout(withOverrides({ tableColumns: ["name"] })), false); + assert.equal( + isDefaultPrintLayout( + withOverrides({ customLegendEntries: [{ id: "cl-1", label: "A", color: "#000000" }] }), + ), + false, + ); + }); +}); + +describe("printLayoutConfigsEqual", () => { + it("compares by value so a rebuilt but unchanged config is not a store write", () => { + assert.equal( + printLayoutConfigsEqual(createDefaultPrintLayout(), createDefaultPrintLayout()), + true, + ); + assert.equal( + printLayoutConfigsEqual( + withOverrides({ title: "Map", extentBbox: [1, 2, 3, 4] }), + withOverrides({ title: "Map", extentBbox: [1, 2, 3, 4] }), + ), + true, + ); + }); + + it("sees a difference in any field", () => { + assert.equal( + printLayoutConfigsEqual(createDefaultPrintLayout(), withOverrides({ title: "Map" })), + false, + ); + assert.equal( + printLayoutConfigsEqual( + withOverrides({ extentBbox: [1, 2, 3, 4] }), + withOverrides({ extentBbox: [1, 2, 3, 5] }), + ), + false, + ); + }); +}); + +describe("scrubPrintLayoutForLayers", () => { + it("leaves a config whose blocks all name surviving layers untouched", () => { + const config = withOverrides({ + showDataTable: true, + tableLayerId: "keep", + showDataChart: true, + chartLayerId: "keep", + }); + assert.equal(scrubPrintLayoutForLayers(config, new Set(["keep"])), config); + }); + + it("clears a block that points at a layer the project no longer carries", () => { + const config = withOverrides({ + showDataTable: true, + tableLayerId: "gone", + showDataChart: true, + chartLayerId: "keep", + atlasEnabled: true, + atlasLayerId: "gone", + }); + const scrubbed = scrubPrintLayoutForLayers(config, new Set(["keep"])); + assert.equal(scrubbed.tableLayerId, ""); + assert.equal(scrubbed.showDataTable, false); + assert.equal(scrubbed.atlasLayerId, ""); + assert.equal(scrubbed.atlasEnabled, false); + // The block that still resolves keeps both its layer and its visibility. + assert.equal(scrubbed.chartLayerId, "keep"); + assert.equal(scrubbed.showDataChart, true); + }); + + it("treats an unset block as nothing to scrub", () => { + const config = createDefaultPrintLayout(); + assert.equal(scrubPrintLayoutForLayers(config, new Set()), config); + }); +}); From 150a8e0734b9cbf38096bde36d48e925cf4d5ce9 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 17:58:05 -0400 Subject: [PATCH 2/3] Address review feedback - Keep a synthesized custom-legend id clear of every id the file claims, not just of the ones already accepted. An entry missing an id followed by one explicitly using the id that would be synthesized produced two entries sharing it, and the composer keys its swatch rows by id, so editing one row silently edited the other. - Drop an inverted or zero-area print extent. The draw tool orders its corners, so such a box only arrives from a hand-edited file, where capturing it would produce an empty image. Mirrors normalizeBounds in project.ts. - Floor the page border width at 1, the editor's own minimum: the border is drawn only when showPageBorder is on, so a stored 0 was an invisible "visible" border. --- packages/core/src/print-layout-config.ts | 39 ++++++++++++++++++++--- tests/print-layout-config.test.ts | 40 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/core/src/print-layout-config.ts b/packages/core/src/print-layout-config.ts index cc0b4fcf7a..18eae1d131 100644 --- a/packages/core/src/print-layout-config.ts +++ b/packages/core/src/print-layout-config.ts @@ -292,13 +292,27 @@ function legendEntries( ): PrintLayoutLegendEntry[] { if (!Array.isArray(value)) return fallback.map((entry) => ({ ...entry })); const entries: PrintLayoutLegendEntry[] = []; + // The editor keys its swatch rows by id, so two entries sharing one would + // make an edit to either apply to both. A hand-edited file can omit an id or + // repeat one, so ids are made unique here rather than trusted. + const used = new Set(); for (const [index, item] of value.entries()) { if (!item || typeof item !== "object" || Array.isArray(item)) continue; const entry = item as Partial; - // A hand-edited file can omit the id; synthesize one rather than drop an - // otherwise usable swatch, since ids are internal to the editor list. + const given = typeof entry.id === "string" ? entry.id.trim() : ""; + let id = given; + if (!id || used.has(id)) { + // Synthesize past whatever is already taken, including ids claimed later + // in the array, so the fallback cannot collide with an explicit one. + let candidate = index + 1; + while (used.has(`cl-${candidate}`) || claimsId(value, index, `cl-${candidate}`)) { + candidate += 1; + } + id = `cl-${candidate}`; + } + used.add(id); entries.push({ - id: typeof entry.id === "string" && entry.id.trim() ? entry.id : `cl-${index + 1}`, + id, label: str(entry.label, ""), color: str(entry.color, "#2563eb"), }); @@ -306,10 +320,25 @@ function legendEntries( return entries; } +/** Whether any entry after `index` explicitly carries `id`. */ +function claimsId(value: unknown[], index: number, id: string): boolean { + for (let i = index + 1; i < value.length; i += 1) { + const item = value[i]; + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const candidate = (item as Partial).id; + if (typeof candidate === "string" && candidate.trim() === id) return true; + } + return false; +} + function extent(value: unknown): [number, number, number, number] | null { if (!Array.isArray(value) || value.length !== 4) return null; if (!value.every((n) => typeof n === "number" && Number.isFinite(n))) return null; const [west, south, east, north] = value as number[]; + // The draw tool orders its corners, so an inverted or zero-area box only + // reaches here from a hand-edited file; capturing it would produce an empty + // image, so treat it as unset (mirrors `normalizeBounds` in project.ts). + if (west >= east || south >= north) return null; return [west, south, east, north]; } @@ -344,7 +373,9 @@ export function normalizePrintLayoutConfig(value: unknown): PrintLayoutConfig | pageMargin: oneOf(raw.pageMargin, ["normal", "narrow", "none"] as const, d.pageMargin), showPageBorder: bool(raw.showPageBorder, d.showPageBorder), pageBorderColor: str(raw.pageBorderColor, d.pageBorderColor), - pageBorderWidth: num(raw.pageBorderWidth, d.pageBorderWidth, 0, 20), + // A page border is drawn only when `showPageBorder` is on, so a zero width + // would be an invisible "visible" border; the editor's own floor is 1. + pageBorderWidth: num(raw.pageBorderWidth, d.pageBorderWidth, 1, 20), mapBorderColor: str(raw.mapBorderColor, d.mapBorderColor), mapBorderWidth: num(raw.mapBorderWidth, d.mapBorderWidth, 0, 10), diff --git a/tests/print-layout-config.test.ts b/tests/print-layout-config.test.ts index e3126ef7b8..3da22335e1 100644 --- a/tests/print-layout-config.test.ts +++ b/tests/print-layout-config.test.ts @@ -118,6 +118,34 @@ describe("normalizePrintLayoutConfig", () => { ]); }); + it("keeps synthesized legend ids clear of ids claimed elsewhere in the array", () => { + // The editor keys swatch rows by id, so a duplicate would make an edit to + // one row apply to the other. The entry missing an id would otherwise be + // synthesized as "cl-1", which the second entry already claims. + const config = normalizePrintLayoutConfig({ + customLegendEntries: [ + { label: "First", color: "#111111" }, + { id: "cl-1", label: "Second", color: "#222222" }, + ], + }); + assert.deepEqual( + config?.customLegendEntries.map((entry) => entry.id), + ["cl-2", "cl-1"], + ); + + // A file that simply repeats an id gets the later one renamed. + const repeated = normalizePrintLayoutConfig({ + customLegendEntries: [ + { id: "cl-3", label: "A", color: "#111111" }, + { id: "cl-3", label: "B", color: "#222222" }, + ], + }); + assert.deepEqual( + repeated?.customLegendEntries.map((entry) => entry.id), + ["cl-3", "cl-2"], + ); + }); + it("drops a malformed print extent rather than drawing from it", () => { assert.equal(normalizePrintLayoutConfig({ extentBbox: [1, 2, 3] })?.extentBbox, null); assert.equal(normalizePrintLayoutConfig({ extentBbox: [1, 2, "3", 4] })?.extentBbox, null); @@ -127,6 +155,18 @@ describe("normalizePrintLayoutConfig", () => { ); }); + it("drops an inverted or zero-area extent, which would capture nothing", () => { + assert.equal(normalizePrintLayoutConfig({ extentBbox: [9, 2, 1, 4] })?.extentBbox, null); + assert.equal(normalizePrintLayoutConfig({ extentBbox: [1, 9, 3, 4] })?.extentBbox, null); + assert.equal(normalizePrintLayoutConfig({ extentBbox: [1, 2, 1, 4] })?.extentBbox, null); + assert.equal(normalizePrintLayoutConfig({ extentBbox: [1, 2, 3, 2] })?.extentBbox, null); + }); + + it("keeps a page border at a width that actually draws", () => { + assert.equal(normalizePrintLayoutConfig({ pageBorderWidth: 0 })?.pageBorderWidth, 1); + assert.equal(normalizePrintLayoutConfig({ pageBorderWidth: 4 })?.pageBorderWidth, 4); + }); + it("filters non-string table columns", () => { const config = normalizePrintLayoutConfig({ tableColumns: ["name", 7, null, "count"] }); assert.deepEqual(config?.tableColumns, ["name", "count"]); From 745b2627049dc1325ad36ae9a0a32456b271cfc0 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 18:14:10 -0400 Subject: [PATCH 3/3] Address review feedback Gate the atlas / data-table / chart "default the layer" effects on `open`, matching the auto-drive effect below them and their own stated intent. The dialog stays mounted while closed, so these ran in the background. That was inert when the composer's settings were component state, but now that they are project state, deleting a layer from the Layers panel would silently reassign the block's layer, push it into the store and mark the project dirty, with the composer never having been opened. Reopening it re-runs the defaulting, which is where the reassignment was always observable. --- .../components/layout/PrintLayoutDialog.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx b/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx index 72e60593d1..5a26e57221 100644 --- a/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx @@ -1603,35 +1603,42 @@ export function PrintLayoutDialog({ // switched on without one selected, or when the selected layer disappears // (e.g. removed from the Layers panel while the dialog is open) — a stale // id would leave the Select valueless and the series silently empty. + // + // Gated on `open` like the auto-drive effect below: the dialog stays mounted + // when closed, and since the composer's settings are now project state, an + // ungated reassignment would rewrite (and dirty) the saved layout in the + // background when a layer is deleted from the Layers panel, with the + // composer never opened. Reopening it re-runs this and defaults then. useEffect(() => { - if (!atlasEnabled || atlasLayers.length === 0) return; + if (!open || !atlasEnabled || atlasLayers.length === 0) return; if (!atlasLayers.some((l) => l.id === atlasLayerId)) { setAtlasLayerId(atlasLayers[0].id); setAtlasNameField(""); setAtlasSortField(""); setAtlasIndex(0); } - }, [atlasEnabled, atlasLayerId, atlasLayers]); + }, [open, atlasEnabled, atlasLayerId, atlasLayers]); - // Same defaulting for the data blocks' layers (GH #1324): fill in the first - // eligible layer when a block is enabled without one, or when its selected - // layer disappears; the field choices belong to the old layer, so drop them. + // Same defaulting (and the same `open` gate) for the data blocks' layers + // (GH #1324): fill in the first eligible layer when a block is enabled + // without one, or when its selected layer disappears; the field choices + // belong to the old layer, so drop them. useEffect(() => { - if (!showDataTable || atlasLayers.length === 0) return; + if (!open || !showDataTable || atlasLayers.length === 0) return; if (!atlasLayers.some((l) => l.id === tableLayerId)) { setTableLayerId(atlasLayers[0].id); setTableColumns([]); setTableSortField(""); } - }, [showDataTable, tableLayerId, atlasLayers]); + }, [open, showDataTable, tableLayerId, atlasLayers]); useEffect(() => { - if (!showDataChart || atlasLayers.length === 0) return; + if (!open || !showDataChart || atlasLayers.length === 0) return; if (!atlasLayers.some((l) => l.id === chartLayerId)) { setChartLayerId(atlasLayers[0].id); setChartCategoryField(""); setChartValueField(""); } - }, [showDataChart, chartLayerId, atlasLayers]); + }, [open, showDataChart, chartLayerId, atlasLayers]); // Latest page index for the auto-drive effect below, so stepping (which // sets the index) does not itself re-trigger a capture.