Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 144 additions & 23 deletions apps/geolibre-desktop/src/components/layout/PrintLayoutDialog.tsx

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,7 +1323,9 @@
"titleLabel": "Heading",
"position": "Position",
"filterToPage": "Only features in the page extent",
"filterToPageHint": "Applies to atlas pages and a drawn print extent; otherwise the whole layer is used."
"filterToPageHint": "Applies to atlas pages and a drawn print extent; otherwise the whole layer is used.",
"filterToAtlasFeature": "Only the current atlas feature",
"filterToAtlasFeatureHint": "Available when the table uses the atlas coverage layer. This takes priority over the page extent filter."
},
"dataTable": {
"columns": "Columns",
Expand Down Expand Up @@ -1453,6 +1455,8 @@
"extentMargin": "Margin around feature",
"extentScale": "Fixed scale",
"marginLabel": "Margin (%)",
"maskOutside": "Mask area outside current feature",
"maskOutsideHint": "Applies a translucent inverted fill to emphasize the current atlas feature.",
"scaleLabel": "Scale",
"scaleRequired": "Enter a scale greater than zero.",
"sortField": "Sort by",
Expand Down
6 changes: 5 additions & 1 deletion apps/geolibre-desktop/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1315,7 +1315,9 @@
"titleLabel": "En-tête",
"position": "Position",
"filterToPage": "Seulement les entités dans l'emprise de la page",
"filterToPageHint": "S'applique aux pages d'atlas et à une emprise d'impression dessinée ; sinon, la couche entière est utilisée."
"filterToPageHint": "S'applique aux pages d'atlas et à une emprise d'impression dessinée ; sinon, la couche entière est utilisée.",
"filterToAtlasFeature": "Seulement l’entité courante de l’atlas",
"filterToAtlasFeatureHint": "Disponible lorsque la table utilise la couche de couverture de l’atlas. Ce filtre est prioritaire sur l’emprise de la page."
Comment on lines +1319 to +1320

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor style nit: these two new strings use a curly apostrophe () in "l’entité"/"l’atlas", while the rest of this file consistently uses a straight apostrophe ('), e.g. filterToPageHint right above uses "S'applique", "dataChart.noNumericFields" uses "n'a aucun", etc. Same for atlas.maskOutside/maskOutsideHint below. Worth normalizing for consistency with the rest of the catalog.

Confidence: low — purely cosmetic, doesn't affect functionality.

},
"dataTable": {
"columns": "Colonnes",
Expand Down Expand Up @@ -1445,6 +1447,8 @@
"extentMargin": "Marge autour de l'entité",
"extentScale": "Échelle fixe",
"marginLabel": "Marge (%)",
"maskOutside": "Masquer la zone hors de l’entité courante",
"maskOutsideHint": "Applique un remplissage inversé translucide pour mettre en évidence l’entité courante de l’atlas.",
"scaleLabel": "Échelle",
"scaleRequired": "Entrez une échelle supérieure à zéro.",
"sortField": "Trier par",
Expand Down
53 changes: 53 additions & 0 deletions apps/geolibre-desktop/src/lib/print-atlas-mask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** Temporary inverted-fill mask for the active Print Layout atlas feature. */
import { buildInvertedMask } from "@geolibre/map";
import type { Feature, FeatureCollection, MultiPolygon, Polygon } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap } from "maplibre-gl";

const SOURCE_ID = "geolibre-print-atlas-mask";
const FILL_LAYER_ID = "geolibre-print-atlas-mask-fill";

/** Remove the atlas mask source and layer, if present. */
export function clearAtlasFeatureMask(map: MapLibreMap): void {
if (map.getLayer(FILL_LAYER_ID)) map.removeLayer(FILL_LAYER_ID);
if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);
}

/**
* Show a translucent inverted fill around one polygon atlas feature.
*
* @param map - MapLibre map used by the Print Layout capture.
* @param feature - Current coverage feature.
* @returns Whether a polygon mask could be rendered.
*/
export function showAtlasFeatureMask(map: MapLibreMap, feature: Feature | undefined): boolean {
if (feature?.geometry?.type !== "Polygon" && feature?.geometry?.type !== "MultiPolygon") {
clearAtlasFeatureMask(map);
return false;
}
const collection: FeatureCollection<Polygon | MultiPolygon> = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor efficiency nit: buildInvertedMask (in @geolibre/map) memoizes on the identity of the collection object it's given, but showAtlasFeatureMask constructs a brand-new FeatureCollection literal on every call. That means the memoization never hits and turf.mask() re-runs on every invocation — once per debounced page-drive and once per page during a full atlas export/print. For a dense polygon coverage layer this adds avoidable recompute on every export page. Not a correctness issue, just worth knowing the cache is effectively bypassed here.

Confidence: low — likely a negligible cost for typical polygon complexity, but could matter for large/complex coverage geometries exported across many pages.

type: "FeatureCollection",
features: [feature as Feature<Polygon | MultiPolygon>],
};
const mask = buildInvertedMask(collection);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor performance nit (low confidence). buildInvertedMask memoizes on the FeatureCollection object identity via a WeakMap (packages/map/src/derived-geometry.ts), but a brand-new collection object is constructed here on every call, so the cache never hits and @turf/mask's polygon-clipping union is recomputed from scratch each time — including every time captureAtlasPage re-applies the mask for the same page (e.g. after the fixed-scale zoom correction triggers a second settle+capture). Since it's one polygon per call this is likely cheap in practice, but for a large "features" atlas with many pages and the mask enabled it adds needless repeated work. Not blocking, just flagging since the memoization exists specifically for this kind of repeated-call scenario.

if (!mask) {
clearAtlasFeatureMask(map);
return false;
}
const source = map.getSource(SOURCE_ID) as GeoJSONSource | undefined;
if (source) source.setData(mask);
else map.addSource(SOURCE_ID, { type: "geojson", data: mask });
if (!map.getLayer(FILL_LAYER_ID)) {
map.addLayer({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addLayer here has no beforeId, so the mask fill always lands at the very top of the style, above the graticule label layer (GRATICULE_LABEL_LAYER_ID). captureAtlasPage explicitly switches to mapFit: "contain" specifically so the graticule's edge coordinate labels aren't cropped — but if a user also enables "Mask area outside current feature" on a page where the polygon doesn't fill the frame, the 70%-opacity white mask will sit on top of those edge labels (which are typically drawn right at/near the edges, i.e. in the masked "outside" region) and wash them out. Consider passing a beforeId (e.g. the graticule label layer, when present) so the mask renders under labels that should stay legible.

Confidence: medium — this is a plausible visual regression for the graticule + polygon-mask combination, but I haven't run the app to confirm the labels actually fall in the masked region.

id: FILL_LAYER_ID,
type: "fill",
source: SOURCE_ID,
metadata: { "geolibre:internal": true },
paint: {
"fill-color": "#ffffff",
Comment on lines +54 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mask fill is a hardcoded #ffffff at 0.7 opacity. On a dark basemap/theme this reads as a bright white overlay rather than an adaptive "de-emphasize" treatment — worth confirming this is the desired look for the dark-theme case the PR description says was tested, since GeoLibre otherwise threads light/dark awareness through most UI-facing rendering.

Confidence: low — this may well be an intentional, theme-independent print-composition choice (print output is typically a light background regardless of app theme).

"fill-opacity": 0.7,
"fill-outline-color": "rgba(0, 0, 0, 0)",
},
});
}
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage gap (low-medium confidence). This new module has no accompanying test file. The codebase already has a precedent for testing this exact kind of MapLibre-mutating code with a fake Map stub — see tests/print-extent.test.ts, which was added specifically because print-extent.ts "had no coverage before" and is DOM/map-driven the same way this file is. clearAtlasFeatureMask/showAtlasFeatureMask are pure enough (a few getLayer/addLayer/removeSource calls plus the Polygon/MultiPolygon type guard) that a similar lightweight fake-map test would be cheap to add and would catch regressions in the guard logic or layer/source lifecycle.

48 changes: 48 additions & 0 deletions apps/geolibre-desktop/src/lib/print-atlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,54 @@ import type { FeatureCollection, Geometry, Position } from "geojson";
/** Geographic bounds as `[west, south, east, north]` in WGS84 degrees. */
export type AtlasBounds = [number, number, number, number];

/** Symmetric fit padding and matching cover-crop rectangle for an atlas page. */
export interface AtlasViewportFrame {
padding: { top: number; bottom: number; left: number; right: number };
crop: { top: number; bottom: number; left: number; right: number };
}

/**
* Fit a target print-frame aspect ratio inside the live map viewport. The
* returned padding constrains `fitBounds` to the exact centered rectangle that
* the print renderer later keeps when it cover-crops the captured map.
*
* @param viewportWidth - Live map width in CSS pixels.
* @param viewportHeight - Live map height in CSS pixels.
* @param frameAspect - Printed map-frame width divided by height.
*/
export function atlasViewportFrame(
viewportWidth: number,
viewportHeight: number,
frameAspect: number,
): AtlasViewportFrame {
const width = Math.max(0, viewportWidth);
const height = Math.max(0, viewportHeight);
let horizontal = 0;
let vertical = 0;
if (width > 0 && height > 0 && Number.isFinite(frameAspect) && frameAspect > 0) {
const viewportAspect = width / height;
if (viewportAspect > frameAspect) {
horizontal = Math.max(0, (width - height * frameAspect) / 2);
} else if (viewportAspect < frameAspect) {
vertical = Math.max(0, (height - width / frameAspect) / 2);
}
}
return {
padding: {
top: vertical,
bottom: vertical,
left: horizontal,
right: horizontal,
},
crop: {
top: vertical,
bottom: height - vertical,
left: horizontal,
right: width - horizontal,
},
};
}

/** One page of an atlas: a coverage feature plus its resolved identity. */
export interface AtlasPage {
/** 0-based position in the final (filtered + sorted) page order. */
Expand Down
10 changes: 10 additions & 0 deletions apps/geolibre-desktop/src/lib/print-data-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ export function rowsWithinBounds(
return rows;
}

/**
* Select the attribute row belonging to the current atlas coverage feature.
* `AtlasPage.sourceIndex` is stable across filtering and sorting, and the row
* array preserves the source collection's order, so the two align directly.
*/
export function rowForAtlasFeature(rows: readonly ChartRow[], sourceIndex: number): ChartRow[] {
const row = rows[sourceIndex];
return row ? [row] : [];
}

/** Format one attribute value for a table cell (blank for null/undefined). */
function cellText(value: unknown): string {
if (value === null || value === undefined) return "";
Expand Down
61 changes: 55 additions & 6 deletions apps/geolibre-desktop/src/lib/print-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,22 @@ export interface PaperSize {
* {@link LayoutOptions.customSize}.
*/
export const PAPER_SIZES: PaperSize[] = [
{ id: "a4", label: "A4 (210 × 297 mm)", width: 210, height: 297, unit: "mm", group: "paper" },
{ id: "a3", label: "A3 (297 × 420 mm)", width: 297, height: 420, unit: "mm", group: "paper" },
{
id: "a4",
label: "A4 (210 × 297 mm)",
width: 210,
height: 297,
unit: "mm",
group: "paper",
},
{
id: "a3",
label: "A3 (297 × 420 mm)",
width: 297,
height: 420,
unit: "mm",
group: "paper",
},
{
id: "letter",
label: "Letter (8.5 × 11 in)",
Expand Down Expand Up @@ -86,7 +100,14 @@ export const PAPER_SIZES: PaperSize[] = [
unit: "px",
group: "screen",
},
{ id: "hd", label: "HD (1280 × 720 px)", width: 720, height: 1280, unit: "px", group: "screen" },
{
id: "hd",
label: "HD (1280 × 720 px)",
width: 720,
height: 1280,
unit: "px",
group: "screen",
},
{
id: "uhd4k",
label: "4K UHD (3840 × 2160 px)",
Expand All @@ -103,7 +124,14 @@ export const PAPER_SIZES: PaperSize[] = [
unit: "px",
group: "screen",
},
{ id: "custom", label: "Custom…", width: 1280, height: 720, unit: "px", group: "screen" },
{
id: "custom",
label: "Custom…",
width: 1280,
height: 720,
unit: "px",
group: "screen",
},
];

export function getPaperSize(id: PaperSizeId): PaperSize {
Expand Down Expand Up @@ -156,7 +184,10 @@ export function pageMm(size: ResolvedPageSize): {
heightMm: number;
} {
if (size.unit === "mm") return { widthMm: size.width, heightMm: size.height };
return { widthMm: size.width / PX_PER_MM_96, heightMm: size.height / PX_PER_MM_96 };
return {
widthMm: size.width / PX_PER_MM_96,
heightMm: size.height / PX_PER_MM_96,
};
}

/**
Expand Down Expand Up @@ -552,6 +583,19 @@ function computeBodyRect(opts: LayoutOptions, W: number, H: number): BodyRect {
};
}

/**
* Aspect ratio of the map frame after page margins, outside titles, and the
* footer row reserve their space. Atlas camera fitting uses this ratio so the
* feature remains visible after the captured live map is cover-cropped into
* the print frame.
*/
export function mapBodyAspectRatio(opts: LayoutOptions): number {
const page = resolvePageSize(opts);
const rect = computeBodyRect(opts, page.width, page.height);
const ratio = rect.bodyW / rect.bodyH;
return Number.isFinite(ratio) && ratio > 0 ? ratio : page.width / page.height;
}

/**
* Cover-scale of a captured map image into the body rectangle: the factor that
* fills the body (cropping overflow), matching the draw in {@link drawLayout}.
Expand Down Expand Up @@ -2104,7 +2148,12 @@ function drawLegend(
});
} else {
if (opts.groupByLayer) {
rows.push({ entryId: entry.id, color: "", text: entry.name, heading: true });
rows.push({
entryId: entry.id,
color: "",
text: entry.name,
heading: true,
});
}
for (const sw of entry.swatches) {
// Carry the marker so a marker + diagram layer (a multi-swatch entry
Expand Down
50 changes: 50 additions & 0 deletions tests/print-atlas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import type { Feature, FeatureCollection } from "geojson";
import {
atlasEntryName,
atlasViewportFrame,
buildAtlasPages,
buildLineAtlasPages,
hasLineGeometry,
Expand Down Expand Up @@ -183,6 +184,55 @@ describe("expandBounds", () => {
});
});

describe("atlasViewportFrame", () => {
it("adds vertical padding when the print frame is wider than the live map", () => {
const frame = atlasViewportFrame(1200, 900, 2);
assert.deepEqual(frame.padding, {
top: 150,
bottom: 150,
left: 0,
right: 0,
});
assert.deepEqual(frame.crop, {
top: 150,
bottom: 750,
left: 0,
right: 1200,
});
});

it("adds horizontal padding when the print frame is narrower than the live map", () => {
const frame = atlasViewportFrame(1200, 600, 1);
assert.deepEqual(frame.padding, {
top: 0,
bottom: 0,
left: 300,
right: 300,
});
assert.deepEqual(frame.crop, {
top: 0,
bottom: 600,
left: 300,
right: 900,
});
});

it("keeps the full viewport for matching or invalid aspect ratios", () => {
assert.deepEqual(atlasViewportFrame(1200, 600, 2).padding, {
top: 0,
bottom: 0,
left: 0,
right: 0,
});
assert.deepEqual(atlasViewportFrame(1200, 600, 0).crop, {
top: 0,
bottom: 600,
left: 0,
right: 1200,
});
});
});

describe("listAtlasFields", () => {
it("unions property keys in first-seen order", () => {
const fields = listAtlasFields([feature({ b: 1, a: 2 }), feature({ c: 3, a: 4 })]);
Expand Down
9 changes: 9 additions & 0 deletions tests/print-data-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DEFAULT_TABLE_ROWS,
layerRows,
MAX_TABLE_ROWS,
rowForAtlasFeature,
rowsWithinBounds,
} from "../apps/geolibre-desktop/src/lib/print-data-blocks";
import { collectAtlasFeatures } from "../apps/geolibre-desktop/src/lib/print-atlas";
Expand Down Expand Up @@ -81,6 +82,14 @@ describe("rowsWithinBounds", () => {
});
});

describe("rowForAtlasFeature", () => {
it("uses the stable source index and handles an out-of-range page", () => {
const source = rows({ name: "a" }, { name: "b" }, { name: "c" });
assert.deepEqual(rowForAtlasFeature(source, 1), [source[1]]);
assert.deepEqual(rowForAtlasFeature(source, 99), []);
});
});

describe("layerRows", () => {
it("maps features to property bags, defaulting missing properties", () => {
const result = layerRows({
Expand Down
Loading
Loading