Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/line-widths-as-on-map.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/rows-keep-content-height.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -161,7 +164,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
Expand Down
9 changes: 7 additions & 2 deletions src/LegendControl.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
Expand Down
6 changes: 5 additions & 1 deletion src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
89 changes: 61 additions & 28 deletions src/swatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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`);
Expand All @@ -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 };

Expand All @@ -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");
Expand All @@ -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;
Expand All @@ -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`;
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading