Skip to content

Commit b93ad29

Browse files
authored
fix(legend): draw proportional size ramps with the layer's marker (#1722)
* fix(legend): draw proportional size ramps with the layer's marker A point layer with both a custom marker and proportional sizing was described by two legends that each got half of it right: the Print Layout legend drew the marker with no size ramp, and the on-map Legend panel drew a size ramp of plain blue circles with a blank heading chip. Neither matched what the map renders, and the ramp's symbols were drawn at roughly half the size the map paints. Three fixes: - The on-map legend's `MarkerSwatch` wrapped `markerSvg` in a `data:image/svg+xml,...` URL unconditionally, so a marker given as an `https:` or `data:` URL (both documented inputs) re-encoded into a broken source and rendered blank. The map's own `resolveSvgSource` handles all three forms, so it moves to `@geolibre/core` next to `drawMarkerPath` and both legends and the sprite baker now share it. - Proportional size rows in both legends carry the layer's marker, and both renderers draw it at the row's size. The map scales the marker sprite through `icon-size`, so a circle there advertises a symbol the map never draws. Line layers keep their stroke ramp. - Legend symbols now read at the map's true sizes: the on-map cap rises from a 12 px radius to 24 (the style editor's default maximum, so the common ramp is 1:1 instead of half scale), and the print legend's swatch column and row height grow when an entry carries sized symbols. Ramps configured past the cap still scale down as one entry, keeping the map's ratios. Refs #1711 * refactor(print-layout): rename the shadowed swatch callback parameter * Address CodeRabbit review feedback - MarkerSwatch: draw the canvas fallback for a custom marker whose SVG source does not resolve. The effect returned early for every "custom" marker, so an unsupported scheme (pointMarkerSwatch only rejects EMPTY markup, not an unresolvable one) rendered an empty canvas — the same blank-chip failure this PR set out to fix, reached by another path. Gating the early return on a resolved source lets drawMarkerPath trace its documented default circle instead. * Address Claude review feedback - LegendSwatch: hoist the duplicated swatchScale call into one `scale` local, matching how GeometrySwatch just above does it. - print-layout: a proportional row whose custom SVG has not loaded now falls back to the same circle the markerless ramp draws, instead of a framed square per step. That was the one path where the "nested boxes instead of one growing symbol" look survived the `boxed` flag. The outline stays on both fallback shapes: a pale fallback color with no outline would vanish against the legend box. Covered by a new test.
1 parent 1887398 commit b93ad29

11 files changed

Lines changed: 534 additions & 92 deletions

File tree

apps/geolibre-desktop/src/components/legend/LegendSwatch.tsx

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Adapted from the GeoLens viewer legend design (Apache-2.0) to GeoLibre's
77
* layer model.
88
*/
9-
import { drawMarkerPath, type MarkerShape } from "@geolibre/core";
9+
import { drawMarkerPath, resolveSvgSource, type MarkerShape } from "@geolibre/core";
1010
import { Image as RasterIcon } from "lucide-react";
1111
import { useEffect, useRef } from "react";
1212
import type { LayerSwatchShape } from "../../lib/layer-swatch";
@@ -15,8 +15,17 @@ import type { LegendMarker } from "../../lib/print-layout";
1515
/** Neutral outline that reads on both light and dark themes. */
1616
const OUTLINE = "rgba(107,114,128,0.6)";
1717

18-
/** Largest radius / stroke width a proportional legend symbol is drawn at (px). */
19-
const MAX_SWATCH_RADIUS = 12;
18+
/**
19+
* Largest radius / stroke width a proportional legend symbol is drawn at (px).
20+
*
21+
* A proportional row's `size` IS the radius the map paints for that value (and,
22+
* for markers, half the sprite's on-screen width — see `markerIconSizeValue` in
23+
* `@geolibre/map`), so below the cap the legend symbol is drawn at exactly the
24+
* size the reader sees on the map. The radius cap matches the style editor's
25+
* default max radius (24) so the common ramp is 1:1 rather than shrunk to
26+
* roughly half scale; ramps configured past it scale down as one entry.
27+
*/
28+
const MAX_SWATCH_RADIUS = 24;
2029
const MAX_SWATCH_STROKE = 8;
2130

2231
/**
@@ -111,50 +120,89 @@ export function GeometrySwatch({
111120
);
112121
}
113122

123+
/** Chip edge length for a marker with no proportional size (px). */
124+
const MARKER_CHIP_SIZE = 14;
125+
114126
/**
115127
* A point-marker preview: built-in shapes are traced with the same
116128
* {@link drawMarkerPath} the map's sprite baker uses, so the legend chip and
117-
* the on-map marker cannot disagree; custom SVG markers render as an image.
129+
* the on-map marker cannot disagree; custom SVG markers render as an image
130+
* whose source is resolved by the shared {@link resolveSvgSource} — inline
131+
* markup, a `data:` URL, and an `https:` URL are all valid `markerSvg` inputs,
132+
* and re-encoding the latter two as data URLs left the chip blank.
133+
*
134+
* `size` (a proportional row's radius) and `maxSize` (the entry's largest)
135+
* scale the marker exactly like {@link GeometrySwatch} scales a circle, so a
136+
* marker layer's size ramp reads at the map's true symbol widths.
118137
*/
119-
export function MarkerSwatch({ marker, opacity = 1 }: { marker: LegendMarker; opacity?: number }) {
138+
export function MarkerSwatch({
139+
marker,
140+
size,
141+
maxSize,
142+
opacity = 1,
143+
}: {
144+
marker: LegendMarker;
145+
size?: number;
146+
maxSize?: number;
147+
opacity?: number;
148+
}) {
120149
const canvasRef = useRef<HTMLCanvasElement | null>(null);
121150
const style = opacity < 1 ? { opacity } : undefined;
151+
// The map draws a marker at the diameter its proportional radius spans, so
152+
// the chip box is 2×radius after the entry's shared fit-to-legend scale.
153+
const scale = size !== undefined ? swatchScale(maxSize, size, MAX_SWATCH_RADIUS) : 1;
154+
const box = size !== undefined ? Math.max(4, Math.round(size * scale * 2)) : MARKER_CHIP_SIZE;
155+
// One column width per entry, so labels stay aligned when rows differ in size.
156+
const boxWidth =
157+
size !== undefined
158+
? Math.max(box, Math.round(Math.max(maxSize ?? size, size) * scale * 2))
159+
: MARKER_CHIP_SIZE;
160+
161+
const svgSource = marker.shape === "custom" ? resolveSvgSource(marker.svg ?? "") : null;
122162

123163
useEffect(() => {
124-
if (marker.shape === "custom") return;
164+
// A custom marker whose source resolved renders as an <img> below; one that
165+
// did not (an unsupported scheme — `pointMarkerSwatch` only rejects EMPTY
166+
// markup) falls through to this canvas, where drawMarkerPath traces its
167+
// documented default circle rather than leaving the chip blank.
168+
if (marker.shape === "custom" && svgSource) return;
125169
const canvas = canvasRef.current;
126170
const ctx = canvas?.getContext("2d");
127171
if (!canvas || !ctx) return;
128-
const size = canvas.width;
129-
ctx.clearRect(0, 0, size, size);
130-
drawMarkerPath(ctx, marker.shape as MarkerShape, size);
172+
const edge = canvas.width;
173+
ctx.clearRect(0, 0, edge, edge);
174+
drawMarkerPath(ctx, marker.shape as MarkerShape, edge);
131175
ctx.fillStyle = marker.color;
132176
ctx.fill();
133177
ctx.strokeStyle = OUTLINE;
134178
ctx.lineWidth = 1;
135179
ctx.stroke();
136-
}, [marker]);
180+
}, [marker, box, svgSource]);
137181

138-
if (marker.shape === "custom" && marker.svg) {
182+
if (marker.shape === "custom" && svgSource) {
139183
return (
140-
<img
184+
<span
141185
aria-hidden="true"
142-
className="h-3.5 w-3.5 shrink-0 object-contain"
143-
style={style}
144-
alt=""
145-
src={`data:image/svg+xml;utf8,${encodeURIComponent(marker.svg)}`}
146-
/>
186+
className="flex shrink-0 items-center justify-center"
187+
style={{ width: boxWidth, ...style }}
188+
>
189+
<img
190+
className="object-contain"
191+
style={{ width: box, height: box }}
192+
alt=""
193+
src={svgSource}
194+
/>
195+
</span>
147196
);
148197
}
149198
return (
150-
<canvas
151-
ref={canvasRef}
152-
width={14}
153-
height={14}
154-
className="h-3.5 w-3.5 shrink-0"
155-
style={style}
199+
<span
156200
aria-hidden="true"
157-
/>
201+
className="flex shrink-0 items-center justify-center"
202+
style={{ width: boxWidth, ...style }}
203+
>
204+
<canvas ref={canvasRef} width={box} height={box} style={{ width: box, height: box }} />
205+
</span>
158206
);
159207
}
160208

apps/geolibre-desktop/src/components/legend/MapLegendPanel.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,12 @@ function LegendClassRow({
931931
)}
932932
<div className="flex items-center gap-1.5">
933933
{row.marker ? (
934-
<MarkerSwatch marker={row.marker} opacity={entry.opacity} />
934+
<MarkerSwatch
935+
marker={row.marker}
936+
size={row.size}
937+
maxSize={maxRowSize > 0 ? maxRowSize : undefined}
938+
opacity={entry.opacity}
939+
/>
935940
) : (
936941
<GeometrySwatch
937942
shape={row.shape}

apps/geolibre-desktop/src/lib/auto-legend.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -457,13 +457,16 @@ function proportionalSizeRows(
457457
locale: string | undefined,
458458
/** Field caption for the block, when the entry's own caption names another. */
459459
caption?: string,
460+
/** The layer's point marker, so the ramp shows the symbol the map draws. */
461+
marker?: LegendMarker,
460462
): RawRow[] {
461463
const rowShape: LayerSwatchShape = shape === "line" ? "line" : "circle";
462464
return [0, 0.5, 1].map((ratio, index) => ({
463465
label: formatLegendNumber(lerp(range.minValue, range.maxValue, ratio), locale),
464466
color,
465467
shape: rowShape,
466468
size: lerp(range.minRadius, range.maxRadius, ratio),
469+
...(marker ? { marker } : {}),
467470
...(index === 0 && caption ? { caption } : {}),
468471
}));
469472
}
@@ -492,14 +495,20 @@ function sizeClassRows(
492495
rows: RawRow[],
493496
stops: VectorStyleStop[],
494497
range: ProportionalSizeRange,
498+
/** The layer's point marker, so the ramp shows the symbol the map draws. */
499+
marker?: LegendMarker,
495500
): RawRow[] {
496501
return rows.map((row, index) => {
497502
const from = Number(stops[index]?.value);
498503
const to = Number(stops[index + 1]?.value);
499504
// The top class has no upper bound ("≥ x"): represent it at its lower bound.
500505
const representative = Number.isFinite(to) ? (from + to) / 2 : from;
501506
if (!Number.isFinite(representative)) return row;
502-
return { ...row, size: proportionalSize(range, representative) };
507+
return {
508+
...row,
509+
size: proportionalSize(range, representative),
510+
...(marker ? { marker } : {}),
511+
};
503512
});
504513
}
505514

@@ -673,6 +682,11 @@ function vectorParts(
673682
// sizing on exactly the layers the map actually sizes. Only circles and line
674683
// strokes carry a size; polygon fills ignore it.
675684
const sizeRange = shape === "circle" || shape === "line" ? proportionalSizeRange(style) : null;
685+
// A marker layer's proportional rows draw the marker (scaled by icon-size on
686+
// the map), not a circle, so carry it onto every sized row. Lines never take a
687+
// marker. Markers bake ONE sprite from markerColor, so a per-row color is not
688+
// something the map renders here — the marker is the faithful symbol.
689+
const sizeMarker = shape === "circle" ? pointMarkerSwatch(style)?.marker : undefined;
676690

677691
if ((mode === "graduated" || mode === "categorized") && stops.length > 0) {
678692
const classProperty = styleValue(style, "vectorStyleProperty");
@@ -683,7 +697,9 @@ function vectorParts(
683697
sizeRange !== null && mode === "graduated" && sizeRange.property === classProperty;
684698
return {
685699
rows: [
686-
...(merged && sizeRange ? sizeClassRows(classRows, stops, sizeRange) : classRows),
700+
...(merged && sizeRange
701+
? sizeClassRows(classRows, stops, sizeRange, sizeMarker)
702+
: classRows),
687703
...(sizeRange && !merged
688704
? proportionalSizeRows(
689705
sizeRange,
@@ -693,6 +709,7 @@ function vectorParts(
693709
// The entry caption already names the classified field; say which
694710
// field this second block sizes by so the two are not confused.
695711
sizeRange.property === classProperty ? undefined : sizeRange.property,
712+
sizeMarker,
696713
)
697714
: []),
698715
...diagrams,
@@ -715,6 +732,7 @@ function vectorParts(
715732
shape,
716733
locale,
717734
sizeRange.property,
735+
sizeMarker,
718736
)
719737
: []),
720738
...diagrams,
@@ -737,6 +755,7 @@ function vectorParts(
737755
shape,
738756
locale,
739757
sizeRange.property === parts.fieldLabel ? undefined : sizeRange.property,
758+
sizeMarker,
740759
)
741760
: []),
742761
...diagrams,
@@ -751,7 +770,14 @@ function vectorParts(
751770
// Single symbology: the size ramp IS the classification, so it carries the
752771
// field caption and replaces the single-swatch heading chip.
753772
const sizeRows = sizeRange
754-
? proportionalSizeRows(sizeRange, styleValue(style, "fillColor") || NEUTRAL, shape, locale)
773+
? proportionalSizeRows(
774+
sizeRange,
775+
styleValue(style, "fillColor") || NEUTRAL,
776+
shape,
777+
locale,
778+
undefined,
779+
sizeMarker,
780+
)
755781
: [];
756782
const marker = pointMarkerSwatch(style);
757783
const headerSwatch = marker

apps/geolibre-desktop/src/lib/print-layout.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,6 +1935,11 @@ function drawDataChart(
19351935
* built-in shape recolored to the marker color. Falls back to a plain
19361936
* `fallbackColor` square when a custom SVG has not been (or could not be)
19371937
* preloaded, so the swatch is never blank.
1938+
*
1939+
* `boxed` frames a custom SVG so a light or transparent-edged icon still reads
1940+
* as a bounded swatch. A proportional size ramp passes false: there the varying
1941+
* icon size IS the information, and a frame around each step makes the rows read
1942+
* as nested squares instead of one growing symbol.
19381943
*/
19391944
function drawLegendMarker(
19401945
ctx: CanvasRenderingContext2D,
@@ -1944,22 +1949,35 @@ function drawLegendMarker(
19441949
size: number,
19451950
fallbackColor: string,
19461951
markerIcons?: ReadonlyMap<string, CanvasImageSource>,
1952+
boxed = true,
19471953
): void {
19481954
if (marker.shape === "custom") {
19491955
const icon = marker.svg ? markerIcons?.get(marker.svg) : undefined;
19501956
if (icon) {
19511957
ctx.drawImage(icon, sx, sy, size, size);
1952-
// Border for parity with every other swatch (built-in shapes, the
1953-
// fallback square, fill/ramp squares), so a light or transparent-edged
1954-
// SVG still reads as a bounded swatch.
1955-
ctx.strokeStyle = BORDER;
1956-
ctx.strokeRect(sx, sy, size, size);
1958+
if (boxed) {
1959+
// Border for parity with every other swatch (built-in shapes, the
1960+
// fallback square, fill/ramp squares).
1961+
ctx.strokeStyle = BORDER;
1962+
ctx.strokeRect(sx, sy, size, size);
1963+
}
19571964
return;
19581965
}
1959-
// SVG not available: fall back to a neutral color square.
1966+
// SVG not available (still loading, or the load failed): fall back to a
1967+
// neutral shape. A proportional row falls back to the same circle the
1968+
// markerless ramp draws, so an entry mid-load still reads as one growing
1969+
// symbol; the outline stays either way, since a pale fallback color would
1970+
// otherwise vanish against the legend box.
19601971
ctx.fillStyle = fallbackColor || "#999999";
1961-
ctx.fillRect(sx, sy, size, size);
19621972
ctx.strokeStyle = BORDER;
1973+
if (!boxed) {
1974+
ctx.beginPath();
1975+
ctx.arc(sx + size / 2, sy + size / 2, size / 2, 0, Math.PI * 2);
1976+
ctx.fill();
1977+
ctx.stroke();
1978+
return;
1979+
}
1980+
ctx.fillRect(sx, sy, size, size);
19631981
ctx.strokeRect(sx, sy, size, size);
19641982
return;
19651983
}
@@ -1996,8 +2014,16 @@ function drawLegend(
19962014
},
19972015
): number {
19982016
const pad = unit * 1.4;
1999-
const rowH = unit * 2.6;
2000-
const swatch = unit * 2;
2017+
// Proportional-symbol rows are only as faithful as the box they fit in: a
2018+
// swatch column sized for a text-height color square crushes a 4 → 24 px ramp
2019+
// into near-identical dots. When any entry carries sized symbols, the whole
2020+
// box grows (uniformly, so rows stay evenly spaced) and the ramp reads at
2021+
// roughly the ratios the map draws.
2022+
const hasSizedSwatch = entries.some((entry) =>
2023+
entry.swatches.some((entrySwatch) => entrySwatch.size !== undefined),
2024+
);
2025+
const rowH = unit * (hasSizedSwatch ? 3.6 : 2.6);
2026+
const swatch = unit * (hasSizedSwatch ? 3 : 2);
20012027
const titleSize = unit * 2;
20022028
const labelSize = unit * 1.7;
20032029
const title = opts.title.trim();
@@ -2148,7 +2174,23 @@ function drawLegend(
21482174
const sx = x + pad;
21492175
const sy = cy - swatch * 0.85;
21502176
if (r.marker) {
2151-
drawLegendMarker(ctx, r.marker, sx, sy, swatch, r.color, opts.markerIcons);
2177+
// A sized marker row is a proportional symbol: the map scales the sprite
2178+
// through icon-size, so draw the marker at the same footprint the circle
2179+
// branch below would use (same center, edge = 2 × radius) rather than at
2180+
// the fixed swatch box, which would flatten the whole ramp.
2181+
const edge =
2182+
r.size !== undefined ? Math.max(unit * 0.7, r.size * rowScale[index]! * 2) : swatch;
2183+
const inset = (swatch - edge) / 2;
2184+
drawLegendMarker(
2185+
ctx,
2186+
r.marker,
2187+
sx + inset,
2188+
sy + inset,
2189+
edge,
2190+
r.color,
2191+
opts.markerIcons,
2192+
r.size === undefined,
2193+
);
21522194
} else if (r.size !== undefined && r.color) {
21532195
const radius = Math.max(unit * 0.35, r.size * rowScale[index]!);
21542196
const cx = sx + swatch / 2;

0 commit comments

Comments
 (0)