Files
wanderer/web/src/lib/vendor/maplibre-elevation-profile/elevationprofile.ts
Christian Beutel a40e1be511 fixes print
2024-12-02 20:48:58 +01:00

1028 lines
36 KiB
TypeScript

import type {
Feature,
FeatureCollection,
GeoJsonObject,
GeometryObject,
LineString,
MultiLineString,
Position,
} from "geojson";
import { Chart, registerables } from "chart.js";
import zoomPlugin from "chartjs-plugin-zoom";
// @ts-ignore
import { CrosshairPlugin } from "chartjs-plugin-crosshair";
import { haversineDistance } from "$lib/models/gpx/utils";
import type { Waypoint } from "$lib/models/waypoint";
import { formatTimeHHMM } from "$lib/util/format_util";
import { haversineCumulatedDistanceWgs84, smoothElevations } from "./tools";
const FEET_PER_METER = 3.28084;
const MILES_PER_METER = 0.000621371;
const KILOMETERS_HOUR_PER_METER_SECOND = 3.6
const MILES_HOUR_PER_METER_SECOND = 2.23694
function extractLineStrings(
geoJson: GeoJsonObject
): { lineStrings: Array<LineString | MultiLineString>, times: Date[] } {
const lineStrings: Array<LineString | MultiLineString> = [];
const times: Date[] = [];
function extractFromGeometry(geometry: GeometryObject) {
if (geometry.type === "LineString" || geometry.type === "MultiLineString") {
lineStrings.push(geometry as LineString | MultiLineString);
}
}
function extractFromFeature(feature: Feature) {
if (feature.geometry) {
extractFromGeometry(feature.geometry);
}
if (feature.properties?.coordinateProperties?.times) {
const coordinateTimes = feature.properties?.coordinateProperties?.times.map((t: string) => new Date(t))
times.push(...coordinateTimes)
}
}
function extractFromFeatureCollection(collection: FeatureCollection) {
for (const feature of collection.features) {
if (feature.type === "Feature") {
extractFromFeature(feature);
} else if (feature.type === "FeatureCollection") {
extractFromFeatureCollection(feature as unknown as FeatureCollection); // had to add unknown
}
}
}
if (geoJson.type === "Feature") {
extractFromFeature(geoJson as Feature);
} else if (geoJson.type === "FeatureCollection") {
extractFromFeatureCollection(geoJson as FeatureCollection);
} else {
// It's a single geometry
extractFromGeometry(geoJson as GeometryObject);
}
return { lineStrings, times };
}
function geoJsonObjectToPositionsAndTimes(geoJson: GeoJsonObject): { positions: Position[], times: Date[] } {
const { lineStrings, times } = extractLineStrings(geoJson);
const positionsGroups: Position[][] = [];
for (let i = 0; i < lineStrings.length; i += 1) {
const feature = lineStrings[i];
if (feature.type === "LineString") {
positionsGroups.push(feature.coordinates);
} else if (feature.type === "MultiLineString") {
positionsGroups.push(feature.coordinates.flat());
}
}
return { positions: positionsGroups.flat(), times };
}
/**
* Event data to `onMove` and `onClick` callback
*/
export type CallbackData = {
/**
* The position as `[lon, lat, elevation]`.
* Elevation will be in meters if the component has been set with the unit "metric" (default)
* of in feet if the unit is "imperial".
*/
position: Position;
/**
* The distance from the start of the route. In km if the component has been set with the unit "metric" (default)
* of in miles if the unit is "imperial".
*/
distance: number;
/**
* Cumulated positive elevation from the begining of the route up to this location.
* In meters if the component has been set with the unit "metric" (default)
* of in feet if the unit is "imperial".
*/
dPlus: number;
/**
* Slope grade in percentage (1% being a increase of 1m on a 100m distance)
*/
gradePercent: number;
};
export type ElevationProfileOptions = {
/**
* Color of the background of the chart
*/
backgroundColor?: string | null;
/**
* Unit system to use.
* If "metric", elevation and D+ will be in meters, distances will be in km.
* If "imperial", elevation and D+ will be in feet, distances will be in miles.
*
* Default: "metric"
*/
unit?: "metric" | "imperial";
/**
* Font size applied to axes labels and tooltip.
*
* Default: `12`
*/
fontSize?: number;
/**
* If `true`, will force the computation of the elevation of the GeoJSON data provided to the `.setData()` method,
* even if they already contain elevation (possibly from GPS while recording). If `false`, the elevation will only
* be computed if missing from the positions.
*
* Default: `false`
*/
forceComputeElevation?: boolean;
/**
* Display the elevation label along the vertical axis.
*
* Default: `true`
*/
displayElevationLabels?: boolean;
/**
* Display the distance labels alon the horizontal axis.
*
* Default: `true`
*/
displayDistanceLabels?: boolean;
/**
* Display the distance and elevation units alongside the labels.
*
* Default: `true`
*/
displayUnits?: boolean;
/**
* Color of the elevation and distance labels.
*
* Default: `"#0009"` (partially transparent black)
*/
labelColor?: string;
/**
* Color of the elevation profile line.
* Can be `null` to not display the line and rely on the background color only.
*
* Default: `"#66ccff"`
*/
profileLineColor?: string | null;
/**
* Width of the elevation profile line.
*
* Default: `1.5`
*/
profileLineWidth?: number;
/**
* Color of the elevation profile background (below the profile line)
* Can be `null` to not display any backgound color.
*
* Default: `"#66ccff22"`
*/
profileBackgroundColor?: string | null;
/**
* Display the tooltip folowing the pointer.
*
* Default: `true`
*/
displayTooltip?: boolean;
/**
* Color of the text inside the tooltip.
*
* Default: `"#fff"`
*/
tooltipTextColor?: string;
/**
* Color of the tooltip background.
*
* Default: `"#000A"` (partially transparent black)
*/
tooltipBackgroundColor?: string;
/**
* Display the distance information inside the tooltip if `true`.
*
* Default: `true`
*/
tooltipDisplayDistance?: boolean;
/**
* Display the elevation information inside the tooltip if `true`.
*
* Default: `true`
*/
tooltipDisplayElevation?: boolean;
/**
* Display the D+ (cumulated positive ascent) inside the tooltip if `true`.
*
* Default: `true`
*/
tooltipDisplayDPlus?: boolean;
/**
* Display the slope grade in percentage inside the tooltip if `true`.
*
* Default: `true`
*/
tooltipDisplayGrade?: boolean;
/**
* Display the slope grade in percentage inside the tooltip if `true`.
*
* Default: `true`
*/
tooltipDisplaySpeed?: boolean;
/**
* Display the distance grid lines (vertical lines matching the distance labels) if `true`.
*
* Default: `false`
*/
displayDistanceGrid?: boolean;
/**
* Display the elevation grid lines (horizontal lines matching the elevation labels) if `true`.
*
* Default: `true`
*/
displayElevationGrid?: boolean;
/**
* Color of the distance grid lines.
*
* Default: `"#0001"` (partially transparent black)
*/
distanceGridColor?: string;
/**
* Color of the elevation drig lines.
*
* Default: `"#0001"` (partially transparent black)
*/
elevationGridColor?: string;
/**
* Padding at the top of the chart, in number of pixels.
*
* Default: `30`
*/
paddingTop?: number;
/**
* Padding at the bottom of the chart, in number of pixels.
*
* Default: `10`
*/
paddingBottom?: number;
/**
* Padding at the left of the chart, in number of pixels.
*
* Default: `10`
*/
paddingLeft?: number;
/**
* Padding at the right of the chart, in number of pixels.
*
* Default: `10`
*/
paddingRight?: number;
/**
* Display the crosshair, a vertical line that follows the pointer, if `true`.
*
* Default: `true`
*/
displayCrosshair?: boolean;
/**
* Color of the crosshair.
*
* Default: `"#0005"` (partially transparent black)
*/
crosshairColor?: string;
zoom?: boolean;
/**
* Callback function to call when the chart is zoomed or panned.
* The argument `windowedLineString` is the GeoJSON LineString corresponding
* to the portion of the route visible in the elevation chart.
*
* Default: `null`
*/
onChangeView?: ((windowedLineString: LineString) => void) | null;
/**
* Callback function to call when the the elevation chart is clicked.
*
* Default: `null`
*/
onClick?: ((data: CallbackData) => void) | null;
/**
* Callback function to call when the pointer is moving on the elevation chart.
*
* Default: `null`
*/
onMove?: ((data: CallbackData) => void) | null;
onEnter?: (() => void) | null;
onLeave?: (() => void) | null;
};
const elevationProfileDefaultOptions: ElevationProfileOptions = {
backgroundColor: null,
unit: "metric",
fontSize: 12,
forceComputeElevation: false,
displayElevationLabels: true,
displayDistanceLabels: true,
displayUnits: true,
labelColor: "#0009",
profileLineColor: "#66ccff",
profileLineWidth: 1.5,
profileBackgroundColor: "#66ccff22",
displayTooltip: true,
tooltipTextColor: "#fff",
tooltipBackgroundColor: "#000A",
tooltipDisplayDistance: true,
tooltipDisplayElevation: true,
tooltipDisplayDPlus: true,
tooltipDisplayGrade: true,
tooltipDisplaySpeed: true,
displayDistanceGrid: false,
displayElevationGrid: true,
distanceGridColor: "#0001",
elevationGridColor: "#0001",
displayCrosshair: true,
crosshairColor: "#0005",
onChangeView: null,
paddingTop: 30,
paddingBottom: 10,
paddingLeft: 10,
paddingRight: 10,
onClick: null,
onMove: null,
zoom: true
};
/**
* Elevation profile chart
*/
export class ElevationProfile {
private canvas: HTMLCanvasElement;
private settings: ElevationProfileOptions;
private chart: Chart<"line", Array<number>, number>;
private elevatedPositions: Position[] = [];
private elevatedPositionsAdjustedUnit: Position[] = [];
private cumulatedDistance: number[] = [];
private cumulatedDistanceAdjustedUnit: number[] = [];
private cumulatedDPlus: number[] = [];
private grade: number[] = [];
private waypointPositions: number[] = []
private waypoints: Waypoint[] = [];
private times: Date[] = [];
private cumulatedTime: number[] = []
private speed: number[] = [];
private gradeColor = [
"#0d0887", // 0% and less
"#3e049c", // 1
"#6300a7", // 2
"#8606a6", // 3
"#a62098", // 4
"#c03a83", // 5
"#d5546e", // 6
"#e76f5a", // 7
"#f68d45", // 8
"#fdae32", // 9
"#fcd225", // 10
"#f0f921", // more than 10%
]
private width?: number
private height?: number
private gradient?: CanvasGradient;
constructor(
/**
* DIV element to place the chart into
*/
container: HTMLDivElement | string,
/**
* Options
*/
options: ElevationProfileOptions = {}
) {
const appContainer =
typeof container === "string"
? document.getElementById(container)
: container;
if (!appContainer) {
throw new Error("The container does not exist.");
}
this.canvas = document.createElement("canvas");
this.canvas.id = "elevation-profile-chart"
appContainer.appendChild(this.canvas);
Chart.register(...registerables);
Chart.register(zoomPlugin);
this.settings = {
...elevationProfileDefaultOptions,
...options,
};
const distanceUnit = this.settings.unit === "imperial" ? "mi" : "km";
const elevationUnit = this.settings.unit === "imperial" ? "ft" : "m";
Chart.defaults.font.size = this.settings.fontSize;
this.chart = new Chart<"line", Array<number>, number>(this.canvas, {
type: "line",
data: {
labels: [],
datasets: [
{
label: "Elevation",
yAxisID: "y",
data: [],
pointRadius: 0,
fill: !!this.settings.profileBackgroundColor,
borderColor: (context) => {
const chart = context.chart;
return this.gradientFromElevation(chart)
},
// borderColor: this.settings.profileLineColor ?? "#0000",
backgroundColor: this.settings.profileBackgroundColor ?? "#0000",
tension: 0.1,
spanGaps: true,
// If line color is null, the line width is set to 0
borderWidth: this.settings.profileLineColor
? this.settings.profileLineWidth
: 0,
}
],
},
options: {
layout: {
padding: {
left: this.settings.paddingLeft,
right: this.settings.paddingRight,
bottom: this.settings.paddingBottom,
top: this.settings.paddingTop,
},
},
onClick: (_e, item) => {
if (typeof this.settings.onClick !== "function") return;
try {
const i = item[0].index;
this.settings.onClick.apply(this, [
{
position: this.elevatedPositionsAdjustedUnit[i],
distance: this.cumulatedDistanceAdjustedUnit[i],
dPlus: this.cumulatedDPlus[i],
gradePercent: this.grade[i],
},
]);
} catch (e) {
// Nothing to do
}
},
onHover: (_e, item) => {
if (typeof this.settings.onMove !== "function") return;
try {
const i = item[0].index;
this.settings.onMove.apply(this, [
{
position: this.elevatedPositionsAdjustedUnit[i],
distance: this.cumulatedDistanceAdjustedUnit[i],
dPlus: this.cumulatedDPlus[i],
gradePercent: this.grade[i],
},
]);
} catch (e) {
// Nothing to do
}
},
animation: false,
maintainAspectRatio: false,
scales: {
x: {
min: 0,
max: 0,
type: "linear",
grid: {
display: this.settings.displayDistanceGrid,
drawOnChartArea: false,
color: this.settings.distanceGridColor,
drawTicks: true,
tickLength: 5,
tickColor: "#0002"
},
ticks: {
stepSize: 0.1,
align: "inner",
display: this.settings.displayDistanceLabels,
color: this.settings.labelColor,
maxRotation: 0,
callback: (value, index) => {
if (index % 10 !== 0) {
return "";
}
const roundedValue = ~~((value as number) * 100) / 100;
return this.settings.displayUnits
? `${roundedValue} ${distanceUnit}`
: roundedValue;
},
},
},
y: {
min: 0,
max: 0,
type: "linear",
afterTickToLabelConversion: (scaleInstance) => {
scaleInstance.ticks.pop();
scaleInstance.ticks.shift();
},
ticks: {
mirror: true,
maxTicksLimit: 10,
align: "end",
display: this.settings.displayElevationLabels,
color: this.settings.labelColor,
callback: (value) => {
const roundedValue = ~~((value as number) * 100) / 100;
return this.settings.displayUnits
? `${roundedValue} ${elevationUnit}`
: roundedValue;
},
},
border: {
dash: [5, 5],
display: true,
color: this.settings.elevationGridColor,
},
grid: {
display: this.settings.displayElevationGrid,
color: this.settings.elevationGridColor,
drawTicks: false,
},
},
},
interaction: {
intersect: false,
mode: "index",
},
plugins: {
zoom: {
zoom: {
wheel: {
enabled: this.settings.zoom,
},
pinch: {
enabled: this.settings.zoom,
},
mode: "x",
},
pan: {
enabled: this.settings.zoom,
mode: "x",
},
limits: {
x: {
min: "original",
max: "original",
},
},
},
title: {
display: false,
},
legend: {
display: false,
},
tooltip: {
enabled: this.settings.displayTooltip,
yAlign: "center",
cornerRadius: 3,
displayColors: false,
backgroundColor: this.settings.tooltipBackgroundColor,
bodyColor: this.settings.tooltipTextColor,
callbacks: {
title: () => {
return "";
},
label: (tooltipItem) => {
if (tooltipItem.datasetIndex != 0) {
return "";
}
const tooltipInfo = [];
if (this.settings.tooltipDisplayDistance) {
tooltipInfo.push(
`After: ${this.cumulatedDistanceAdjustedUnit[
tooltipItem.dataIndex
].toFixed(4)} ${distanceUnit} ${this.cumulatedTime.length ? '(' + formatTimeHHMM(this.cumulatedTime[tooltipItem.dataIndex]) + ')' : ''}`
);
}
if (this.settings.tooltipDisplayElevation) {
tooltipInfo.push(
`Elevation: ${this.elevatedPositionsAdjustedUnit[
tooltipItem.dataIndex
][2].toFixed(2)} ${elevationUnit}`
);
}
if (this.settings.tooltipDisplayDPlus) {
tooltipInfo.push(
`D+: ${this.cumulatedDPlus[tooltipItem.dataIndex].toFixed(
0
)} ${elevationUnit}`
);
}
if (this.settings.tooltipDisplayGrade) {
tooltipInfo.push(
`Grade: ${this.grade[tooltipItem.dataIndex].toFixed(1)}%`
);
}
if (this.settings.tooltipDisplaySpeed && this.speed.length) {
tooltipInfo.push(`Speed: ${this.speed[
tooltipItem.dataIndex
].toFixed(2)} ${distanceUnit}/h`
);
}
return tooltipInfo;
},
},
},
// The crosshair plugin does not have types
// @ts-ignore
crosshair: {
zoom: {
enabled: false,
},
line: {
color: this.settings.displayCrosshair
? this.settings.crosshairColor
: "#0000",
width: 1,
},
},
},
},
plugins: [
CrosshairPlugin,
{
id: "waypointPlugin",
afterDraw: (chart, args, options) => {
const waypointContainer = document.getElementById("waypoint-container") as HTMLDivElement;
waypointContainer.innerHTML = ""; // Clear previous ticks
const xScale = chart.scales.x; // Get X-axis scale
const chartRect = chart.canvas.getBoundingClientRect(); // Canvas position
this.waypointPositions.forEach((position: number, index: number) => {
const xPos = xScale.getPixelForValue(position); // X-axis pixel for tick
// Create custom HTML tick
const wpDiv = document.createElement("div");
wpDiv.className = "wp-marker absolute -translate-x-1/2 w-6 aspect-square bg-background-inverse rounded-full flex justify-center items-center text-content-inverse cursor-pointer hover:scale-110";
wpDiv.style.left = `${xPos}px`; // Position horizontally
wpDiv.style.top = `8px`; // Position horizontally
// Add custom HTML content (e.g., icon + label)
wpDiv.innerHTML = `<div class="tooltip" data-title="${this.waypoints[index].name ?? "?"}"><i class="fa fa-${this.waypoints.at(index)?.icon ?? 'circle'}"></i></div>`;
waypointContainer.appendChild(wpDiv); // Add to container
});
}
},
{
id: "customZoomEvent",
afterDataLimits: (chart) => {
if (typeof this.settings.onChangeView !== "function") return;
try {
this.settings.onChangeView.apply(this, [
this.createWindowExtractLineString(),
]);
} catch (e) {
// nothing to do
}
},
},
],
});
if (typeof this.settings.onLeave === "function") {
this.chart.canvas.addEventListener("mouseout", this.settings.onLeave)
}
if (typeof this.settings.onEnter === "function") {
this.chart.canvas.addEventListener("mouseenter", this.settings.onEnter)
}
// If the tooltip is shown, then we hide it when panning the chart
if (this.settings.displayTooltip) {
let mouseDown = false;
this.chart.canvas.addEventListener("mousedown", () => {
mouseDown = true;
});
this.chart.canvas.addEventListener("mousemove", () => {
if (
mouseDown &&
this.chart.options.plugins &&
this.chart.options.plugins.tooltip
) {
this.chart.options.plugins.tooltip.enabled = false;
this.chart.update();
}
});
window.addEventListener("mouseup", () => {
if (this.chart.options.plugins && this.chart.options.plugins.tooltip) {
this.chart.options.plugins.tooltip.enabled = true;
this.chart.update();
mouseDown = false;
}
});
}
}
gradientFromElevation(chart: Chart, force: boolean = false) {
const ctx = chart.ctx;
const chartArea = chart.chartArea;
if (!chartArea) {
return;
}
const chartWidth = chartArea.right - chartArea.left;
const chartHeight = chartArea.bottom - chartArea.top;
if (chartWidth == 0 || chartHeight == 0) {
return;
}
if (!this.gradient || this.width !== chartWidth || this.height !== chartHeight || force) {
this.width = chartWidth;
this.height = chartHeight;
this.gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0) as CanvasGradient;
this.gradient.addColorStop(0, this.gradeColor[0])
let prevColor = this.gradeColor[0];
for (let i = 0; i < this.grade.length; i++) {
const grade = ~~(Math.abs(this.grade[i]) / 2.5);
let color;
if (grade < 1) {
color = this.gradeColor[0]
}
else if (grade > 10) {
color = this.gradeColor[11];
} else {
color = this.gradeColor[grade];
}
if (color !== prevColor) {
const percentDone = this.cumulatedDistance[i] / this.cumulatedDistance[this.cumulatedDistance.length - 1]
this.gradient.addColorStop(percentDone, color);
prevColor = color;
}
}
}
return this.gradient;
}
createWindowExtractLineString(): LineString {
const scaleMin = this.chart.scales.x.min;
const scaleMax = this.chart.scales.x.max;
const cda = this.cumulatedDistanceAdjustedUnit;
const nbElem = cda.length;
let indexStart = 0;
let indexEnd = nbElem - 1;
// find the start index
for (let i = 0; i < nbElem; i += 1) {
if (cda[i] >= scaleMin) {
indexStart = i;
break;
}
}
// find the end index
for (let i = nbElem - 1; i >= indexStart; i -= 1) {
if (cda[i] <= scaleMax) {
indexEnd = i;
break;
}
}
return this.createExtractLineString(indexStart, indexEnd);
}
createExtractLineString(fromIndex: number, toIndex: number): LineString {
const elevatedPositionsWindow: Position[] = this.elevatedPositions.slice(
fromIndex,
toIndex
);
return {
type: "LineString",
coordinates: elevatedPositionsWindow,
};
}
toggleTheme(options: ElevationProfileOptions) {
this.settings = {
...this.settings,
...options,
};
this.chart.data.datasets[0].backgroundColor = this.settings.profileBackgroundColor ?? "#0000";
this.chart.options.scales!.x!.ticks!.color = this.settings.labelColor;
this.chart.options.scales!.y!.grid!.color = this.settings.elevationGridColor;
this.chart.options.scales!.y!.border!.color = this.settings.elevationGridColor;
this.chart.options.scales!.y!.ticks!.color = this.settings.labelColor;
(this.chart.options.plugins as any).crosshair!.line.color = this.settings.crosshairColor;
this.chart.update();
}
async setData(data: GeoJsonObject, waypoints?: Waypoint[]) {
// Concatenates the positions that may come from multiple LineStrings or MultiLineString
const { positions, times } = geoJsonObjectToPositionsAndTimes(data);
this.times = times;
this.elevatedPositions = smoothElevations(positions, Math.ceil(positions.length / 100));
this.cumulatedDistance = haversineCumulatedDistanceWgs84(
this.elevatedPositions
);
// Conversion of distance to miles and elevation to feet
if (this.settings.unit === "imperial") {
this.cumulatedDistanceAdjustedUnit = this.cumulatedDistance.map(
(dist) => dist * MILES_PER_METER
);
this.elevatedPositionsAdjustedUnit = this.elevatedPositions.map((pos) => [
pos[0],
pos[1],
pos[2] * FEET_PER_METER,
]);
this.cumulatedDPlus = this.cumulatedDPlus.map(
(ele) => ele * FEET_PER_METER
);
} else {
this.cumulatedDistanceAdjustedUnit = this.cumulatedDistance.map(
(dist) => dist / 1000
); // we still need to convert distance to km
this.elevatedPositionsAdjustedUnit = this.elevatedPositions;
}
this.cumulatedDPlus = [];
this.grade = [];
this.waypoints = waypoints ?? [];
// this.waypointPositions = [];
let cumulatedDPlus = 0;
let cumulatedTime = 0;
const minSegmentDistance = (this.cumulatedDistance.at(-1) ?? 1000) / 100;
let segmentStartIndex = 0;
// Initialize an array to store the minimum distance for each waypoint
const minDistances = new Array(waypoints?.length).fill(Infinity);
for (let i = 0; i < this.elevatedPositions.length; i++) {
// Check if a waypoint is closest to this point
this.waypoints.forEach((waypoint, waypointIndex) => {
const distance = haversineDistance(this.elevatedPositions[i][1], this.elevatedPositions[i][0], waypoint.lat, waypoint.lon);
// Update if the current route coordinate is closer to the waypoint
if (distance < minDistances[waypointIndex]) {
minDistances[waypointIndex] = distance;
this.waypointPositions[waypointIndex] = this.cumulatedDistanceAdjustedUnit[i];
}
});
const elevation = this.elevatedPositions[i][2];
const time = this.times[i]
if (i > 1) {
const elevationPrevious = this.elevatedPositions[i - 1][2];
const elevationDelta = elevation - elevationPrevious;
const segmentDistance =
this.cumulatedDistance[i] - this.cumulatedDistance[segmentStartIndex];
cumulatedDPlus += Math.max(0, elevationDelta);
this.cumulatedDPlus.push(cumulatedDPlus);
if (time) {
const timePrevious = this.times[i - 1];
const timeDelta = (time.getTime() - timePrevious.getTime()) / (1000 * 60);
cumulatedTime += timeDelta;
this.cumulatedTime.push(cumulatedTime)
}
// Check if the segment distance is greater than or equal to the minimum threshold
if (segmentDistance >= minSegmentDistance || i === this.elevatedPositions.length - 1) {
// Calculate the grade for the consolidated segment
const elevationStart = this.elevatedPositions[segmentStartIndex][2];
const elevationEnd = this.elevatedPositions[i][2];
const elevationDelta = elevationEnd - elevationStart;
const gradePercent = (elevationDelta / segmentDistance) * 100; // Grade as a percentage
let speed;
if (this.times.length) {
const distanceStart = this.cumulatedDistance[segmentStartIndex]
const distanceEnd = this.cumulatedDistance[i]
const distanceDelta = distanceEnd - distanceStart
const timeStart = this.times[segmentStartIndex]
const timeEnd = this.times[i]
const timeDelta = (timeEnd.getTime() - timeStart.getTime()) / 1000
speed = (distanceDelta / timeDelta)
if (this.settings.unit === "imperial") {
speed = speed * MILES_HOUR_PER_METER_SECOND
} else {
speed = speed * KILOMETERS_HOUR_PER_METER_SECOND;
}
}
// Apply the same grade to all positions within this segment
for (let j = segmentStartIndex; j <= i; j++) {
this.grade.push(gradePercent);
if (speed) {
this.speed.push(speed)
}
}
// Move to the next segment
segmentStartIndex = i + 1;
}
}
}
this.grade.push(this.grade.at(-1) ?? 0);
this.cumulatedDPlus.push(cumulatedDPlus);
let minElevation = +Infinity;
let maxElevation = -Infinity;
for (let i = 0; i < this.elevatedPositionsAdjustedUnit.length; i += 1) {
if (this.elevatedPositionsAdjustedUnit[i][2] < minElevation) {
minElevation = this.elevatedPositionsAdjustedUnit[i][2];
}
if (this.elevatedPositionsAdjustedUnit[i][2] > maxElevation) {
maxElevation = this.elevatedPositionsAdjustedUnit[i][2];
}
}
const elevationPadding = (maxElevation - minElevation) * 0.1;
this.chart.data.labels = this.cumulatedDistanceAdjustedUnit;
this.chart.data.datasets[0].data = this.elevatedPositionsAdjustedUnit.map(
(pos) => pos[2]
);
const gradient = this.gradientFromElevation(this.chart, true);
if (gradient) {
this.chart.data.datasets[0].borderColor = gradient
}
if (
this.chart.options.scales &&
this.chart.options.scales.x &&
this.chart.options.scales.y
) {
this.chart.options.scales.x.min = this.cumulatedDistanceAdjustedUnit[0];
this.chart.options.scales.x.max =
this.cumulatedDistanceAdjustedUnit[
this.cumulatedDistanceAdjustedUnit.length - 1
];
this.chart.options.scales.y.min = minElevation - elevationPadding;
this.chart.options.scales.y.max = maxElevation + elevationPadding;
}
this.chart.update();
}
}