Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ export function PrintLayoutDialog({
legendFormatNote: (count: number) => t("printLayout.legend.moreItems", { count }),
markerIcons,
metersPerPixel: captured?.metersPerPixel ?? 0,
mapPixelRatio: captured?.pixelRatio ?? 1,
bearingDeg: captured?.bearingDeg ?? 0,
mapImage: captured?.image ?? null,
mapImageWidth: captured?.width ?? 0,
Expand Down Expand Up @@ -1210,6 +1211,7 @@ export function PrintLayoutDialog({
subtitle: substituteAtlasTokens(options.subtitle, ctx),
footerText: substituteAtlasTokens(options.footerText, ctx),
metersPerPixel: cap.metersPerPixel,
mapPixelRatio: cap.pixelRatio,
bearingDeg: cap.bearingDeg,
mapImage: cap.image,
mapImageWidth: cap.width,
Expand Down Expand Up @@ -1640,6 +1642,7 @@ export function PrintLayoutDialog({
subtitle: substituteAtlasTokens(options.subtitle, ctx),
footerText: substituteAtlasTokens(options.footerText, ctx),
metersPerPixel: cap.metersPerPixel,
mapPixelRatio: cap.pixelRatio,
bearingDeg: cap.bearingDeg,
mapImage: cap.image,
mapImageWidth: cap.width,
Expand Down
3 changes: 3 additions & 0 deletions apps/geolibre-desktop/src/lib/print-layout-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface CapturedMap {
height: number;
/** Ground metres per device pixel of the captured image, at map centre. */
metersPerPixel: number;
/** Device pixels per CSS pixel in the captured map canvas. */
pixelRatio: number;
bearingDeg: number;
}

Expand Down Expand Up @@ -209,6 +211,7 @@ export function captureMapImage(map: MapLike, clip?: CaptureClip | null): Captur
width: image.width,
height: image.height,
metersPerPixel,
pixelRatio: dpr,
bearingDeg: map.getBearing(),
};
}
Expand Down
54 changes: 25 additions & 29 deletions apps/geolibre-desktop/src/lib/print-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@ export interface LayoutOptions {
markerIcons?: ReadonlyMap<string, CanvasImageSource>;
/** Ground metres per source-image pixel at the map centre. */
metersPerPixel: number;
/** Device pixels per CSS pixel in the captured map image. */
mapPixelRatio?: number;
/** Map bearing in degrees clockwise from north. */
bearingDeg: number;
/** The captured map image (already composited). */
Expand Down Expand Up @@ -888,6 +890,10 @@ export function drawLayout(canvas: HTMLCanvasElement, opts: LayoutOptions): void
title: opts.legendTitle,
groupByLayer: opts.legendGroupByLayer,
markerIcons: opts.markerIcons,
// Legend sizes are stored in MapLibre CSS pixels. The capture is in
// device pixels and is then fitted into the page body, so apply both
// transforms to make the legend symbols match their map counterparts.
mapSymbolScale: Math.max(0, (opts.mapPixelRatio ?? 1) * coverScale),
maxHeight: bodyH - inset * 2,
formatNote: opts.legendFormatNote,
});
Expand Down Expand Up @@ -2008,22 +2014,29 @@ function drawLegend(
title: string;
groupByLayer: boolean;
markerIcons?: ReadonlyMap<string, CanvasImageSource>;
/** Output pixels per MapLibre CSS pixel in the composed map image. */
mapSymbolScale: number;
/** Vertical space the box may occupy before rows are elided. */
maxHeight?: number;
formatNote?: (count: number) => string;
},
): number {
const pad = unit * 1.4;
// Proportional-symbol rows are only as faithful as the box they fit in: a
// swatch column sized for a text-height color square crushes a 4 → 24 px ramp
// into near-identical dots. When any entry carries sized symbols, the whole
// box grows (uniformly, so rows stay evenly spaced) and the ramp reads at
// roughly the ratios the map draws.
const hasSizedSwatch = entries.some((entry) =>
entry.swatches.some((entrySwatch) => entrySwatch.size !== undefined),
// Proportional sizes are MapLibre CSS-pixel radii. Size the legend column and
// rows around their actual footprint after the captured map is fitted into
// the page, so the ramp remains 1:1 with the symbols visible behind it.
const maxSizedRadius = entries.reduce(
(max, entry) =>
Math.max(
max,
...entry.swatches.map((entrySwatch) =>
entrySwatch.size === undefined ? 0 : entrySwatch.size * opts.mapSymbolScale,
),
),
0,
);
const rowH = unit * (hasSizedSwatch ? 3.6 : 2.6);
const swatch = unit * (hasSizedSwatch ? 3 : 2);
const swatch = Math.max(unit * 2, maxSizedRadius * 2);
const rowH = Math.max(unit * 2.6, swatch + unit * 0.6);
Comment thread
giswqs marked this conversation as resolved.
const titleSize = unit * 2;
const labelSize = unit * 1.7;
const title = opts.title.trim();
Expand Down Expand Up @@ -2112,22 +2125,6 @@ function drawLegend(
const note = hiddenRows > 0 ? (opts.formatNote?.(hiddenRows) ?? `+${hiddenRows} more`) : "";
const hasNote = note.length > 0;

// Cap proportional circles so a huge max radius still fits the legend box,
// while keeping ratios within each entry (same idea as the on-map LegendSwatch).
const MAX_CIRCLE_R = swatch * 0.55;
const entryMaxSize = new Map<string, number>();
for (const r of rows) {
if (r.size === undefined) continue;
entryMaxSize.set(r.entryId, Math.max(entryMaxSize.get(r.entryId) ?? 0, r.size));
}
const entryScale = new Map<string, number>();
for (const [entryId, maxSize] of entryMaxSize) {
entryScale.set(entryId, maxSize > MAX_CIRCLE_R ? MAX_CIRCLE_R / maxSize : 1);
}
const rowScale: number[] = rows.map((r) =>
r.size !== undefined ? (entryScale.get(r.entryId) ?? 1) : 1,
);

// Measure required width.
ctx.save();
ctx.font = `600 ${titleSize}px system-ui, sans-serif`;
Expand Down Expand Up @@ -2166,8 +2163,7 @@ function drawLegend(
cy += unit;
}

for (let index = 0; index < rows.length; index++) {
const r = rows[index]!;
for (const r of rows) {
cy += rowH;
const hasSwatch = rowHasSwatch(r);
const textX = hasSwatch ? x + pad + swatch + unit : x + pad;
Expand All @@ -2179,7 +2175,7 @@ function drawLegend(
// branch below would use (same center, edge = 2 × radius) rather than at
// the fixed swatch box, which would flatten the whole ramp.
const edge =
r.size !== undefined ? Math.max(unit * 0.7, r.size * rowScale[index]! * 2) : swatch;
r.size !== undefined ? Math.max(unit * 0.7, r.size * opts.mapSymbolScale * 2) : swatch;
const inset = (swatch - edge) / 2;
drawLegendMarker(
ctx,
Expand All @@ -2192,7 +2188,7 @@ function drawLegend(
r.size === undefined,
);
} else if (r.size !== undefined && r.color) {
const radius = Math.max(unit * 0.35, r.size * rowScale[index]!);
const radius = Math.max(unit * 0.35, r.size * opts.mapSymbolScale);
const cx = sx + swatch / 2;
const cyc = sy + swatch / 2;
ctx.beginPath();
Expand Down
33 changes: 33 additions & 0 deletions tests/print-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,39 @@ describe("drawLayout legend rendering", () => {
);
});

it("matches proportional legend markers to the fitted map image scale", () => {
const svg = "<svg/>";
const image = {} as unknown as CanvasImageSource;
const marker = { shape: "custom", color: "#3b82f6", svg } as const;
const legend: LegendEntry[] = [
{
id: "ruchers",
name: "Ruchers",
swatches: [{ color: "#3b82f6", label: "86", size: 24, marker }],
},
];
const render = (mapImageWidth: number) => {
const rec = recordingCanvas();
drawLayout(
rec.canvas,
baseOptions({
legend,
markerIcons: new Map([[svg, image]]),
mapImage: {} as CanvasImageSource,
mapImageWidth,
mapImageHeight: mapImageWidth,
mapPixelRatio: 2,
}),
);
return rec.imageBoxes.at(-1)!.w;
};

// A 400 px square page with normal margins has a 360 px map body. At DPR 2,
// a 24 CSS-px radius is a 48 device-px radius before the map fit is applied.
assert.equal(render(400), 86.4);
assert.equal(render(800), 43.2);
});

it("falls back to a color square when a custom SVG marker icon is not preloaded", () => {
const svg = "<svg/>";
const rec = recordingCanvas();
Expand Down
Loading