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/english-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@maptoolkit/elevation-profile": patch
---

Error messages are in English
5 changes: 5 additions & 0 deletions .changeset/larger-svg-padding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@maptoolkit/elevation-profile": patch
---

Increase the padding around the plot so larger label font sizes are not clipped
5 changes: 5 additions & 0 deletions .changeset/selection-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@maptoolkit/elevation-profile": minor
---

Add a `selection-line` element to style the part of the line within the selection
5 changes: 5 additions & 0 deletions .changeset/svg-size-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@maptoolkit/elevation-profile": minor
---

Add `width` and `height` options to set the SVG viewBox size and aspect ratio
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<string, string>` | see below | Hover badge labels for section values, keyed `"<section id>.<value>"` 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`:

Expand Down Expand Up @@ -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-<id>` | One section bar. |
| `section-value`, `section-<id>-<value>` | A bar segment and the matching badge dot. |

Expand Down
61 changes: 43 additions & 18 deletions src/ElevationProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,16 @@ export interface ElevationProfileOptions {
* Merged with the defaults, so single entries can be overridden.
*/
locale: Record<string, string>;
/** 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",
Expand Down Expand Up @@ -223,6 +229,8 @@ export class ElevationProfile {
private readonly container: HTMLElement;
private readonly unit: UnitSystem;
private readonly locale: Record<string, string>;
private readonly width: number;
private readonly height: number;
private readonly clipId: string;

// Drag selection stays visible until a new drag or a plain click.
Expand All @@ -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<ElevationProfileOptions> = {}) {
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}`;
}

Expand Down Expand Up @@ -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[] = [];
Expand All @@ -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]]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 `<g class="${cls("section", `section-${ElevationProfile.cssName(section.id)}`)}">\n${rects}\n </g>`;
})
Expand All @@ -576,7 +588,7 @@ export class ElevationProfile {
const xx = x(t / distFactor).toFixed(1);
// The line at 0 is the y axis.
return `<line class="${cls("grid-line", "grid-line-x", ...(i === 0 ? ["axis-line", "axis-line-y"] : []))}" x1="${xx}" x2="${xx}" y1="${margin.top}" y2="${margin.top + plotH}" />
<text class="${cls("axis-label", "axis-label-x")}" x="${xx}" y="${margin.top + plotH + barsHeight + 7}" text-anchor="middle" dominant-baseline="hanging">${t} ${unit.distance.unit}</text>`;
<text class="${cls("axis-label", "axis-label-x")}" x="${xx}" y="${margin.top + plotH + barsHeight + 11}" text-anchor="middle" dominant-baseline="hanging">${t} ${unit.distance.unit}</text>`;
})
.join("\n");

Expand All @@ -595,6 +607,9 @@ export class ElevationProfile {
<clipPath id="${clipId}">
<path d="${areaPath}" />
</clipPath>
<clipPath id="${clipId}-selection">
<rect class="${cls("selection-clip")}" y="0" height="${height}" />
</clipPath>
</defs>
${gridLinesY}
${gridLinesX}
Expand All @@ -603,6 +618,7 @@ export class ElevationProfile {
${sectionBars}
${markers}
<rect class="${cls("selection-rect")}" y="${margin.top}" height="${plotH}" clip-path="url(#${clipId})" />
<path class="${cls("selection-line")}" d="${linePath}" clip-path="url(#${clipId}-selection)" />
<line class="${cls("hover-line")}" x1="0" x2="0" y1="${margin.top}" y2="${margin.top + plotH}" clip-path="url(#${clipId})" />
<circle class="${cls("hover-dot")}" r="4" />
<rect class="${cls("hover-capture")}" x="${margin.left}" y="${margin.top}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor: default;" />
Expand Down Expand Up @@ -683,6 +699,8 @@ export class ElevationProfile {
const hoverLine = svgEl.querySelector<SVGLineElement>(`.${cls("hover-line")}`)!;
const hoverDot = svgEl.querySelector<SVGCircleElement>(`.${cls("hover-dot")}`)!;
const selectionRect = svgEl.querySelector<SVGRectElement>(`.${cls("selection-rect")}`)!;
const selectionLine = svgEl.querySelector<SVGPathElement>(`.${cls("selection-line")}`)!;
const selectionClip = svgEl.querySelector<SVGRectElement>(`.${cls("selection-clip")}`)!;
const badgeEl = svgEl.querySelector<SVGGElement>(`.${cls("hover-badge")}`)!;
const badgeBg = badgeEl.querySelector<SVGRectElement>(`.${cls("hover-badge-bg")}`)!;
const badgeText = badgeEl.querySelector<SVGTextElement>(`.${cls("hover-badge-text")}`)!;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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(
Expand All @@ -801,6 +825,7 @@ export class ElevationProfile {
const clearSelection = () => {
this.selection = null;
selectionRect.style.display = "none";
selectionLine.style.display = "none";
badgeEl.style.display = "none";
};

Expand Down
16 changes: 12 additions & 4 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
42 changes: 32 additions & 10 deletions test/ElevationProfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -519,6 +534,13 @@ describe("methods", () => {
const profile = create();
expect(profile.setSelection(750, 250)).toEqual({ ascent: 60, descent: 30 });
expect($<SVGRectElement>(".maptoolkit-elevation-profile-selection-rect").style.display).toBe("block");
expect($<SVGPathElement>(".maptoolkit-elevation-profile-selection-line").style.display).toBe("block");
const clip = $<SVGRectElement>(".maptoolkit-elevation-profile-selection-clip");
const rect = $<SVGRectElement>(".maptoolkit-elevation-profile-selection-rect");
expect(clip.getAttribute("x")).toBe(rect.getAttribute("x"));
expect(clip.getAttribute("width")).toBe(rect.getAttribute("width"));
profile.clearSelection();
expect($<SVGPathElement>(".maptoolkit-elevation-profile-selection-line").style.display).toBe("none");
});

it("setSelection clamps to the profile", () => {
Expand Down
Loading