Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
79 changes: 45 additions & 34 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,26 +2014,49 @@ 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),
);
const rowH = unit * (hasSizedSwatch ? 3.6 : 2.6);
const swatch = unit * (hasSizedSwatch ? 3 : 2);
const titleSize = unit * 2;
const labelSize = unit * 1.7;
const title = opts.title.trim();
const hasTitle = title.length > 0;
const chromeH = pad * 2 + (hasTitle ? titleSize + unit : 0);
// 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 wantedSwatch = Math.max(unit * 2, maxSizedRadius * 2);
// A map symbol's size is independent of the page, so 1:1 sizing alone would
// let one outlier circle push rowH past the height the caller allows — and
// the truncation below then draws *nothing*, blanking unrelated normally
// sized entries too. Hold any single row to a quarter of the space left after
// chrome, so a layer heading, two class rows and the "+N more" note always
// survive: an extreme ramp then merely stops being 1:1 with the map instead
// of costing the reader the whole legend.
const swatchCap =
opts.maxHeight === undefined
? Infinity
: Math.max(unit * 2, (opts.maxHeight - chromeH) / 4 - unit * 0.6);
const swatch = Math.min(wantedSwatch, swatchCap);
const rowH = Math.max(unit * 2.6, swatch + unit * 0.6);
Comment thread
giswqs marked this conversation as resolved.
// Shrink every sized symbol by the same factor when the cap bites, so the
// ratios within a ramp survive and nothing overflows its swatch box.
const symbolScale = opts.mapSymbolScale * (swatch < wantedSwatch ? swatch / wantedSwatch : 1);
Comment thread
giswqs marked this conversation as resolved.
Outdated

// Flatten entries into drawable rows. Single-swatch entries render inline; a
// multi-class entry renders a layer heading (when groupByLayer is on) above
Expand Down Expand Up @@ -2087,14 +2116,14 @@ function drawLegend(
let hiddenRows = 0;
const maxHeight = opts.maxHeight;
if (maxHeight !== undefined) {
const chromeH = pad * 2 + (hasTitle ? titleSize + unit : 0);
if (chromeH + rows.length * rowH > maxHeight) {
const fitRows = Math.max(0, Math.floor((maxHeight - chromeH - rowH) / rowH));
// Not even one row plus its note fits. Drawing anyway would produce a box
// taller than the caller allotted whose only content is "+N more", so
// draw nothing and report no height. Defensive: every unit here scales
// with the page, so drawLayout's own maxHeight always clears ~24 rows
// whatever the paper size. Safe to return early — ctx.save() is below.
// with the page — proportional rows included, thanks to swatchCap — so
// drawLayout's own maxHeight always clears at least one row plus the
// note whatever the paper size. Safe to return early — ctx.save() is below.
if (fitRows === 0) return 0;
if (fitRows < rows.length) {
// A layer heading only means something with class rows under it, so a
Expand All @@ -2112,22 +2141,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 +2179,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 @@ -2178,8 +2190,7 @@ function drawLegend(
// through icon-size, so draw the marker at the same footprint the circle
// 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;
const edge = r.size !== undefined ? Math.max(unit * 0.7, r.size * symbolScale * 2) : swatch;
const inset = (swatch - edge) / 2;
drawLegendMarker(
ctx,
Expand All @@ -2192,7 +2203,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 * symbolScale);
const cx = sx + swatch / 2;
const cyc = sy + swatch / 2;
ctx.beginPath();
Expand Down
64 changes: 64 additions & 0 deletions tests/print-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,70 @@ 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: 16, 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 16 CSS-px radius is a 32 device-px radius before the map fit is applied.
// (Kept under the swatch cap below so this asserts the fit scaling alone.)
assert.equal(render(400), 57.6);
assert.equal(render(800), 28.8);
});

it("caps an outsized proportional symbol instead of blanking the legend box", () => {
// A live-map radius far larger than the page: sized 1:1 it would make one
// row taller than the whole map body, and the truncation rule would drop
// the entire box — including the unrelated entry below it.
const legend: LegendEntry[] = [
{
id: "hives",
name: "Hives",
swatches: [
{ color: "#3b82f6", label: "1", size: 4 },
{ color: "#3b82f6", label: "9000", size: 900 },
],
},
];
const rec = recordingCanvas();
drawLayout(
rec.canvas,
baseOptions({
legend,
mapImage: {} as CanvasImageSource,
mapImageWidth: 400,
mapImageHeight: 400,
mapPixelRatio: 2,
}),
);
const texts = rec.fills.map((f) => f.text);
assert.ok(texts.includes("Hives"), `legend was blanked: ${texts.join()}`);
assert.ok(texts.includes("1") && texts.includes("9000"), `rows elided: ${texts.join()}`);
});

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