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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 146 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
62 changes: 62 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,62 @@
/** Temporary inverted-fill mask for the active Print Layout atlas feature. */
import { buildInvertedMask } from "@geolibre/map/derived-geometry";
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";
const featureCollections = new WeakMap<
Feature<Polygon | MultiPolygon>,
FeatureCollection<Polygon | MultiPolygon>
>();

/** 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 polygonFeature = feature as Feature<Polygon | MultiPolygon>;
let collection = featureCollections.get(polygonFeature);
if (!collection) {
collection = {
type: "FeatureCollection",
features: [polygonFeature],
};
featureCollections.set(polygonFeature, collection);
}
const mask = buildInvertedMask(collection);
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
3 changes: 2 additions & 1 deletion packages/map/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./derived-geometry": "./src/derived-geometry.ts"
},
"dependencies": {
"@geolibre/core": "*",
Expand Down
104 changes: 104 additions & 0 deletions tests/print-atlas-mask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Feature, Point, Polygon } from "geojson";

import {
clearAtlasFeatureMask,
showAtlasFeatureMask,
} from "../apps/geolibre-desktop/src/lib/print-atlas-mask";

interface FakeSource {
data: unknown;
setData: (data: unknown) => void;
}

class FakeMap {
layers = new Map<string, unknown>();
sources = new Map<string, FakeSource>();

getLayer(id: string) {
return this.layers.get(id);
}

addLayer(layer: { id: string }) {
this.layers.set(layer.id, layer);
}

removeLayer(id: string) {
this.layers.delete(id);
}

getSource(id: string) {
return this.sources.get(id);
}

addSource(id: string, source: { data: unknown }) {
const fakeSource: FakeSource = {
data: source.data,
setData: (data) => {
fakeSource.data = data;
},
};
this.sources.set(id, fakeSource);
}

removeSource(id: string) {
this.sources.delete(id);
}
}

const polygon: Feature<Polygon> = {
type: "Feature",
properties: { name: "coverage" },
geometry: {
type: "Polygon",
coordinates: [
[
[0, 0],
[2, 0],
[2, 2],
[0, 2],
[0, 0],
],
],
},
};

describe("atlas feature mask", () => {
it("adds and clears the temporary mask source and layer", () => {
const map = new FakeMap();
assert.equal(showAtlasFeatureMask(map as never, polygon), true);
assert.equal(map.layers.size, 1);
assert.equal(map.sources.size, 1);

clearAtlasFeatureMask(map as never);
assert.equal(map.layers.size, 0);
assert.equal(map.sources.size, 0);
});

it("reuses the derived mask when the same feature is shown again", () => {
const map = new FakeMap();
showAtlasFeatureMask(map as never, polygon);
const source = map.sources.get("geolibre-print-atlas-mask");
const firstMask = source?.data;

showAtlasFeatureMask(map as never, polygon);
assert.strictEqual(source?.data, firstMask);
assert.equal(map.layers.size, 1);
assert.equal(map.sources.size, 1);
});

it("rejects a non-polygon feature and removes any previous mask", () => {
const map = new FakeMap();
showAtlasFeatureMask(map as never, polygon);
const point: Feature<Point> = {
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: [1, 1] },
};

assert.equal(showAtlasFeatureMask(map as never, point), false);
assert.equal(map.layers.size, 0);
assert.equal(map.sources.size, 0);
});
});
Loading
Loading