diff --git a/.changeset/english-errors.md b/.changeset/english-errors.md new file mode 100644 index 0000000..da2fdd7 --- /dev/null +++ b/.changeset/english-errors.md @@ -0,0 +1,5 @@ +--- +"@maptoolkit/elevation-profile": patch +--- + +Error messages are in English diff --git a/.changeset/larger-svg-padding.md b/.changeset/larger-svg-padding.md new file mode 100644 index 0000000..b8ba169 --- /dev/null +++ b/.changeset/larger-svg-padding.md @@ -0,0 +1,5 @@ +--- +"@maptoolkit/elevation-profile": patch +--- + +Increase the padding around the plot so larger label font sizes are not clipped diff --git a/.changeset/selection-line.md b/.changeset/selection-line.md new file mode 100644 index 0000000..65a58c1 --- /dev/null +++ b/.changeset/selection-line.md @@ -0,0 +1,5 @@ +--- +"@maptoolkit/elevation-profile": minor +--- + +Add a `selection-line` element to style the part of the line within the selection diff --git a/.changeset/svg-size-options.md b/.changeset/svg-size-options.md new file mode 100644 index 0000000..d5117c1 --- /dev/null +++ b/.changeset/svg-size-options.md @@ -0,0 +1,5 @@ +--- +"@maptoolkit/elevation-profile": minor +--- + +Add `width` and `height` options to set the SVG viewBox size and aspect ratio diff --git a/README.md b/README.md index 9cea92e..9cebfea 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ const profile = new ElevationProfile("profile", { units: "metric" }); profile.render(feature); ``` -The first argument is the container element or its id. The element may be added to the DOM after `render()`. The profile fills the container's width; its height follows from the aspect ratio of the SVG (900 × 320). Calling `render()` again replaces the profile and clears any selection. +The first argument is the container element or its id. The element may be added to the DOM after `render()`. The profile fills the container's width; its height follows from the aspect ratio of the SVG (`width` × `height`, default 900 × 320). Calling `render()` again replaces the profile and clears any selection. ### Without a bundler @@ -111,6 +111,8 @@ profile.render({ | -------- | -------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `units` | `"metric"` \| `"imperial"` | `"metric"` | Axis labels in m/km or ft/mi. With `"imperial"`, all lengths in events and methods are in feet instead of meters. | | `locale` | `Record` | see below | Hover badge labels for section values, keyed `"
."` with the raw values from the data. Merged with the defaults. Values without an entry are shown as is. | +| `width` | `number` | `900` | Width of the SVG viewBox. Together with `height` it sets the aspect ratio. Fonts and margins are in viewBox units, so a smaller viewBox makes the text relatively larger. | +| `height` | `number` | `320` | Height of the SVG viewBox. | The default `locale` has English labels for `surface.*` (`asphalt`, `paved`, `unpaved`, `natural`, `alpine`, `other`) and `highway.*` values, see `defaultElevationProfileOptions` in `src/ElevationProfile.ts`: @@ -189,6 +191,7 @@ Classes, shown without the `maptoolkit-elevation-profile-` prefix: | `hover-line`, `hover-dot` | Hover point, following the mouse. | | `hover-badge` | Badge with `hover-badge-bg`, `hover-badge-text` and `hover-badge-dot`. | | `selection-rect` | Drag selection. | +| `selection-line` | The line within the selection, on top of `line`. | | `section-` | One section bar. | | `section-value`, `section--` | A bar segment and the matching badge dot. | diff --git a/src/ElevationProfile.ts b/src/ElevationProfile.ts index 3ee0e2a..3378f1f 100644 --- a/src/ElevationProfile.ts +++ b/src/ElevationProfile.ts @@ -36,10 +36,16 @@ export interface ElevationProfileOptions { * Merged with the defaults, so single entries can be overridden. */ locale: Record; + /** SVG viewBox width. With height it sets the aspect ratio; fonts and margins are in these units. */ + width: number; + /** SVG viewBox height. */ + height: number; } export const defaultElevationProfileOptions: ElevationProfileOptions = { units: "metric", + width: 900, + height: 320, locale: { "surface.asphalt": "Asphalt", "surface.paved": "Paved", @@ -223,6 +229,8 @@ export class ElevationProfile { private readonly container: HTMLElement; private readonly unit: UnitSystem; private readonly locale: Record; + private readonly width: number; + private readonly height: number; private readonly clipId: string; // Drag selection stays visible until a new drag or a plain click. @@ -238,16 +246,21 @@ export class ElevationProfile { // container: element or element id to render into. It may be added to the DOM later. constructor(container: HTMLElement | string, options: Partial = {}) { const element = typeof container === "string" ? document.getElementById(container) : container; - if (!element) throw new Error(`Element #${container} nicht gefunden.`); + if (!element) throw new Error(`Element #${container} not found.`); this.container = element; // Scopes the styles in style.css. this.container.classList.add(PREFIX); - const { units } = { ...defaultElevationProfileOptions, ...options }; + const { units, width, height } = { ...defaultElevationProfileOptions, ...options }; const locale = { ...defaultElevationProfileOptions.locale, ...options.locale }; const unit = ElevationProfile.UNIT_SYSTEMS[units]; - if (!unit) throw new Error(`Unbekanntes Masssystem "${units}".`); + if (!unit) throw new Error(`Unknown units "${units}".`); this.unit = unit; this.locale = locale; + for (const [name, value] of Object.entries({ width, height })) { + if (!(Number.isFinite(value) && value > 0)) throw new Error(`Invalid ${name} "${value}".`); + } + this.width = width; + this.height = height; this.clipId = `${PREFIX}-clip-${++ElevationProfile.instanceCount}`; } @@ -375,7 +388,7 @@ export class ElevationProfile { let lines: Position[][]; if (geometry.type === "LineString") lines = [geometry.coordinates || []]; else if (geometry.type === "MultiLineString") lines = geometry.coordinates || []; - else throw new Error(`Nicht unterstützter Geometrie-Typ "${(geometry as { type?: string }).type}".`); + else throw new Error(`Unsupported geometry type "${(geometry as { type?: string }).type}".`); const samples: number[] = []; const coordinates: Position[] = []; let distances: number[] = []; @@ -385,7 +398,7 @@ export class ElevationProfile { lines.forEach((line) => { const start = distance; line.forEach((c, i) => { - if (typeof c[2] !== "number") throw new Error(`Koordinate ${samples.length} hat keinen Höhenwert.`); + if (typeof c[2] !== "number") throw new Error(`Coordinate ${samples.length} has no elevation.`); if (i > 0) distance += ElevationProfile.distanceFromTo(line[i - 1], c); samples.push(c[2]); coordinates.push([c[0], c[1]]); @@ -439,27 +452,26 @@ export class ElevationProfile { // Hover line and selection are clipped to the area under the curve. const clipId = this.clipId; const { samples, distances, distance } = profile; - if (!samples.length) throw new Error("Keine Höhen-Samples in der Antwort gefunden."); + if (!samples.length) throw new Error("No elevation samples found."); - const width = 900, - height = 320; - const sectionBar = { height: 10, gap: 4, segmentGap: 2 }; + const { width, height } = this; + const sectionBar = { height: 16, gap: 6, plotGap: 8, segmentGap: 2 }; // One bar per section below the plot, in data order. // The plot grows or shrinks by the bars' height, the x labels stay in place. const barCount = sections.length; - const barsHeight = barCount * (sectionBar.gap + sectionBar.height); - const margin: Margin = { top: 16, right: 20, bottom: 40 + (barCount - 1) * (sectionBar.gap + sectionBar.height), left: 70 }; - // Hover badge, top right in the plot. Sizes in viewBox units, font-size must match the CSS. + const barsHeight = barCount ? sectionBar.plotGap + barCount * sectionBar.height + (barCount - 1) * sectionBar.gap : 0; + const margin: Margin = { top: 36, right: 24, bottom: 36 + barsHeight, left: 94 }; + // Hover badge, in the top right corner of the SVG. Sizes in viewBox units, font-size must match the CSS. const badge: Badge = { - right: width - margin.right - 8, - top: margin.top + 8, + right: width - 15, + top: 15, padX: 10, padY: 4, radius: 6, fontSize: 18, - lineHeight: 27, + lineHeight: 31, dotRadius: 8, - dotGap: 4, + dotGap: 8, }; const plotW = width - margin.left - margin.right; const plotH = height - margin.top - margin.bottom; @@ -554,7 +566,7 @@ export class ElevationProfile { }; const sectionBars = sections .map((section, i) => { - const barY = margin.top + plotH + sectionBar.gap + i * (sectionBar.height + sectionBar.gap); + const barY = margin.top + plotH + sectionBar.plotGap + i * (sectionBar.height + sectionBar.gap); const rects = barRects(section.runs, barY, (value) => ElevationProfile.sectionValueClass(section.id, value)); return `\n${rects}\n `; }) @@ -576,7 +588,7 @@ export class ElevationProfile { const xx = x(t / distFactor).toFixed(1); // The line at 0 is the y axis. return ` - ${t} ${unit.distance.unit}`; + ${t} ${unit.distance.unit}`; }) .join("\n"); @@ -595,6 +607,9 @@ export class ElevationProfile { + + + ${gridLinesY} ${gridLinesX} @@ -603,6 +618,7 @@ export class ElevationProfile { ${sectionBars} ${markers} + @@ -683,6 +699,8 @@ export class ElevationProfile { const hoverLine = svgEl.querySelector(`.${cls("hover-line")}`)!; const hoverDot = svgEl.querySelector(`.${cls("hover-dot")}`)!; const selectionRect = svgEl.querySelector(`.${cls("selection-rect")}`)!; + const selectionLine = svgEl.querySelector(`.${cls("selection-line")}`)!; + const selectionClip = svgEl.querySelector(`.${cls("selection-clip")}`)!; const badgeEl = svgEl.querySelector(`.${cls("hover-badge")}`)!; const badgeBg = badgeEl.querySelector(`.${cls("hover-badge-bg")}`)!; const badgeText = badgeEl.querySelector(`.${cls("hover-badge-text")}`)!; @@ -723,6 +741,8 @@ export class ElevationProfile { badgeBg.setAttribute("y", String(top - badge.padY)); badgeBg.setAttribute("width", String(badge.right - (left - badge.padX))); badgeBg.setAttribute("height", String(bottom - top + 2 * badge.padY)); + // Move the badge so its top is at badge.top whatever the CSS font-size. + badgeEl.setAttribute("transform", `translate(0 ${badge.top - (top - badge.padY)})`); }; const meterAt = (evt: MouseEvent): number => { @@ -782,6 +802,10 @@ export class ElevationProfile { selectionRect.setAttribute("x", String(x1)); selectionRect.setAttribute("width", String(Math.max(0, x2 - x1))); selectionRect.style.display = "block"; + // The line is clipped to the selected x range only, so its stroke isn't cut at the top. + selectionClip.setAttribute("x", String(x1)); + selectionClip.setAttribute("width", String(Math.max(0, x2 - x1))); + selectionLine.style.display = "block"; // Ascent/descent from the nearest samples, like the hover badge. const { ascent, descent } = ElevationProfile.ascentDescentBetween( @@ -801,6 +825,7 @@ export class ElevationProfile { const clearSelection = () => { this.selection = null; selectionRect.style.display = "none"; + selectionLine.style.display = "none"; badgeEl.style.display = "none"; }; diff --git a/src/style.css b/src/style.css index 3290eea..49699e0 100644 --- a/src/style.css +++ b/src/style.css @@ -69,10 +69,18 @@ display: none; } .maptoolkit-elevation-profile .maptoolkit-elevation-profile-selection-rect { - fill: var(--maptoolkit-elevation-profile-line); - fill-opacity: 0.15; - stroke: var(--maptoolkit-elevation-profile-line); - stroke-width: 1; + fill: var(--maptoolkit-elevation-profile-accent); + fill-opacity: 0.4; + stroke: var(--maptoolkit-elevation-profile-accent); + stroke-width: 0; + display: none; +} +.maptoolkit-elevation-profile .maptoolkit-elevation-profile-selection-line { + fill: none; + stroke: var(--maptoolkit-elevation-profile-accent); + stroke-width: 6; + stroke-linejoin: round; + stroke-linecap: round; display: none; } .maptoolkit-elevation-profile .maptoolkit-elevation-profile-hover-dot { diff --git a/test/ElevationProfile.test.ts b/test/ElevationProfile.test.ts index 4e36b1d..eaf52f2 100644 --- a/test/ElevationProfile.test.ts +++ b/test/ElevationProfile.test.ts @@ -8,9 +8,9 @@ import { } from "../src/ElevationProfile"; // jsdom has no SVG layout. With an identity screen CTM, clientX equals the viewBox x, -// so the plot (viewBox 900 wide, margin left 70 / right 20) spans clientX 70..880. -const PLOT_LEFT = 70; -const PLOT_WIDTH = 810; +// so the plot (viewBox 900 wide, margin left 94 / right 24) spans clientX 94..876. +const PLOT_LEFT = 94; +const PLOT_WIDTH = 782; const FT = 0.3048; type SvgProto = { createSVGPoint?: unknown; getScreenCTM?: unknown }; @@ -123,11 +123,16 @@ describe("constructor", () => { }); it("throws for an unknown element", () => { - expect(() => new ElevationProfile("missing")).toThrow(/#missing nicht gefunden/); + expect(() => new ElevationProfile("missing")).toThrow(/#missing not found/); }); it("throws for unknown units", () => { - expect(() => new ElevationProfile("profile", { units: "nautical" as never })).toThrow(/Masssystem "nautical"/); + expect(() => new ElevationProfile("profile", { units: "nautical" as never })).toThrow(/units "nautical"/); + }); + + it("throws for an invalid width or height", () => { + expect(() => new ElevationProfile("profile", { width: 0 })).toThrow(/width "0"/); + expect(() => new ElevationProfile("profile", { height: NaN })).toThrow(/height "NaN"/); }); it("accepts an element", () => { @@ -186,9 +191,18 @@ describe("not rendered container", () => { describe("render", () => { it("throws for unsupported geometry, missing elevation and empty lines", () => { const profile = new ElevationProfile("profile"); - expect(() => profile.render({ geometry: { type: "Point" } } as never)).toThrow(/Geometrie-Typ "Point"/); - expect(() => profile.render({ geometry: { type: "LineString", coordinates: [[0, 0]] } })).toThrow(/keinen Höhenwert/); - expect(() => profile.render({ geometry: { type: "LineString", coordinates: [] } })).toThrow(/Keine Höhen-Samples/); + expect(() => profile.render({ geometry: { type: "Point" } } as never)).toThrow(/geometry type "Point"/); + expect(() => profile.render({ geometry: { type: "LineString", coordinates: [[0, 0]] } })).toThrow(/has no elevation/); + expect(() => profile.render({ geometry: { type: "LineString", coordinates: [] } })).toThrow(/No elevation samples/); + }); + + it("uses width and height as viewBox", () => { + create(); + expect($("svg").getAttribute("viewBox")).toBe("0 0 900 320"); + create({ width: 600, height: 300 }); + expect($("svg").getAttribute("viewBox")).toBe("0 0 600 300"); + // Plot from margin left 94 to width - margin right 24. + expect($(".maptoolkit-elevation-profile-hover-capture").getAttribute("width")).toBe("482"); }); it("renders metric axis labels", () => { @@ -229,8 +243,9 @@ describe("render", () => { new ElevationProfile("a").render(lineFeature()); new ElevationProfile("b").render(lineFeature()); const ids = Array.from(document.querySelectorAll("clipPath")).map((el) => el.id); - expect(new Set(ids).size).toBe(2); - ids.forEach((id) => expect(id).toMatch(/^maptoolkit-elevation-profile-clip-\d+$/)); + // Two per instance: area and selection. + expect(new Set(ids).size).toBe(4); + ids.forEach((id) => expect(id).toMatch(/^maptoolkit-elevation-profile-clip-\d+(-selection)?$/)); }); it("prefixes all classes", () => { @@ -519,6 +534,13 @@ describe("methods", () => { const profile = create(); expect(profile.setSelection(750, 250)).toEqual({ ascent: 60, descent: 30 }); expect($(".maptoolkit-elevation-profile-selection-rect").style.display).toBe("block"); + expect($(".maptoolkit-elevation-profile-selection-line").style.display).toBe("block"); + const clip = $(".maptoolkit-elevation-profile-selection-clip"); + const rect = $(".maptoolkit-elevation-profile-selection-rect"); + expect(clip.getAttribute("x")).toBe(rect.getAttribute("x")); + expect(clip.getAttribute("width")).toBe(rect.getAttribute("width")); + profile.clearSelection(); + expect($(".maptoolkit-elevation-profile-selection-line").style.display).toBe("none"); }); it("setSelection clamps to the profile", () => {