toggle additional data on elevation profile

This commit is contained in:
vcoppe
2024-04-21 16:40:28 +02:00
parent 0bf10c85ca
commit 3d4273f9fe
12 changed files with 405 additions and 92 deletions

View File

@@ -397,6 +397,22 @@ export class TrackPoint {
getCoordinates(): Coordinates { getCoordinates(): Coordinates {
return this.attributes; return this.attributes;
} }
getHeartRate(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] : undefined;
}
getCadence(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] : undefined;
}
getTemperature(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] : undefined;
}
getPower(): number {
return this.extensions && this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] ? this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] : undefined;
}
}; };
export class Waypoint { export class Waypoint {

View File

@@ -9,7 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@mapbox/mapbox-gl-geocoder": "^5.0.2", "@mapbox/mapbox-gl-geocoder": "^5.0.2",
"bits-ui": "^0.21.3", "bits-ui": "^0.21.4",
"chart.js": "^4.4.2", "chart.js": "^4.4.2",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"gpx": "file:../gpx", "gpx": "file:../gpx",
@@ -1828,9 +1828,9 @@
} }
}, },
"node_modules/bits-ui": { "node_modules/bits-ui": {
"version": "0.21.3", "version": "0.21.4",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.3.tgz", "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.4.tgz",
"integrity": "sha512-VMQVXwYIjYmDoudIRm2ZlS2guy97lUQk73DwSfTnaS0dhldImbDFMATNxjLSLsTDj8FqJ8Dv78wSctdxcloIbQ==", "integrity": "sha512-IL+7s19GW561jwkeYk23dwkTfQ9606I062qqv2AtjCdhhIdoOEJNVBX0kjP5xefSaS6ojL0HGG54att0aRTcAQ==",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.5.1", "@internationalized/date": "^3.5.1",
"@melt-ui/svelte": "0.76.2", "@melt-ui/svelte": "0.76.2",

View File

@@ -42,7 +42,7 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@mapbox/mapbox-gl-geocoder": "^5.0.2", "@mapbox/mapbox-gl-geocoder": "^5.0.2",
"bits-ui": "^0.21.3", "bits-ui": "^0.21.4",
"chart.js": "^4.4.2", "chart.js": "^4.4.2",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"gpx": "file:../gpx", "gpx": "file:../gpx",

View File

@@ -1,88 +1,127 @@
<script lang="ts"> <script lang="ts">
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import Tooltip from '$lib/components/Tooltip.svelte';
import { Separator } from '$lib/components/ui/separator';
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto';
import { selectedFiles } from '$lib/stores'; import { selectedFiles } from '$lib/stores';
import { onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import {
BrickWall,
TriangleRight,
HeartPulse,
Orbit,
SquareActivity,
Thermometer,
Zap
} from 'lucide-svelte';
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
let chart: Chart; let chart: Chart;
Chart.defaults.font.family = Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
let elevationFill: string;
let additionalDatasets: string[];
let options = {
animation: false,
parsing: false,
maintainAspectRatio: false,
scales: {
x: {
type: 'linear',
title: {
display: true,
text: 'Distance (km)',
padding: 0
}
},
y: {
type: 'linear',
title: {
display: true,
text: 'Elevation (m)',
padding: 0
}
}
},
datasets: {
line: {
pointRadius: 0,
tension: 0.4
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
plugins: {
legend: {
display: false
},
decimation: {
enabled: true
}
},
stacked: false
};
let datasets = {
speed: {
id: 'speed',
label: 'Speed',
units: 'km/h'
},
hr: {
id: 'hr',
label: 'Heart Rate',
units: 'bpm'
},
cad: {
id: 'cad',
label: 'Cadence',
units: 'rpm'
},
atemp: {
id: 'atemp',
label: 'Temperature',
units: '°C'
},
power: {
id: 'power',
label: 'Power',
units: 'W'
}
};
for (let [id, dataset] of Object.entries(datasets)) {
options.scales[`y${id}`] = {
type: 'linear',
position: 'right',
title: {
display: true,
text: dataset.label + ' (' + dataset.units + ')',
padding: 0
},
grid: {
display: false
},
display: false
};
}
onMount(() => { onMount(() => {
chart = new Chart(canvas, { chart = new Chart(canvas, {
type: 'line', type: 'line',
data: { data: {
datasets: [] datasets: []
}, },
options: { options
animation: false,
parsing: false,
maintainAspectRatio: false,
scales: {
x: {
type: 'linear',
title: {
display: true,
text: 'Distance (km)',
padding: 0
}
},
y: {
type: 'linear',
title: {
display: true,
text: 'Elevation (m)',
padding: 0
}
},
y1: {
type: 'linear',
position: 'right',
title: {
display: true,
text: 'Speed (km/h)',
padding: 0
},
grid: {
display: false
}
},
y2: {
type: 'linear',
position: 'right',
title: {
display: true,
text: 'Slope (%)',
padding: 0
},
grid: {
display: false
}
}
},
datasets: {
line: {
pointRadius: 0,
tension: 0.4
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
plugins: {
legend: {
display: false
},
decimation: {
enabled: true
}
},
stacked: false
}
}); });
}); });
@@ -102,7 +141,7 @@
fill: true fill: true
}; };
chart.data.datasets[1] = { chart.data.datasets[1] = {
label: 'Speed', label: datasets.speed.label,
data: trackPointsAndStatistics.points.map((point, index) => { data: trackPointsAndStatistics.points.map((point, index) => {
return { return {
x: trackPointsAndStatistics.statistics.distance[index], x: trackPointsAndStatistics.statistics.distance[index],
@@ -110,18 +149,56 @@
}; };
}), }),
normalized: true, normalized: true,
yAxisID: 'y1' yAxisID: `y${datasets.speed.id}`,
hidden: true
}; };
chart.data.datasets[2] = { chart.data.datasets[2] = {
label: 'Slope', label: datasets.hr.label,
data: trackPointsAndStatistics.points.map((point, index) => { data: trackPointsAndStatistics.points.map((point, index) => {
return { return {
x: trackPointsAndStatistics.statistics.distance[index], x: trackPointsAndStatistics.statistics.distance[index],
y: trackPointsAndStatistics.statistics.slope[index] y: point.getHeartRate()
}; };
}), }),
normalized: true, normalized: true,
yAxisID: 'y2' yAxisID: `y${datasets.hr.id}`,
hidden: true
};
chart.data.datasets[3] = {
label: datasets.cad.label,
data: trackPointsAndStatistics.points.map((point, index) => {
return {
x: trackPointsAndStatistics.statistics.distance[index],
y: point.getCadence()
};
}),
normalized: true,
yAxisID: `y${datasets.cad.id}`,
hidden: true
};
chart.data.datasets[4] = {
label: datasets.atemp.label,
data: trackPointsAndStatistics.points.map((point, index) => {
return {
x: trackPointsAndStatistics.statistics.distance[index],
y: point.getTemperature()
};
}),
normalized: true,
yAxisID: `y${datasets.atemp.id}`,
hidden: true
};
chart.data.datasets[5] = {
label: datasets.power.label,
data: trackPointsAndStatistics.points.map((point, index) => {
return {
x: trackPointsAndStatistics.statistics.distance[index],
y: point.getPower()
};
}),
normalized: true,
yAxisID: `y${datasets.power.id}`,
hidden: true
}; };
chart.options.scales.x['min'] = 0; chart.options.scales.x['min'] = 0;
chart.options.scales.x['max'] = file.statistics.distance.total; chart.options.scales.x['max'] = file.statistics.distance.total;
@@ -129,8 +206,87 @@
chart.update(); chart.update();
} }
} }
$: console.log(elevationFill);
$: if (additionalDatasets && chart) {
let includeSpeed = additionalDatasets.includes('speed');
let includeHeartRate = additionalDatasets.includes('hr');
let includeCadence = additionalDatasets.includes('cad');
let includeTemperature = additionalDatasets.includes('atemp');
let includePower = additionalDatasets.includes('power');
if (chart.data.datasets.length > 0) {
chart.data.datasets[1].hidden = !includeSpeed;
chart.data.datasets[2].hidden = !includeHeartRate;
chart.data.datasets[3].hidden = !includeCadence;
chart.data.datasets[4].hidden = !includeTemperature;
chart.data.datasets[5].hidden = !includePower;
}
chart.options.scales[`y${datasets.speed.id}`].display = includeSpeed;
chart.options.scales[`y${datasets.hr.id}`].display = includeHeartRate;
chart.options.scales[`y${datasets.cad.id}`].display = includeCadence;
chart.options.scales[`y${datasets.atemp.id}`].display = includeTemperature;
chart.options.scales[`y${datasets.power.id}`].display = includePower;
chart.update();
}
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script> </script>
<div class="h-full grow min-w-0 py-4"> <div class="h-full grow min-w-0 flex flex-row items-center">
<canvas bind:this={canvas}> </canvas> <div class="h-full grow min-w-0 py-4">
<canvas bind:this={canvas}> </canvas>
</div>
<div class="h-fit flex flex-col m-2 border rounded">
<ToggleGroup.Root class="flex-col gap-0" type="single" bind:value={elevationFill}>
<ToggleGroup.Item class="p-0 w-8 h-8" value="slope">
<Tooltip side="left">
<TriangleRight slot="data" size="16" />
<span slot="tooltip">Show slope</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-8 h-8" value="surface">
<Tooltip side="left">
<BrickWall slot="data" size="16" />
<span slot="tooltip">Show surface</span>
</Tooltip>
</ToggleGroup.Item>
</ToggleGroup.Root>
<Separator />
<ToggleGroup.Root class="flex-col gap-0" type="multiple" bind:value={additionalDatasets}>
<ToggleGroup.Item class="p-0 w-8 h-8" value="speed">
<Tooltip side="left">
<Zap slot="data" size="16" />
<span slot="tooltip">Show speed</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-8 h-8" value="hr">
<Tooltip side="left">
<HeartPulse slot="data" size="16" />
<span slot="tooltip">Show heart rate</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-8 h-8" value="cad">
<Tooltip side="left">
<Orbit slot="data" size="16" />
<span slot="tooltip">Show cadence</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-8 h-8" value="atemp">
<Tooltip side="left">
<Thermometer slot="data" size="16" />
<span slot="tooltip">Show temperature</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-8 h-8" value="power">
<Tooltip side="left">
<SquareActivity slot="data" size="16" />
<span slot="tooltip">Show power</span>
</Tooltip>
</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import GPXDataItem from '$lib/components/GPXDataItem.svelte'; import Tooltip from '$lib/components/Tooltip.svelte';
import { GPXStatistics } from 'gpx'; import { GPXStatistics } from 'gpx';
@@ -30,14 +30,14 @@
<Card.Root class="h-full overflow-hidden border-none"> <Card.Root class="h-full overflow-hidden border-none">
<Card.Content class="h-full flex flex-col flex-wrap gap-4 justify-center"> <Card.Content class="h-full flex flex-col flex-wrap gap-4 justify-center">
<GPXDataItem> <Tooltip>
<span slot="data" class="flex flex-row items-center"> <span slot="data" class="flex flex-row items-center">
<Ruler size="18" class="mr-1" /> <Ruler size="18" class="mr-1" />
{gpxData.distance.total.toFixed(2)} km {gpxData.distance.total.toFixed(2)} km
</span> </span>
<span slot="tooltip">Distance</span> <span slot="tooltip">Distance</span>
</GPXDataItem> </Tooltip>
<GPXDataItem> <Tooltip>
<span slot="data" class="flex flex-row items-center"> <span slot="data" class="flex flex-row items-center">
<MoveUpRight size="18" class="mr-1" /> <MoveUpRight size="18" class="mr-1" />
{gpxData.elevation.gain.toFixed(0)} m {gpxData.elevation.gain.toFixed(0)} m
@@ -45,20 +45,20 @@
{gpxData.elevation.loss.toFixed(0)} m {gpxData.elevation.loss.toFixed(0)} m
</span> </span>
<span slot="tooltip">Elevation</span> <span slot="tooltip">Elevation</span>
</GPXDataItem> </Tooltip>
<GPXDataItem> <Tooltip>
<span slot="data" class="flex flex-row items-center"> <span slot="data" class="flex flex-row items-center">
<Zap size="18" class="mr-1" /> <Zap size="18" class="mr-1" />
{gpxData.speed.moving.toFixed(2)} km/h {gpxData.speed.moving.toFixed(2)} km/h
</span> </span>
<span slot="tooltip">Time</span> <span slot="tooltip">Time</span>
</GPXDataItem> </Tooltip>
<GPXDataItem> <Tooltip>
<span slot="data" class="flex flex-row items-center"> <span slot="data" class="flex flex-row items-center">
<Timer size="18" class="mr-1" /> <Timer size="18" class="mr-1" />
{toHHMMSS(gpxData.time.moving)} / {toHHMMSS(gpxData.time.total)} {toHHMMSS(gpxData.time.moving)} / {toHHMMSS(gpxData.time.total)}
</span> </span>
<span slot="tooltip">Moving time / Total time</span> <span slot="tooltip">Moving time / Total time</span>
</GPXDataItem> </Tooltip>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -11,8 +11,6 @@
Trash2, Trash2,
HeartHandshake, HeartHandshake,
Upload, Upload,
CloudDownload,
CloudUpload,
Cloud Cloud
} from 'lucide-svelte'; } from 'lucide-svelte';

View File

@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
</script> </script>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
<slot name="data" /> <slot name="data" />
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="top"> <Tooltip.Content {side}>
<slot name="tooltip" /> <slot name="tooltip" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>

View File

@@ -0,0 +1,23 @@
import type { VariantProps } from "tailwind-variants";
import { getContext, setContext } from "svelte";
import Root from "./toggle-group.svelte";
import Item from "./toggle-group-item.svelte";
import type { toggleVariants } from "$lib/components/ui/toggle/index.js";
export type ToggleVariants = VariantProps<typeof toggleVariants>;
export function setToggleGroupCtx(props: ToggleVariants) {
setContext("toggleGroup", props);
}
export function getToggleGroupCtx() {
return getContext<ToggleVariants>("toggleGroup");
}
export {
Root,
Item,
//
Root as ToggleGroup,
Item as ToggleGroupItem,
};

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { type ToggleVariants, getToggleGroupCtx } from "./index.js";
import { cn } from "$lib/utils.js";
import { toggleVariants } from "$lib/components/ui/toggle/index.js";
type $$Props = ToggleGroupPrimitive.ItemProps & ToggleVariants;
let className: string | undefined | null = undefined;
export { className as class };
export let variant: $$Props["variant"] = "default";
export let size: $$Props["size"] = "default";
export let value: $$Props["value"];
const ctx = getToggleGroupCtx();
</script>
<ToggleGroupPrimitive.Item
class={cn(
toggleVariants({
variant: ctx.variant || variant,
size: ctx.size || size,
}),
className
)}
{value}
{...$$restProps}
>
<slot />
</ToggleGroupPrimitive.Item>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { VariantProps } from "tailwind-variants";
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { setToggleGroupCtx } from "./index.js";
import type { toggleVariants } from "$lib/components/ui/toggle/index.js";
import { cn } from "$lib/utils.js";
type T = $$Generic<"single" | "multiple">;
type $$Props = ToggleGroupPrimitive.Props<T> & VariantProps<typeof toggleVariants>;
let className: string | undefined | null = undefined;
export { className as class };
export let variant: $$Props["variant"] = "default";
export let size: $$Props["size"] = "default";
export let value: $$Props["value"] = undefined;
setToggleGroupCtx({
variant,
size,
});
</script>
<ToggleGroupPrimitive.Root
class={cn("flex items-center justify-center gap-1", className)}
bind:value
{...$$restProps}
let:builder
>
<slot {builder} />
</ToggleGroupPrimitive.Root>

View File

@@ -0,0 +1,31 @@
import { type VariantProps, tv } from "tailwind-variants";
import Root from "./toggle.svelte";
export const toggleVariants = tv({
base: "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type Variant = VariantProps<typeof toggleVariants>["variant"];
export type Size = VariantProps<typeof toggleVariants>["size"];
export {
Root,
//
Root as Toggle,
};

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Toggle as TogglePrimitive } from "bits-ui";
import { type Size, type Variant, toggleVariants } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = TogglePrimitive.Props & {
variant?: Variant;
size?: Size;
};
type $$Events = TogglePrimitive.Events;
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export let size: $$Props["size"] = "default";
export let pressed: $$Props["pressed"] = undefined;
export { className as class };
</script>
<TogglePrimitive.Root
bind:pressed
class={cn(toggleVariants({ variant, size, className }))}
{...$$restProps}
on:click
>
<slot />
</TogglePrimitive.Root>