diff --git a/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx b/apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx index 92272bd181..5a26e57221 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, @@ -1390,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. @@ -1858,7 +2078,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..18eae1d131 --- /dev/null +++ b/packages/core/src/print-layout-config.ts @@ -0,0 +1,520 @@ +/** + * 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[] = []; + // 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; + 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, + label: str(entry.label, ""), + color: str(entry.color, "#2563eb"), + }); + } + 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]; +} + +/** + * 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), + // 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), + 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..3da22335e1 --- /dev/null +++ b/tests/print-layout-config.test.ts @@ -0,0 +1,269 @@ +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("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); + assert.deepEqual( + normalizePrintLayoutConfig({ extentBbox: [1, 2, 3, 4] })?.extentBbox, + [1, 2, 3, 4], + ); + }); + + 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"]); + }); +}); + +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); + }); +});