From ce3e70a3b7971f1540212df909b76494f1548955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20B=C3=B6ck?= Date: Mon, 12 Oct 2026 16:27:42 +0200 Subject: [PATCH] Add geometry to interactive events --- .changeset/event-geometry.md | 7 +++ README.md | 24 +++++----- src/ElevationProfile.ts | 83 ++++++++++++++++++++++++++++------- src/index.ts | 2 + test/ElevationProfile.test.ts | 70 +++++++++++++++++++++++++++-- 5 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 .changeset/event-geometry.md diff --git a/.changeset/event-geometry.md b/.changeset/event-geometry.md new file mode 100644 index 0000000..0b025a5 --- /dev/null +++ b/.changeset/event-geometry.md @@ -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. diff --git a/README.md b/README.md index 89a976e..c5ce029 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/ElevationProfile.ts b/src/ElevationProfile.ts index 3378f1f..c4f6df7 100644 --- a/src/ElevationProfile.ts +++ b/src/ElevationProfile.ts @@ -16,8 +16,10 @@ // Badge labels: options.locale["."], 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. @@ -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; } @@ -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 { @@ -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; } @@ -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; @@ -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 }); @@ -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. @@ -631,8 +648,11 @@ export class ElevationProfile { `; // 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". @@ -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. @@ -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(`.${cls("hover-capture")}`)!; const hoverLine = svgEl.querySelector(`.${cls("hover-line")}`)!; const hoverDot = svgEl.querySelector(`.${cls("hover-dot")}`)!; @@ -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, }; }; @@ -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] }, }; }; diff --git a/src/index.ts b/src/index.ts index 09e25bf..798d6c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ export { ElevationProfile, defaultElevationProfileOptions } from "./ElevationPro export type { ElevationProfileOptions, ElevationProfilePoint, + ElevationProfilePointGeometry, + ElevationProfileLineGeometry, ElevationProfileSelection, ElevationProfileEventMap, ElevationProfileEventType, diff --git a/test/ElevationProfile.test.ts b/test/ElevationProfile.test.ts index eaf52f2..dffb35a 100644 --- a/test/ElevationProfile.test.ts +++ b/test/ElevationProfile.test.ts @@ -74,6 +74,8 @@ function create(options: Partial = {}, feature = lineFe const $ = (selector: string) => document.querySelector(`#profile ${selector}`)!; const $$ = (selector: string) => Array.from(document.querySelectorAll(`#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. @@ -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); @@ -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" }); }); @@ -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();