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
7 changes: 7 additions & 0 deletions .changeset/event-geometry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@maptoolkit/elevation-profile": minor
---

Replace `lngLat` in the `hover` and `click` events with `geometry`, a GeoJSON `Point`. Its coordinates are `[lng, lat, ele]`, so use `e.geometry.coordinates.slice(0, 2)` instead of `e.lngLat`.

Add `geometry` to the `selection` and `selectionend` events: the selected stretch of the line as `LineString` or `MultiLineString`, like the input geometry. Coordinates are `[lng, lat, ele]` with `ele` in meters, interpolated on the line.
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,24 +128,28 @@ new ElevationProfile("profile", {
## Events

```js
profile.on("hover", (e) => marker.setLngLat(e.lngLat));
profile.on("hover", (e) => {
const [lng, lat] = e.geometry.coordinates;
marker.setLngLat([lng, lat]);
});
profile.on("hoverend", () => marker.remove());
profile.on("selectionend", (e) => console.log(e.start, e.end, e.ascent, e.descent));
```

`on(type, listener)`, `once(type, listener)` and `off(type, listener)` work like in MapLibre and return the profile. Every event has `type`, `target` (the profile) and `originalEvent` (the `MouseEvent`).

| Event | Data | Fired when |
| ---------------- | ------------------------------------------- | ---------------------------------------------------------------------- |
| `hover` | `{ distance, elevation, lngLat, sections }` | The mouse moves over the plot, while there is no selection. |
| `hoverend` | — | The mouse leaves the plot or a drag starts, once per hover. |
| `click` | `{ distance, elevation, lngLat, sections }` | Click without drag, while there is no selection. |
| `selection` | `{ start, end, ascent, descent }` | While dragging a selection. |
| `selectionend` | `{ start, end, ascent, descent }` | A drag selection ends. |
| `selectionclear` | — | Click without drag while a selection exists. No `click` is fired then. |
| Event | Data | Fired when |
| ---------------- | --------------------------------------------- | ---------------------------------------------------------------------- |
| `hover` | `{ distance, elevation, geometry, sections }` | The mouse moves over the plot, while there is no selection. |
| `hoverend` | — | The mouse leaves the plot or a drag starts, once per hover. |
| `click` | `{ distance, elevation, geometry, sections }` | Click without drag, while there is no selection. |
| `selection` | `{ start, end, ascent, descent, geometry }` | While dragging a selection. |
| `selectionend` | `{ start, end, ascent, descent, geometry }` | A drag selection ends. |
| `selectionclear` | — | Click without drag while a selection exists. No `click` is fired then. |

- `distance`, `elevation`, `start`, `end`, `ascent` and `descent` are in meters, or feet with `units: "imperial"`. Values are not rounded.
- `elevation` is the nearest sample (same as the badge); `lngLat` (`[lng, lat]`) is interpolated on the line.
- `elevation` is the nearest sample (same as the badge).
- `geometry` is the location as GeoJSON geometry with `[lng, lat, ele]` coordinates, `ele` in meters like the input (also with `units: "imperial"`) and interpolated on the line: a `Point` interpolated on the line for `hover` and `click`, the selected stretch of the line for `selection` and `selectionend`. The stretch has the type of the input geometry (`LineString` or `MultiLineString`, one line per part it covers).
- `sections` maps each section id to its raw value at that point, `"other"` if not covered.
- `start <= end`, regardless of the drag direction.

Expand Down
83 changes: 67 additions & 16 deletions src/ElevationProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
// Badge labels: options.locale["<id>.<value>"], else the raw value.
//
// Events (profile.on/off/once), all distances and elevations in meters (feet for imperial):
// hover, click: { distance, elevation, lngLat, sections }
// selection (while dragging), selectionend: { start, end, ascent, descent }
// hover, click: { distance, elevation, geometry (Point, interpolated on the line), sections }
// selection (while dragging), selectionend: { start, end, ascent, descent, geometry }
// (geometry of the selected stretch: LineString or MultiLineString like the input)
// geometry coordinates are [lng, lat, ele], ele in meters like the input and interpolated on the line
// hoverend, selectionclear: no data
// A click that clears a selection fires selectionclear, not click.
// showHoverAt/hideHover/setSelection/clearSelection fire no events and use the same unit.
Expand Down Expand Up @@ -95,13 +97,18 @@ export interface ElevationProfileFeature {
} | null;
}

/** GeoJSON geometries of event locations, [lng, lat, ele] with ele in meters like the input. */
export type ElevationProfilePointGeometry = { type: "Point"; coordinates: [number, number, number] };
export type ElevationProfileLineGeometry =
{ type: "LineString"; coordinates: [number, number, number][] } | { type: "MultiLineString"; coordinates: [number, number, number][][] };

/** A point on the profile, distance and elevation in meters (feet for imperial). */
export interface ElevationProfilePoint {
distance: number;
/** Nearest sample, like the badge. */
elevation: number;
/** Interpolated on the line. */
lngLat: [number, number];
geometry: ElevationProfilePointGeometry;
/** Raw value per section id, "other" if uncovered. */
sections: Record<string, string>;
}
Expand All @@ -112,6 +119,8 @@ export interface ElevationProfileSelection {
end: number;
ascent: number;
descent: number;
/** The selected stretch of the line, same type as the input geometry. */
geometry: ElevationProfileLineGeometry;
}

export interface ElevationProfileEventMap {
Expand Down Expand Up @@ -143,8 +152,11 @@ interface UnitSystem {
interface Profile {
samples: number[];
distances: number[];
// [lng, lat] per sample.
// [lng, lat, ele] per sample.
coordinates: Position[];
// First and last sample index of each part (one part for a LineString).
partIndices: [number, number][];
multiLine: boolean;
distance: number;
}

Expand Down Expand Up @@ -175,6 +187,8 @@ interface SvgMeta {
distances: number[];
samples: number[];
coordinates: Position[];
partIndices: [number, number][];
multiLine: boolean;
yTicks: { min: number; max: number };
sections: Section[];
badge: Badge;
Expand Down Expand Up @@ -395,13 +409,15 @@ export class ElevationProfile {
let distance = 0;
// Start/end distance of each part, for part-relative section runs.
const parts: { start: number; end: number }[] = [];
const partIndices: [number, number][] = [];
lines.forEach((line) => {
const start = distance;
if (line.length) partIndices.push([samples.length, samples.length + line.length - 1]);
line.forEach((c, i) => {
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]]);
coordinates.push([c[0], c[1], c[2]]);
distances.push(distance);
});
parts.push({ start, end: distance });
Expand Down Expand Up @@ -438,7 +454,8 @@ export class ElevationProfile {
});
});
const sections = Array.from(sectionsById, ([id, runs]) => ({ id, runs }));
return { profile: { samples, distances, coordinates, distance }, sections };
const multiLine = geometry.type === "MultiLineString";
return { profile: { samples, distances, coordinates, partIndices, multiLine, distance }, sections };
}

// Section value at a distance, "other" if none.
Expand Down Expand Up @@ -631,8 +648,11 @@ export class ElevationProfile {
</svg>`;

// Needed by setupInteraction() to map mouse position to samples.
const { coordinates } = profile;
return { svg, meta: { margin, plotW, plotH, safeDistance, distances, samples, coordinates, yTicks, sections, badge } };
const { coordinates, partIndices, multiLine } = profile;
return {
svg,
meta: { margin, plotW, plotH, safeDistance, distances, samples, coordinates, partIndices, multiLine, yTicks, sections, badge },
};
}

// Binary search for the sample closest to "meter".
Expand Down Expand Up @@ -670,13 +690,40 @@ export class ElevationProfile {
return samples[hi - 1] + (samples[hi] - samples[hi - 1]) * t;
}

// [lng, lat] on the line at "meter". Across MultiLineString part gaps it jumps to the next part.
private static interpolateLngLat(distances: number[], coordinates: Position[], meter: number): [number, number] {
// [lng, lat, ele] on the line at "meter". Across MultiLineString part gaps it jumps to the next part.
private static interpolatePosition(distances: number[], coordinates: Position[], meter: number): [number, number, number] {
const { hi, t } = ElevationProfile.interpolationAt(distances, meter);
const b = coordinates[hi];
if (hi === 0) return [b[0], b[1]];
if (hi === 0) return [b[0], b[1], b[2]];
const a = coordinates[hi - 1];
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}

// The stretch of the line between "from" and "to" meters (from <= to), one array per part it touches.
// Parts are cut separately, so a stretch never bridges the gap between two parts.
private static lineBetween(
distances: number[],
coordinates: Position[],
partIndices: [number, number][],
from: number,
to: number,
): [number, number, number][][] {
const lines: [number, number, number][][] = [];
partIndices.forEach(([first, last]) => {
const lo = Math.max(from, distances[first]);
const hi = Math.min(to, distances[last]);
// Skip parts the stretch doesn't reach or only touches at an end, unless the stretch is a single point.
if (hi < lo || (hi === lo && (from < to || lines.length > 0))) return;
const partDistances = distances.slice(first, last + 1);
const partCoordinates = coordinates.slice(first, last + 1);
const line = [ElevationProfile.interpolatePosition(partDistances, partCoordinates, lo)];
for (let i = first; i <= last; i++) {
if (distances[i] > lo && distances[i] < hi) line.push([coordinates[i][0], coordinates[i][1], coordinates[i][2]]);
}
line.push(ElevationProfile.interpolatePosition(partDistances, partCoordinates, hi));
lines.push(line);
});
return lines;
}

// Raw ascent/descent between two sample indices.
Expand All @@ -694,7 +741,7 @@ export class ElevationProfile {
}

private setupInteraction(svgEl: SVGSVGElement, meta: SvgMeta): Interaction {
const { margin, plotW, plotH, safeDistance, distances, samples, coordinates, yTicks, sections, badge } = meta;
const { margin, plotW, plotH, safeDistance, distances, samples, coordinates, partIndices, multiLine, yTicks, sections, badge } = meta;
const capture = svgEl.querySelector<SVGRectElement>(`.${cls("hover-capture")}`)!;
const hoverLine = svgEl.querySelector<SVGLineElement>(`.${cls("hover-line")}`)!;
const hoverDot = svgEl.querySelector<SVGCircleElement>(`.${cls("hover-dot")}`)!;
Expand Down Expand Up @@ -770,7 +817,7 @@ export class ElevationProfile {
return {
distance: meter * lengthFactor,
elevation: samples[nearestSampleIndex(distances, meter)] * lengthFactor,
lngLat: ElevationProfile.interpolateLngLat(distances, coordinates, meter),
geometry: { type: "Point", coordinates: ElevationProfile.interpolatePosition(distances, coordinates, meter) },
sections: values,
};
};
Expand Down Expand Up @@ -814,11 +861,15 @@ export class ElevationProfile {
nearestSampleIndex(distances, selection.end),
);
showBadge([{ text: `+ ${this.formatElevation(ascent)}` }, { text: `- ${this.formatElevation(descent)}` }]);
const start = Math.min(selection.start, selection.end);
const end = Math.max(selection.start, selection.end);
const lines = ElevationProfile.lineBetween(distances, coordinates, partIndices, start, end);
return {
start: Math.min(selection.start, selection.end) * lengthFactor,
end: Math.max(selection.start, selection.end) * lengthFactor,
start: start * lengthFactor,
end: end * lengthFactor,
ascent: ascent * lengthFactor,
descent: descent * lengthFactor,
geometry: multiLine ? { type: "MultiLineString", coordinates: lines } : { type: "LineString", coordinates: lines[0] },
};
};

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export { ElevationProfile, defaultElevationProfileOptions } from "./ElevationPro
export type {
ElevationProfileOptions,
ElevationProfilePoint,
ElevationProfilePointGeometry,
ElevationProfileLineGeometry,
ElevationProfileSelection,
ElevationProfileEventMap,
ElevationProfileEventType,
Expand Down
70 changes: 67 additions & 3 deletions test/ElevationProfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ function create(options: Partial<ElevationProfileOptions> = {}, feature = lineFe
const $ = <T extends Element = SVGElement>(selector: string) => document.querySelector<T>(`#profile ${selector}`)!;
const $$ = <T extends Element = SVGElement>(selector: string) => Array.from(document.querySelectorAll<T>(`#profile ${selector}`));
const texts = (selector: string) => $$(selector).map((el) => el.textContent);
// Rounds nested coordinates, to compare interpolated positions.
const round = (value: unknown): unknown => (Array.isArray(value) ? value.map(round) : Math.round((value as number) * 1e6) / 1e6);
const badgeLines = () => texts(".maptoolkit-elevation-profile-hover-badge-text tspan");

// clientX for a distance in meters on a 1000 m profile.
Expand Down Expand Up @@ -411,7 +413,7 @@ describe("hover badge", () => {
});

describe("events", () => {
it("fires hover with distance, nearest elevation, interpolated lngLat and sections", () => {
it("fires hover with distance, nearest elevation, interpolated Point geometry and sections", () => {
const profile = create({}, lineFeature({ sections: [{ id: "surface", values: [{ from: 0, to: 1, value: "asphalt" }] }] }));
const listener = vi.fn();
profile.on("hover", listener);
Expand All @@ -422,8 +424,11 @@ describe("events", () => {
expect(e.originalEvent).toBe(evt);
expect(e.distance).toBeCloseTo(300, 6);
expect(e.elevation).toBe(150);
expect(e.lngLat[0]).toBeCloseTo(0.12, 6);
expect(e.lngLat[1]).toBeCloseTo(0, 6);
expect(e.geometry.type).toBe("Point");
expect(e.geometry.coordinates[0]).toBeCloseTo(0.12, 6);
expect(e.geometry.coordinates[1]).toBeCloseTo(0, 6);
// Interpolated between 150 m at 250 and 120 m at 500, unlike the nearest-sample elevation.
expect(e.geometry.coordinates[2]).toBeCloseTo(144, 6);
expect(e.sections).toEqual({ surface: "asphalt" });
});

Expand Down Expand Up @@ -462,6 +467,65 @@ describe("events", () => {
expect(e.descent).toBe(30);
});

it("fires selectionend with the selected stretch as 3D LineString", () => {
const profile = create();
const listener = vi.fn();
profile.on("selectionend", listener);
drag(125, 625);
const { geometry } = listener.mock.calls[0][0];
expect(geometry.type).toBe("LineString");
expect(round(geometry.coordinates)).toEqual([
[0.05, 0, 125],
[0.1, 0, 150],
[0.2, 0, 120],
[0.25, 0, 150],
]);
});

it("fires selectionend with one line per covered part for a MultiLineString", () => {
// Two parts of equal length: 0..500 m and 500..1000 m.
const profile = create(
{},
{
geometry: {
type: "MultiLineString",
coordinates: [
[
[0, 0, 100],
[0.2, 0, 110],
],
[
[1, 0, 120],
[1.2, 0, 130],
],
],
},
properties: { length: 1000 },
},
);
const listener = vi.fn();
profile.on("selectionend", listener);
drag(250, 750);
expect(listener.mock.calls[0][0].geometry.type).toBe("MultiLineString");
expect(round(listener.mock.calls[0][0].geometry.coordinates)).toEqual([
[
[0.1, 0, 105],
[0.2, 0, 110],
],
[
[1, 0, 120],
[1.1, 0, 125],
],
]);
drag(100, 400);
expect(round(listener.mock.calls[1][0].geometry.coordinates)).toEqual([
[
[0.04, 0, 102],
[0.16, 0, 108],
],
]);
});

it("orders start and end when dragging to the left", () => {
const profile = create();
const listener = vi.fn();
Expand Down
Loading