From ee44b929e2f7fe3804574f9618d32edcfa8d88f1 Mon Sep 17 00:00:00 2001 From: Weltstaat Date: Tue, 6 Oct 2026 15:36:27 +0200 Subject: [PATCH 1/2] List rows keep their content height A host that gives the list a fixed height (the styleeditor's flex column) squeezed the `auto` rows down to the cells' min-height, and symbols with their names ran into the neighbouring rows. `grid-auto-rows: max-content` keeps every row as tall as its content; the list scrolls instead. Co-Authored-By: Claude Opus 5.5 --- .changeset/rows-keep-content-height.md | 5 +++++ README.md | 3 ++- src/style.css | 6 +++++- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/rows-keep-content-height.md diff --git a/.changeset/rows-keep-content-height.md b/.changeset/rows-keep-content-height.md new file mode 100644 index 0000000..9e7844a --- /dev/null +++ b/.changeset/rows-keep-content-height.md @@ -0,0 +1,5 @@ +--- +"@maptoolkit/maplibre-legend-control": patch +--- + +List rows keep their content height: a host that gave the list a fixed height (e.g. a flex layout) squeezed the rows and symbols with their names ran into the neighbouring rows; the list now scrolls instead. diff --git a/README.md b/README.md index 3ab446c..432596a 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,8 @@ entry; the manifest label (or the humanized key) on the right, right-aligned. The list is one grid across all groups, so the left column is as wide as the widest symbol and every block sits on one centre axis; `text-justify` only aligns the lines inside a block (a peak keeps its elevation left-aligned under -its name). +its name). Rows are as tall as their content, so a host that gives the list a +fixed height (e.g. a flex layout) gets a scrolling list, never squeezed rows. The reverse also happens: one layer can paint a few property values differently, and its tag then gives those values their own keys diff --git a/src/style.css b/src/style.css index 119832c..d891470 100644 --- a/src/style.css +++ b/src/style.css @@ -648,10 +648,14 @@ /* list — ONE grid across all groups: the left column is as wide as the widest swatch/symbol anywhere in the legend, so centred symbols share one axis and - nothing gets clipped; group headings span both columns. */ + nothing gets clipped; group headings span both columns. Rows are as tall as + their content: with `auto` rows, a host that gives the list a fixed height + (a flex column) squeezed them down to the cells' min-height and the symbols + ran into each other — the list scrolls instead. */ .maplibre-legend-control .maplibre-legend-control-list { display: grid; grid-template-columns: max-content minmax(72px, 1fr); + grid-auto-rows: max-content; column-gap: var(--legend-control-space-3); align-items: center; overflow: auto; From a7f4988d4539e2627e237a3ddde0506d08f60429 Mon Sep 17 00:00:00 2001 From: Weltstaat Date: Tue, 6 Oct 2026 15:36:27 +0200 Subject: [PATCH 2/2] Line swatches show the map's widths Every line swatch used to be scaled on its own to a 16 px stroke, so at high zoom all wide roads looked equally wide. Strokes up to 30 px are now drawn as wide as on the map, the swatch growing taller around the centred curve; wider stacks are scaled down with one scale for all lines of a group (the widest decides), so the lines keep their widths relative to each other. Co-Authored-By: Claude Opus 5.5 --- .changeset/line-widths-as-on-map.md | 5 ++ README.md | 7 ++- src/LegendControl.ts | 9 ++- src/swatch.ts | 89 ++++++++++++++++++++--------- test/swatch.test.ts | 57 ++++++++++++++---- 5 files changed, 124 insertions(+), 43 deletions(-) create mode 100644 .changeset/line-widths-as-on-map.md diff --git a/.changeset/line-widths-as-on-map.md b/.changeset/line-widths-as-on-map.md new file mode 100644 index 0000000..221b568 --- /dev/null +++ b/.changeset/line-widths-as-on-map.md @@ -0,0 +1,5 @@ +--- +"@maptoolkit/maplibre-legend-control": patch +--- + +Line swatches show the map's widths: strokes up to 30 px are drawn as wide as on the map (the row grows taller for them) instead of being capped at 16 px each, and wider ones are scaled down with one scale for all lines of a group, so a minor road stays narrower than a major one at every zoom. diff --git a/README.md b/README.md index 432596a..2c1630b 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,11 @@ evaluated paint and layout, and builds: ferries and admin borders, wavier for minor roads, pistes, cycle routes and waterways, winding for paths and contours — one of its shapes per entry, taken from the layer's position in the style so that layers drawn next to - each other never share a shape, stable across updates — scaled - together with their blur when too wide; the copies come from one and the same feature, ground level + each other never share a shape, stable across updates — as wide as on the + map up to 30 px (the row grows taller for them), beyond that scaled + together with their blur, with one scale for all lines of a group (the + widest decides), so a minor road stays narrower than a major one at every + zoom; the copies come from one and the same feature, ground level preferred over bridge/tunnel duplicates whose shadows and casings stay out. Route overlays (tag `overlay`) show the whole stack of their feature — the hiking band together with the path it runs on. Of the rendered stretches the diff --git a/src/LegendControl.ts b/src/LegendControl.ts index 73d6de8..2d7dcac 100644 --- a/src/LegendControl.ts +++ b/src/LegendControl.ts @@ -1,7 +1,7 @@ import type { Map, IControl, ControlPosition } from "maplibre-gl"; import { buildLegendModel, tagIn, featureIdentity, valueToString } from "./model"; import { legendLocaleFor } from "./locales"; -import { createSwatch, createSymbolOnSwatch, createSymbolPreview, type GetImage, type RenderOptions } from "./swatch"; +import { createSwatch, createSymbolOnSwatch, createSymbolPreview, lineFitScale, type GetImage, type RenderOptions } from "./swatch"; import { LEGEND_METADATA_KEY, type LegendEntry, type LegendManifest, type LegendModel, type RenderedFeature } from "./types"; /** @@ -793,6 +793,11 @@ export class LegendControl implements IControl { heading.textContent = group.label; section.appendChild(heading); + // one stroke scale for every line of the group: the widest decides, so the + // lines keep their widths relative to each other, as on the map + const lineScale = Math.min(1, ...group.entries.map((e) => lineFitScale(e.anchor?.swatch ?? e.swatch))); + const groupRender = { ...render, lineScale }; + const ul = document.createElement("ul"); ul.classList.add(`${CLASS}-entries`); for (const entry of group.entries) { @@ -805,7 +810,7 @@ export class LegendControl implements IControl { // (a peak keeps its elevation left-aligned under the name). const visual = document.createElement("span"); visual.classList.add(`${CLASS}-visual`); - visual.appendChild(entry.kind === "instance" ? symbolOf(entry, getImage, render) : createSwatch(entry.swatch, getImage, entry.variant, render)); + visual.appendChild(entry.kind === "instance" ? symbolOf(entry, getImage, groupRender) : createSwatch(entry.swatch, getImage, entry.variant, groupRender)); li.appendChild(visual); const label = document.createElement("span"); diff --git a/src/swatch.ts b/src/swatch.ts index 2d988f4..8fb6cd7 100644 --- a/src/swatch.ts +++ b/src/swatch.ts @@ -17,7 +17,11 @@ export type StyleImageLike = { export type GetImage = (id: string) => StyleImageLike | undefined | null; /** How the rows are rendered: `maxNameWidth` caps a name in the map font (px); see `LegendControlOptions.maxNameWidth`. */ -export type RenderOptions = { maxNameWidth?: number }; +export type RenderOptions = { + maxNameWidth?: number; + /** One stroke scale for every line of a group (see {@link lineFitScale}): lines keep their widths relative to each other. */ + lineScale?: number; +}; const CLASS = "maplibre-legend-control"; const SWATCH_W = 64; @@ -174,11 +178,11 @@ export function lineShapeFamilyFor(layerId: string): LineShapeFamily { /** Vertical extent of the curves above (all stay within y = 8…18). */ const PATH_EXTENT = 10; /** - * Strokes wider than this (incl. casing gaps and their blur) are scaled down - * together, keeping their ratios: half the stroke lies above/below the curve, - * so curve extent + widest stroke must fit the box height. + * Strokes up to this width (incl. casing gaps and their blur) are drawn as wide + * as on the map, the swatch growing taller for them (curve extent + widest + * stroke); wider stacks are scaled down together, keeping their ratios. */ -const MAX_STROKE = SWATCH_H - PATH_EXTENT; +const MAX_STROKE = 30; /** A blur wider than this stops reading as a soft edge and just washes the swatch out. */ const MAX_BLUR = 3; @@ -214,18 +218,19 @@ let maskSeq = 0; * the gap. The map shows what lies between them — a translucent road over the * hiking band beneath it — so the gap must not be painted in the casing colour. */ -function gapMask(path: SVGElement, d: string, stroke: Stroke, scale: number, scaleX = 1, width = SWATCH_W): SVGElement { +function gapMask(path: SVGElement, d: string, stroke: Stroke, scale: number, scaleX = 1, width = SWATCH_W, top = 0, height = SWATCH_H): SVGElement { const id = `${CLASS}-gap-${++maskSeq}`; const mask = document.createElementNS(SVG_NS, "mask"); mask.setAttribute("id", id); mask.setAttribute("maskUnits", "userSpaceOnUse"); mask.setAttribute("x", "0"); - mask.setAttribute("y", "0"); + mask.setAttribute("y", String(top)); mask.setAttribute("width", String(width)); - mask.setAttribute("height", String(SWATCH_H)); + mask.setAttribute("height", String(height)); const keep = document.createElementNS(SVG_NS, "rect"); + keep.setAttribute("y", String(top)); keep.setAttribute("width", String(width)); - keep.setAttribute("height", String(SWATCH_H)); + keep.setAttribute("height", String(height)); keep.setAttribute("fill", "white"); const cut = document.createElementNS(SVG_NS, "path"); cut.setAttribute("class", `${CLASS}-gap`); @@ -245,14 +250,17 @@ function gapMask(path: SVGElement, d: string, stroke: Stroke, scale: number, sca * Stacked line strokes on a curve of the main layer's shape family (see * {@link LINE_SHAPES}): blur/casing below, the main stroke on top, dash arrays, * caps and blur from the evaluated layers; a casing's gap is masked out, not - * painted. All strokes are scaled together when the widest would not fit, so - * casing and main keep their ratio at every zoom. + * painted. Strokes keep their map width up to {@link MAX_STROKE}, the box + * growing taller around the centred curve; beyond it all strokes are scaled + * together, so casing and main keep their ratio at every zoom. */ type LineSwatchOptions = { /** Wider than the standard box: the bend is stretched to this width, the strokes keep their width. */ width?: number; /** A name is set along the line: the tighter families give way to a gentler bend, as the map labels only its softer curves. */ readable?: boolean; + /** A scale shared with the other lines of the group; never above what this stack needs to fit. */ + scale?: number; }; type LineSwatchParts = { box: HTMLElement; svg?: SVGSVGElement; d: string; scaleX: number; width: number }; @@ -267,22 +275,19 @@ function lineSwatchParts(layers: SwatchLayer[], variant = 0, options: LineSwatch const scaleX = width / SWATCH_W; const strokes = layers.map(strokeOf).filter((s): s is Stroke => Boolean(s)); if (!strokes.length) return { box, d, scaleX, width }; + const scale = Math.min(options.scale ?? 1, fitScale(strokes)); - // A blurred stroke reaches its blur radius beyond its own width on each side - // (a piste casing at z17 is 49 px wide with a 12 px blur), so stroke and blur - // share the budget — otherwise the soft edge is cut off by the box. Below the - // blur cap both scale together; above it the blur is fixed and only the - // stroke has to fit what is left. - const fit = (st: Stroke) => { - const shared = MAX_STROKE / (st.width + 2 * st.blur); - return st.blur * shared <= MAX_BLUR ? shared : (MAX_STROKE - 2 * MAX_BLUR) / st.width; - }; - const scale = Math.min(1, ...strokes.map(fit)); + // a stack wider than the standard box makes it taller; the viewBox grows + // evenly above and below, so the curves (drawn around y = 13) stay centred + const extent = Math.max(...strokes.map((st) => Math.max(0.75, st.width * scale) + 2 * Math.min(st.blur * scale, MAX_BLUR))); + const height = Math.max(SWATCH_H, Math.ceil(extent + PATH_EXTENT)); + const top = -(height - SWATCH_H) / 2; + if (height > SWATCH_H) box.style.setProperty("--legend-control-swatch-height", `${height}px`); const svg = document.createElementNS(SVG_NS, "svg"); - svg.setAttribute("viewBox", `0 0 ${width} ${SWATCH_H}`); + svg.setAttribute("viewBox", `0 ${round2(top)} ${width} ${height}`); svg.setAttribute("width", String(width)); - svg.setAttribute("height", String(SWATCH_H)); + svg.setAttribute("height", String(height)); svg.setAttribute("aria-hidden", "true"); for (const stroke of strokes) { const path = document.createElementNS(SVG_NS, "path"); @@ -308,13 +313,38 @@ function lineSwatchParts(layers: SwatchLayer[], variant = 0, options: LineSwatch } if (stroke.blur > 0) path.style.filter = `blur(${Math.min(stroke.blur * scale, MAX_BLUR)}px)`; stretchX(path, scaleX); - if (stroke.gap > 0) svg.appendChild(gapMask(path, d, stroke, scale, scaleX, width)); + if (stroke.gap > 0) svg.appendChild(gapMask(path, d, stroke, scale, scaleX, width, round2(top), height)); svg.appendChild(path); } box.appendChild(svg); return { box, svg, d, scaleX, width }; } +/** The scale that fits a stack's widest stroke into the box; 1 when everything fits. */ +function fitScale(strokes: Stroke[]): number { + // A blurred stroke reaches its blur radius beyond its own width on each side + // (a piste casing at z17 is 49 px wide with a 12 px blur), so stroke and blur + // share the budget — otherwise the soft edge is cut off by the box. Below the + // blur cap both scale together; above it the blur is fixed and only the + // stroke has to fit what is left. + const fit = (st: Stroke) => { + const shared = MAX_STROKE / (st.width + 2 * st.blur); + return st.blur * shared <= MAX_BLUR ? shared : (MAX_STROKE - 2 * MAX_BLUR) / st.width; + }; + return Math.min(1, ...strokes.map(fit)); +} + +/** + * The scale a line stack needs to fit its swatch (1 for anything but a line). + * The control gives every line of a group the smallest of these, so a minor + * road stays narrower than a major one even when both are wider than the box. + */ +export function lineFitScale(layers: SwatchLayer[]): number { + const main = layers.find((l) => l.role === "main") ?? layers[0]; + if (main?.type !== "line") return 1; + return fitScale(layers.map(strokeOf).filter((s): s is Stroke => Boolean(s))); +} + /** Stretch a shape sideways only: the geometry follows, every stroke keeps its width. */ function stretchX(shape: SVGElement, scaleX: number): void { if (scaleX === 1) return; @@ -331,10 +361,10 @@ function stretchX(shape: SVGElement, scaleX: number): void { export function createLineSwatch(layers: SwatchLayer[], variant = 0, options: RenderOptions = {}): HTMLElement { const label = layers.find((l) => l.type === "symbol" && l.role === "label"); const raw = label ? valueToString(label.layout["text-field"])?.trim() : undefined; - if (!label || !raw) return lineSwatchParts(layers, variant).box; + if (!label || !raw) return lineSwatchParts(layers, variant, { scale: options.lineScale }).box; const text = textStyleFromLayer(label.layout, label.paint); const name = truncateName(raw, text, options.maxNameWidth); - const parts = lineSwatchParts(layers, variant, { width: nameWidth(name, text) + 2 * SYMBOL_REACH, readable: true }); + const parts = lineSwatchParts(layers, variant, { width: nameWidth(name, text) + 2 * SYMBOL_REACH, readable: true, scale: options.lineScale }); if (text.alongLine) setNameAlongLine(parts, name, text); else setNameStraight(parts, name, text); if (parts.width > SWATCH_W) parts.box.style.width = `${parts.width}px`; @@ -803,14 +833,17 @@ export function createSymbolOnSwatch( if (main?.type === "line" && entry.name && entry.text?.alongLine && !entry.icon) { // set along the line inside the SVG, which sizes itself to the name plus the reach const name = truncateName(entry.name, entry.text, options.maxNameWidth); - const parts = lineSwatchParts(entry.anchor.swatch, entry.anchor.variant, { width: nameWidth(name, entry.text) + 2 * SYMBOL_REACH, readable: true }); + const parts = lineSwatchParts(entry.anchor.swatch, entry.anchor.variant, { width: nameWidth(name, entry.text) + 2 * SYMBOL_REACH, readable: true, scale: options.lineScale }); setNameAlongLine(parts, name, entry.text); box.classList.add(`${CLASS}-symbol-on-path`); box.appendChild(parts.box); return box; } - const base = createSwatch(entry.anchor.swatch, getImage, entry.anchor.variant); + const base = createSwatch(entry.anchor.swatch, getImage, entry.anchor.variant, { lineScale: options.lineScale }); + // a wide road made its swatch taller: the box (sized by the symbol, at least a swatch high) follows + const tall = base.style.getPropertyValue("--legend-control-swatch-height"); + if (tall) box.style.setProperty("--legend-control-swatch-height", tall); const svg = base.querySelector("svg"); if (svg) { svg.setAttribute("preserveAspectRatio", "none"); diff --git a/test/swatch.test.ts b/test/swatch.test.ts index 0bb1ccb..cf93979 100644 --- a/test/swatch.test.ts +++ b/test/swatch.test.ts @@ -10,6 +10,7 @@ import { pathMidTangent, truncateName, FILL_SHAPES, + lineFitScale, lineShapeFamilyFor, LINE_SHAPES, swatchVariantFor, @@ -137,12 +138,25 @@ describe("wide strokes", () => { layout: { "line-cap": "round" }, }); - it("scales the stack so the widest stroke plus the curve fits the box, keeping ratios", () => { + const widths = (box: HTMLElement) => [...box.querySelectorAll("path.maplibre-legend-control-stroke")].map((p) => Number(p.getAttribute("stroke-width"))); + + it("draws a stack up to 30 px as wide as the map, the swatch growing taller around the centred curve", () => { // z18 motorway: 24 px main, casing 2 px around a 24 px gap = 28 px - const svg = createLineSwatch([mk("casing", "casing", 2, 24), mk("main", "main", 24)]); - const widths = [...svg.querySelectorAll("path.maplibre-legend-control-stroke")].map((p) => Number(p.getAttribute("stroke-width"))); - expect(Math.max(...widths)).toBeCloseTo(16, 5); // 26 px box − 10 px curve extent - expect(widths[1] / widths[0]).toBeCloseTo(24 / 28, 5); + const box = createLineSwatch([mk("casing", "casing", 2, 24), mk("main", "main", 24)]); + expect(widths(box)).toEqual([28, 24]); + const svg = box.querySelector("svg")!; + expect(svg.getAttribute("height")).toBe("38"); // 28 px stroke + 10 px curve extent + expect(svg.getAttribute("viewBox")).toBe("0 -6 64 38"); // grown evenly above and below + expect(box.style.getPropertyValue("--legend-control-swatch-height")).toBe("38px"); + }); + + it("scales a stack wider than 30 px down, keeping ratios", () => { + // casing 2 px around a 40 px gap = 44 px + const box = createLineSwatch([mk("casing", "casing", 2, 40), mk("main", "main", 40)]); + const [casing, main] = widths(box); + expect(casing).toBeCloseTo(30, 5); + expect(main / casing).toBeCloseTo(40 / 44, 5); + expect(box.querySelector("svg")!.getAttribute("height")).toBe("40"); }); it("keeps a casing's gap see-through instead of painting it in the casing colour", () => { @@ -150,9 +164,11 @@ describe("wide strokes", () => { const casing = svg.querySelector("path.maplibre-legend-control-stroke-casing") as SVGPathElement; const ref = casing.getAttribute("mask")?.match(/^url\(#(.+)\)$/)?.[1]; expect(ref).toBeTruthy(); - const cut = svg.querySelector(`mask[id="${ref}"] path`) as SVGPathElement; + const mask = svg.querySelector(`mask[id="${ref}"]`)!; + expect([mask.getAttribute("y"), mask.getAttribute("height")]).toEqual(["-6", "38"]); // covers the grown box + const cut = mask.querySelector("path") as SVGPathElement; expect(cut.getAttribute("stroke")).toBe("black"); - expect(Number(cut.getAttribute("stroke-width"))).toBeCloseTo((24 * 16) / 28, 5); // the gap, scaled like the strokes + expect(Number(cut.getAttribute("stroke-width"))).toBeCloseTo(24, 5); // the gap, as wide as on the map expect(svg.querySelector("path.maplibre-legend-control-stroke-main")?.getAttribute("mask")).toBeNull(); }); @@ -162,13 +178,32 @@ describe("wide strokes", () => { const [casing, main] = [...svg.querySelectorAll("path.maplibre-legend-control-stroke")] as SVGPathElement[]; const width = Number(casing.getAttribute("stroke-width")); const blur = Number(casing.style.filter.match(/blur\(([\d.]+)px\)/)![1]); - expect(width + 2 * blur).toBeCloseTo(16, 5); // stroke and its soft edge together fit the 26 px box under the 10 px curve + expect(width + 2 * blur).toBeCloseTo(30, 5); // stroke and its soft edge together stay within the 30 px cap expect(Number(main.getAttribute("stroke-width")) / width).toBeCloseTo(36 / 48, 5); // ratios kept }); - it("leaves narrow stacks unscaled", () => { - const svg = createLineSwatch([mk("main", "main", 3)]); - expect(svg.querySelector("path")?.getAttribute("stroke-width")).toBe("3"); + it("leaves narrow stacks unscaled in the standard box", () => { + const box = createLineSwatch([mk("main", "main", 3)]); + expect(box.querySelector("path")?.getAttribute("stroke-width")).toBe("3"); + expect(box.querySelector("svg")!.getAttribute("viewBox")).toBe("0 0 64 26"); + expect(box.style.getPropertyValue("--legend-control-swatch-height")).toBe(""); + }); + + it("scales the lines of a group together, so a minor road stays narrower than a major one", () => { + const major = [mk("casing", "casing", 2, 40), mk("main", "main", 40)]; // 44 px + const minor = [mk("main", "main", 20)]; + const lineScale = Math.min(lineFitScale(major), lineFitScale(minor)); + expect(lineScale).toBeCloseTo(30 / 44, 5); // the widest decides + const widest = (layers: ReturnType[]) => Math.max(...widths(createLineSwatch(layers, 0, { lineScale }))); + expect(widest(major)).toBeCloseTo(30, 5); + expect(widest(minor)).toBeCloseTo((20 * 30) / 44, 5); // not 20 as on its own + // a shared scale never widens a stack beyond what fits its own box + expect(Math.max(...widths(createLineSwatch(major, 0, { lineScale: 1 })))).toBeCloseTo(30, 5); + }); + + it("gives anything but a line the neutral fit scale", () => { + expect(lineFitScale([{ id: "f", type: "fill", role: "main", order: 0, paint: { "fill-color": "red" }, layout: {} }])).toBe(1); + expect(lineFitScale([])).toBe(1); }); });