mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-08-31 15:43:25 +00:00
embedding progress
This commit is contained in:
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
|||||||
- name: Build website
|
- name: Build website
|
||||||
env:
|
env:
|
||||||
BASE_PATH: '/${{ github.event.repository.name }}'
|
BASE_PATH: '/${{ github.event.repository.name }}'
|
||||||
|
PUBLIC_MAPBOX_TOKEN: ${{ secrets.PUBLIC_MAPBOX_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npm run build --prefix website
|
npm run build --prefix website
|
||||||
|
|
||||||
|
1
website/.env.example
Normal file
1
website/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN
|
@@ -1,7 +1,6 @@
|
|||||||
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
import { type AnySourceData, type Style } from 'mapbox-gl';
|
import { type AnySourceData, type Style } from 'mapbox-gl';
|
||||||
|
|
||||||
export const mapboxAccessToken = 'pk.eyJ1IjoidmNvcHBlIiwiYSI6ImNseG0zNHpwdTA1NXUycXF4ejJyODc0NWQifQ.4tiCPQ1SxnYl4o7aQc89VA';
|
|
||||||
|
|
||||||
export const basemaps: { [key: string]: string | Style; } = {
|
export const basemaps: { [key: string]: string | Style; } = {
|
||||||
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
|
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
|
||||||
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
|
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
|
||||||
@@ -269,7 +268,7 @@ export const basemaps: { [key: string]: string | Style; } = {
|
|||||||
export function extendBasemap(basemap: string | Style): string | Style {
|
export function extendBasemap(basemap: string | Style): string | Style {
|
||||||
if (typeof basemap === 'object') {
|
if (typeof basemap === 'object') {
|
||||||
basemap["glyphs"] = "mapbox://fonts/mapbox/{fontstack}/{range}.pbf";
|
basemap["glyphs"] = "mapbox://fonts/mapbox/{fontstack}/{range}.pbf";
|
||||||
basemap["sprite"] = `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${mapboxAccessToken}`;
|
basemap["sprite"] = `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`;
|
||||||
}
|
}
|
||||||
return basemap;
|
return basemap;
|
||||||
}
|
}
|
||||||
|
@@ -11,12 +11,13 @@
|
|||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/db';
|
||||||
import { locale } from 'svelte-i18n';
|
import { locale } from 'svelte-i18n';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { mapboxAccessToken } from '$lib/assets/layers';
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
|
|
||||||
mapboxgl.accessToken = mapboxAccessToken;
|
|
||||||
|
|
||||||
|
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
||||||
export let geocoder = true;
|
export let geocoder = true;
|
||||||
|
|
||||||
|
mapboxgl.accessToken = accessToken;
|
||||||
|
|
||||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||||
maxZoom: 15,
|
maxZoom: 15,
|
||||||
linear: true,
|
linear: true,
|
||||||
|
@@ -7,7 +7,7 @@
|
|||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="w-full sticky top-0 bg-background z-10">
|
<nav class="w-full sticky top-0 bg-background z-50">
|
||||||
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
||||||
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
|
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
|
||||||
<Logo class="h-8 sm:hidden" iconOnly={true} />
|
<Logo class="h-8 sm:hidden" iconOnly={true} />
|
||||||
|
@@ -64,7 +64,12 @@
|
|||||||
@apply px-1;
|
@apply px-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.markdown a) {
|
:global(.markdown > a) {
|
||||||
|
@apply text-blue-500;
|
||||||
|
@apply hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown p > a) {
|
||||||
@apply text-blue-500;
|
@apply text-blue-500;
|
||||||
@apply hover:underline;
|
@apply hover:underline;
|
||||||
}
|
}
|
||||||
|
205
website/src/lib/components/embedding/Embedding.svelte
Normal file
205
website/src/lib/components/embedding/Embedding.svelte
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
|
||||||
|
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
||||||
|
import FileList from '$lib/components/file-list/FileList.svelte';
|
||||||
|
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||||
|
import MapComponent from '$lib/components/Map.svelte';
|
||||||
|
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
|
||||||
|
import OpenIn from '$lib/components/OpenIn.svelte';
|
||||||
|
|
||||||
|
import { gpxStatistics, slicedGPXStatistics, embedding, loadFile, map } from '$lib/stores';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
|
import type { GPXFile } from 'gpx';
|
||||||
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
|
import { ListFileItem } from '$lib/components/file-list/FileList';
|
||||||
|
import { allowedEmbeddingBasemaps, type EmbeddingOptions } from './Embedding';
|
||||||
|
import { languages } from '$lib/languages';
|
||||||
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
|
||||||
|
$embedding = true;
|
||||||
|
|
||||||
|
const { currentBasemap, distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
|
|
||||||
|
export let options: EmbeddingOptions;
|
||||||
|
|
||||||
|
let prevUnits = {
|
||||||
|
distance: '',
|
||||||
|
velocity: '',
|
||||||
|
temperature: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyOptions() {
|
||||||
|
fileObservers.update(($fileObservers) => {
|
||||||
|
$fileObservers.clear();
|
||||||
|
return $fileObservers;
|
||||||
|
});
|
||||||
|
|
||||||
|
let downloads: Promise<GPXFile | null>[] = [];
|
||||||
|
options.files.forEach((url) => {
|
||||||
|
downloads.push(
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => response.blob())
|
||||||
|
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||||
|
.then(loadFile)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(downloads).then((files) => {
|
||||||
|
let ids: string[] = [];
|
||||||
|
let bounds = {
|
||||||
|
southWest: {
|
||||||
|
lat: 90,
|
||||||
|
lon: 180
|
||||||
|
},
|
||||||
|
northEast: {
|
||||||
|
lat: -90,
|
||||||
|
lon: -180
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fileObservers.update(($fileObservers) => {
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
if (file === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = `gpx-${index}`;
|
||||||
|
file._data.id = id;
|
||||||
|
let statistics = new GPXStatisticsTree(file);
|
||||||
|
|
||||||
|
$fileObservers.set(
|
||||||
|
id,
|
||||||
|
readable({
|
||||||
|
file,
|
||||||
|
statistics
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
ids.push(id);
|
||||||
|
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
|
||||||
|
|
||||||
|
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
|
||||||
|
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
|
||||||
|
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
|
||||||
|
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $fileObservers;
|
||||||
|
});
|
||||||
|
|
||||||
|
selection.update(($selection) => {
|
||||||
|
$selection.clear();
|
||||||
|
ids.forEach((id) => {
|
||||||
|
$selection.toggle(new ListFileItem(id));
|
||||||
|
});
|
||||||
|
return $selection;
|
||||||
|
});
|
||||||
|
|
||||||
|
map.subscribe(($map) => {
|
||||||
|
if ($map) {
|
||||||
|
$map.fitBounds(
|
||||||
|
[
|
||||||
|
bounds.southWest.lon,
|
||||||
|
bounds.southWest.lat,
|
||||||
|
bounds.northEast.lon,
|
||||||
|
bounds.northEast.lat
|
||||||
|
],
|
||||||
|
{
|
||||||
|
padding: 80,
|
||||||
|
linear: true,
|
||||||
|
easing: () => 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.basemap !== $currentBasemap && allowedEmbeddingBasemaps.includes(options.basemap)) {
|
||||||
|
$currentBasemap = options.basemap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.distanceUnits !== $distanceUnits) {
|
||||||
|
$distanceUnits = options.distanceUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.velocityUnits !== $velocityUnits) {
|
||||||
|
$velocityUnits = options.velocityUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.temperatureUnits !== $temperatureUnits) {
|
||||||
|
$temperatureUnits = options.temperatureUnits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
prevUnits.distance = $distanceUnits;
|
||||||
|
prevUnits.velocity = $velocityUnits;
|
||||||
|
prevUnits.temperature = $temperatureUnits;
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (options) {
|
||||||
|
applyOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if ($distanceUnits !== prevUnits.distance) {
|
||||||
|
$distanceUnits = prevUnits.distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($velocityUnits !== prevUnits.velocity) {
|
||||||
|
$velocityUnits = prevUnits.velocity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($temperatureUnits !== prevUnits.temperature) {
|
||||||
|
$temperatureUnits = prevUnits.temperature;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
||||||
|
<div class="grow relative">
|
||||||
|
<MapComponent
|
||||||
|
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
|
||||||
|
accessToken={options.token}
|
||||||
|
geocoder={false}
|
||||||
|
/>
|
||||||
|
<OpenIn bind:files={options.files} />
|
||||||
|
<LayerControl />
|
||||||
|
<GPXLayers />
|
||||||
|
{#if $fileObservers.size > 1}
|
||||||
|
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||||
|
<FileList orientation="horizontal" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
|
||||||
|
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
||||||
|
>
|
||||||
|
<GPXStatistics
|
||||||
|
{gpxStatistics}
|
||||||
|
{slicedGPXStatistics}
|
||||||
|
panelSize={options.elevation.height}
|
||||||
|
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||||
|
/>
|
||||||
|
{#if options.elevation.show}
|
||||||
|
<ElevationProfile
|
||||||
|
{gpxStatistics}
|
||||||
|
{slicedGPXStatistics}
|
||||||
|
additionalDatasets={[
|
||||||
|
options.elevation.speed ? 'speed' : null,
|
||||||
|
options.elevation.hr ? 'hr' : null,
|
||||||
|
options.elevation.cad ? 'cad' : null,
|
||||||
|
options.elevation.temp ? 'temp' : null,
|
||||||
|
options.elevation.power ? 'power' : null
|
||||||
|
].filter((dataset) => dataset !== null)}
|
||||||
|
elevationFill={options.elevation.fill}
|
||||||
|
panelSize={options.elevation.height}
|
||||||
|
showControls={options.elevation.controls}
|
||||||
|
class="py-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
63
website/src/lib/components/embedding/Embedding.ts
Normal file
63
website/src/lib/components/embedding/Embedding.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export type EmbeddingOptions = {
|
||||||
|
token: string;
|
||||||
|
files: string[];
|
||||||
|
basemap: string;
|
||||||
|
elevation: {
|
||||||
|
show: boolean;
|
||||||
|
height: number,
|
||||||
|
controls: boolean,
|
||||||
|
fill: 'slope' | 'surface' | undefined,
|
||||||
|
speed: boolean,
|
||||||
|
hr: boolean,
|
||||||
|
cad: boolean,
|
||||||
|
temp: boolean,
|
||||||
|
power: boolean,
|
||||||
|
},
|
||||||
|
distanceUnits: 'metric' | 'imperial',
|
||||||
|
velocityUnits: 'speed' | 'pace',
|
||||||
|
temperatureUnits: 'celsius' | 'fahrenheit',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultEmbeddingOptions = {
|
||||||
|
token: '',
|
||||||
|
files: [],
|
||||||
|
basemap: 'mapboxOutdoors',
|
||||||
|
elevation: {
|
||||||
|
show: true,
|
||||||
|
height: 170,
|
||||||
|
controls: true,
|
||||||
|
fill: undefined,
|
||||||
|
speed: false,
|
||||||
|
hr: false,
|
||||||
|
cad: false,
|
||||||
|
temp: false,
|
||||||
|
power: false,
|
||||||
|
},
|
||||||
|
distanceUnits: 'metric',
|
||||||
|
velocityUnits: 'speed',
|
||||||
|
temperatureUnits: 'celsius',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
||||||
|
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): any {
|
||||||
|
const cleanedOptions = JSON.parse(JSON.stringify(options));
|
||||||
|
for (const key in cleanedOptions) {
|
||||||
|
if (typeof cleanedOptions[key] === 'object' && cleanedOptions[key] !== null && !Array.isArray(cleanedOptions[key])) {
|
||||||
|
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||||
|
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||||
|
delete cleanedOptions[key];
|
||||||
|
}
|
||||||
|
} else if (JSON.stringify(cleanedOptions[key]) === JSON.stringify(defaultOptions[key])) {
|
||||||
|
delete cleanedOptions[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const allowedEmbeddingBasemaps = [
|
||||||
|
'mapboxOutdoors',
|
||||||
|
'mapboxSatellite',
|
||||||
|
];
|
@@ -5,42 +5,32 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||||
import { basemaps } from '$lib/assets/layers';
|
|
||||||
import { Zap, HeartPulse, Orbit, Thermometer, SquareActivity } from 'lucide-svelte';
|
import { Zap, HeartPulse, Orbit, Thermometer, SquareActivity } from 'lucide-svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
|
import {
|
||||||
|
allowedEmbeddingBasemaps,
|
||||||
|
getCleanedEmbeddingOptions,
|
||||||
|
getDefaultEmbeddingOptions
|
||||||
|
} from './Embedding';
|
||||||
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
|
import Embedding from './Embedding.svelte';
|
||||||
|
import { map } from '$lib/stores';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
|
||||||
let options = {
|
let options = getDefaultEmbeddingOptions();
|
||||||
files: ['https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'],
|
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||||
basemap: 'mapboxOutdoors',
|
options.files = [
|
||||||
elevation: {
|
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||||
show: true,
|
];
|
||||||
height: 170,
|
|
||||||
data: [],
|
|
||||||
fill: undefined,
|
|
||||||
controls: true
|
|
||||||
},
|
|
||||||
distanceUnits: 'metric',
|
|
||||||
velocityUnits: 'speed',
|
|
||||||
temperatureUnits: 'celsius'
|
|
||||||
};
|
|
||||||
|
|
||||||
let files =
|
|
||||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx';
|
|
||||||
|
|
||||||
|
let files = options.files[0];
|
||||||
$: if (files) {
|
$: if (files) {
|
||||||
options.files = files.split(',');
|
let urls = files.split(',');
|
||||||
}
|
urls = urls.filter((url) => url.length > 0);
|
||||||
|
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||||
let additionalData = {
|
options.files = urls;
|
||||||
speed: false,
|
}
|
||||||
hr: false,
|
|
||||||
cad: false,
|
|
||||||
temp: false,
|
|
||||||
power: false
|
|
||||||
};
|
|
||||||
|
|
||||||
$: if (additionalData) {
|
|
||||||
options.elevation.data = Object.keys(additionalData).filter((key) => additionalData[key]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let manualCamera = false;
|
let manualCamera = false;
|
||||||
@@ -53,36 +43,47 @@
|
|||||||
|
|
||||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||||
|
|
||||||
$: console.log(options);
|
$: iframeOptions =
|
||||||
</script>
|
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||||
|
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||||
|
: options;
|
||||||
|
|
||||||
<iframe
|
async function resizeMap() {
|
||||||
src={`../../embed?options=${encodeURIComponent(JSON.stringify(options))}${hash}`}
|
if ($map) {
|
||||||
style="width: 100%; height: 600px;"
|
await tick();
|
||||||
></iframe>
|
$map.resize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (options.elevation.height || options.elevation.show) {
|
||||||
|
resizeMap();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Card Title</Card.Title>
|
<Card.Title>{$_('embedding.title')}</Card.Title>
|
||||||
<Card.Description>Card Description</Card.Description>
|
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<fieldset class="flex flex-col gap-3">
|
<fieldset class="flex flex-col gap-3">
|
||||||
|
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
||||||
|
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||||
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
||||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||||
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
||||||
<Select.Root
|
<Select.Root
|
||||||
|
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
||||||
onSelectedChange={(selected) => {
|
onSelectedChange={(selected) => {
|
||||||
if (selected?.value) {
|
if (selected?.value) {
|
||||||
options.basemap = selected?.value;
|
options.basemap = selected?.value;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select.Trigger id="basemap" class="w-[180px] h-8">
|
<Select.Trigger id="basemap" class="w-full h-8">
|
||||||
<Select.Value />
|
<Select.Value />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||||
{#each Object.keys(basemaps) as basemap}
|
{#each allowedEmbeddingBasemaps as basemap}
|
||||||
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
@@ -102,13 +103,13 @@
|
|||||||
{$_('embedding.fill_by')}
|
{$_('embedding.fill_by')}
|
||||||
</span>
|
</span>
|
||||||
<Select.Root
|
<Select.Root
|
||||||
|
selected={{ value: 'none', label: $_('embedding.none') }}
|
||||||
onSelectedChange={(selected) => {
|
onSelectedChange={(selected) => {
|
||||||
if (selected?.value) {
|
let value = selected?.value;
|
||||||
if (selected?.value === 'none') {
|
if (value === 'none') {
|
||||||
options.elevation.fill = undefined;
|
options.elevation.fill = undefined;
|
||||||
} else {
|
} else if (value === 'slope' || value === 'surface') {
|
||||||
options.elevation.fill = selected?.value;
|
options.elevation.fill = value;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -127,35 +128,35 @@
|
|||||||
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-speed" bind:checked={additionalData.speed} />
|
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||||
<Zap size="16" />
|
<Zap size="16" />
|
||||||
{$_('chart.show_speed')}
|
{$_('chart.show_speed')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-hr" bind:checked={additionalData.hr} />
|
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||||
<HeartPulse size="16" />
|
<HeartPulse size="16" />
|
||||||
{$_('chart.show_heartrate')}
|
{$_('chart.show_heartrate')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-cad" bind:checked={additionalData.cad} />
|
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||||
<Orbit size="16" />
|
<Orbit size="16" />
|
||||||
{$_('chart.show_cadence')}
|
{$_('chart.show_cadence')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-temp" bind:checked={additionalData.temp} />
|
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||||
<Thermometer size="16" />
|
<Thermometer size="16" />
|
||||||
{$_('chart.show_temperature')}
|
{$_('chart.show_temperature')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-power" bind:checked={additionalData.power} />
|
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||||
<SquareActivity size="16" />
|
<SquareActivity size="16" />
|
||||||
{$_('chart.show_power')}
|
{$_('chart.show_power')}
|
||||||
@@ -204,6 +205,20 @@
|
|||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<Label>
|
||||||
|
{$_('embedding.preview')}
|
||||||
|
</Label>
|
||||||
|
<div class="relative h-[600px]">
|
||||||
|
<Embedding bind:options={iframeOptions} />
|
||||||
|
</div>
|
||||||
|
<Label>
|
||||||
|
{$_('embedding.code')}
|
||||||
|
</Label>
|
||||||
|
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||||
|
<code class="language-html">
|
||||||
|
{`<iframe src="https://gpx.studio${base}/embed?${JSON.stringify(getCleanedEmbeddingOptions(options))}${hash}" width="100%" height="600px" frameborder="0" />`}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { map, gpxLayers } from '$lib/stores';
|
import { map, gpxLayers } from '$lib/stores';
|
||||||
import { GPXLayer } from './GPXLayer';
|
import { GPXLayer } from './GPXLayer';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import WaypointPopup from './WaypointPopup.svelte';
|
import WaypointPopup from './WaypointPopup.svelte';
|
||||||
import { fileObservers } from '$lib/db';
|
import { fileObservers } from '$lib/db';
|
||||||
import { DistanceMarkers } from './DistanceMarkers';
|
import { DistanceMarkers } from './DistanceMarkers';
|
||||||
@@ -21,14 +20,14 @@
|
|||||||
// add layers for new files
|
// add layers for new files
|
||||||
$fileObservers.forEach((file, fileId) => {
|
$fileObservers.forEach((file, fileId) => {
|
||||||
if (!gpxLayers.has(fileId)) {
|
if (!gpxLayers.has(fileId)) {
|
||||||
gpxLayers.set(fileId, new GPXLayer(get(map), fileId, file));
|
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($map) {
|
$: if ($map) {
|
||||||
distanceMarkers = new DistanceMarkers(get(map));
|
distanceMarkers = new DistanceMarkers($map);
|
||||||
startEndMarkers = new StartEndMarkers(get(map));
|
startEndMarkers = new StartEndMarkers($map);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@@ -3,9 +3,14 @@ title: Integration
|
|||||||
---
|
---
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import EmbeddingPlaygound from '$lib/components/EmbeddingPlayground.svelte';
|
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||||
|
import EmbeddingPlaygound from '$lib/components/embedding/EmbeddingPlayground.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
# { title }
|
# { title }
|
||||||
|
|
||||||
|
<DocsNote>
|
||||||
|
This section is a work in progress.
|
||||||
|
</DocsNote>
|
||||||
|
|
||||||
<EmbeddingPlaygound />
|
<EmbeddingPlaygound />
|
||||||
|
@@ -372,6 +372,8 @@
|
|||||||
"identity_description": "The website is free to use, without ads, and the source code is publicly available on GitHub. This is only possible thanks to the incredible support of the community."
|
"identity_description": "The website is free to use, without ads, and the source code is publicly available on GitHub. This is only possible thanks to the incredible support of the community."
|
||||||
},
|
},
|
||||||
"embedding": {
|
"embedding": {
|
||||||
|
"title": "Create your own map",
|
||||||
|
"mapbox_token": "Mapbox access token",
|
||||||
"file_urls": "File URLs (separated by commas)",
|
"file_urls": "File URLs (separated by commas)",
|
||||||
"basemap": "Basemap",
|
"basemap": "Basemap",
|
||||||
"height": "Height",
|
"height": "Height",
|
||||||
@@ -384,6 +386,8 @@
|
|||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
"longitude": "Longitude",
|
"longitude": "Longitude",
|
||||||
"pitch": "Pitch",
|
"pitch": "Pitch",
|
||||||
"bearing": "Bearing"
|
"bearing": "Bearing",
|
||||||
|
"preview": "Preview",
|
||||||
|
"code": "Integration code"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -15,6 +15,8 @@
|
|||||||
import { gpxStatistics, loadFiles, slicedGPXStatistics } from '$lib/stores';
|
import { gpxStatistics, loadFiles, slicedGPXStatistics } from '$lib/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { languages } from '$lib/languages';
|
||||||
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
verticalFileView,
|
verticalFileView,
|
||||||
@@ -99,6 +101,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- hidden links for svelte crawling -->
|
||||||
|
<div class="hidden">
|
||||||
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
|
<a href={getURLForLanguage(lang, '/embed')}>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
div :global(.toaster.group) {
|
div :global(.toaster.group) {
|
||||||
@apply absolute;
|
@apply absolute;
|
||||||
|
@@ -1,38 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
|
|
||||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
|
||||||
import FileList from '$lib/components/file-list/FileList.svelte';
|
|
||||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
|
||||||
import MapComponent from '$lib/components/Map.svelte';
|
|
||||||
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
|
|
||||||
import OpenIn from '$lib/components/OpenIn.svelte';
|
|
||||||
|
|
||||||
import { gpxStatistics, slicedGPXStatistics, embedding, loadFile, map } from '$lib/stores';
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
import Embedding from '$lib/components/embedding/Embedding.svelte';
|
||||||
import { readable } from 'svelte/store';
|
import {
|
||||||
import type { GPXFile } from 'gpx';
|
getDefaultEmbeddingOptions,
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
type EmbeddingOptions
|
||||||
import { ListFileItem } from '$lib/components/file-list/FileList';
|
} from '$lib/components/embedding/Embedding';
|
||||||
|
|
||||||
$embedding = true;
|
let embeddingOptions: EmbeddingOptions | undefined = undefined;
|
||||||
|
|
||||||
const { currentBasemap, distanceUnits, velocityUnits, temperatureUnits } = settings;
|
|
||||||
|
|
||||||
let elevationProfile = true;
|
|
||||||
let bottomPanelSize = 170;
|
|
||||||
let additionalDatasets: string[] = [];
|
|
||||||
let elevationFill: 'slope' | 'surface' | undefined = undefined;
|
|
||||||
let elevationControls = true;
|
|
||||||
|
|
||||||
let files: string[] = [];
|
|
||||||
|
|
||||||
let prevUnits = {
|
|
||||||
distance: '',
|
|
||||||
velocity: '',
|
|
||||||
temperature: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let options = $page.url.searchParams.get('options');
|
let options = $page.url.searchParams.get('options');
|
||||||
@@ -43,189 +18,10 @@
|
|||||||
if (options === null) {
|
if (options === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
embeddingOptions = Object.assign(getDefaultEmbeddingOptions(), options);
|
||||||
if (options.files && Array.isArray(options.files)) {
|
|
||||||
files = options.files;
|
|
||||||
let downloads: Promise<GPXFile | null>[] = [];
|
|
||||||
options.files.forEach((url) => {
|
|
||||||
downloads.push(
|
|
||||||
fetch(url)
|
|
||||||
.then((response) => response.blob())
|
|
||||||
.then((blob) => new File([blob], url.split('/').pop()))
|
|
||||||
.then(loadFile)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.all(downloads).then((files) => {
|
|
||||||
let ids: string[] = [];
|
|
||||||
let bounds = {
|
|
||||||
southWest: {
|
|
||||||
lat: 90,
|
|
||||||
lon: 180
|
|
||||||
},
|
|
||||||
northEast: {
|
|
||||||
lat: -90,
|
|
||||||
lon: -180
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
files.forEach((file, index) => {
|
|
||||||
if (file === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = `gpx-${index}`;
|
|
||||||
file._data.id = id;
|
|
||||||
let statistics = new GPXStatisticsTree(file);
|
|
||||||
|
|
||||||
fileObservers.update(($fileObservers) => {
|
|
||||||
$fileObservers.set(
|
|
||||||
id,
|
|
||||||
readable({
|
|
||||||
file,
|
|
||||||
statistics
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return $fileObservers;
|
|
||||||
});
|
|
||||||
|
|
||||||
ids.push(id);
|
|
||||||
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
|
|
||||||
|
|
||||||
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
|
|
||||||
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
|
|
||||||
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
|
|
||||||
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
|
|
||||||
});
|
|
||||||
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.clear();
|
|
||||||
ids.forEach((id) => {
|
|
||||||
$selection.toggle(new ListFileItem(id));
|
|
||||||
});
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($page.url.hash.length === 0) {
|
|
||||||
map.subscribe(($map) => {
|
|
||||||
if ($map) {
|
|
||||||
$map.fitBounds(
|
|
||||||
[
|
|
||||||
bounds.southWest.lon,
|
|
||||||
bounds.southWest.lat,
|
|
||||||
bounds.northEast.lon,
|
|
||||||
bounds.northEast.lat
|
|
||||||
],
|
|
||||||
{
|
|
||||||
padding: 80,
|
|
||||||
linear: true,
|
|
||||||
easing: () => 1
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.basemap !== undefined && typeof options.basemap === 'string') {
|
|
||||||
currentBasemap.set(options.basemap);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.elevation !== undefined && typeof options.elevation === 'object') {
|
|
||||||
const elevationOptions = options.elevation;
|
|
||||||
if (elevationOptions.show !== undefined && typeof elevationOptions.show === 'boolean') {
|
|
||||||
elevationProfile = elevationOptions.show;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elevationOptions.data && Array.isArray(elevationOptions.data)) {
|
|
||||||
elevationOptions.data.forEach((dataset) => {
|
|
||||||
if (['speed', 'hr', 'cad', 'temp', 'power'].includes(dataset)) {
|
|
||||||
additionalDatasets.push(dataset);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elevationOptions.fill === 'slope' || elevationOptions.fill === 'surface') {
|
|
||||||
elevationFill = elevationOptions.fill;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elevationOptions.height !== undefined && typeof elevationOptions.height === 'number') {
|
|
||||||
bottomPanelSize = elevationOptions.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
elevationOptions.controls !== undefined &&
|
|
||||||
typeof elevationOptions.controls === 'boolean'
|
|
||||||
) {
|
|
||||||
elevationControls = elevationOptions.controls;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevUnits.distance = $distanceUnits;
|
|
||||||
prevUnits.velocity = $velocityUnits;
|
|
||||||
prevUnits.temperature = $temperatureUnits;
|
|
||||||
|
|
||||||
if (options.distanceUnits === 'metric' || options.distanceUnits === 'imperial') {
|
|
||||||
$distanceUnits = options.distanceUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.velocityUnits === 'speed' || options.velocityUnits === 'pace') {
|
|
||||||
$velocityUnits = options.velocityUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.temperatureUnits === 'celsius' || options.temperatureUnits === 'fahrenheit') {
|
|
||||||
$temperatureUnits = options.temperatureUnits;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if ($distanceUnits !== prevUnits.distance) {
|
|
||||||
$distanceUnits = prevUnits.distance;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($velocityUnits !== prevUnits.velocity) {
|
|
||||||
$velocityUnits = prevUnits.velocity;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($temperatureUnits !== prevUnits.temperature) {
|
|
||||||
$temperatureUnits = prevUnits.temperature;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="fixed flex flex-col h-full w-full border rounded-xl overflow-clip">
|
{#if embeddingOptions}
|
||||||
<div class="grow relative">
|
<Embedding options={embeddingOptions} />
|
||||||
<MapComponent class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}" geocoder={false} />
|
{/if}
|
||||||
<OpenIn bind:files />
|
|
||||||
<LayerControl />
|
|
||||||
<GPXLayers />
|
|
||||||
{#if $fileObservers.size > 1}
|
|
||||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
|
||||||
<FileList orientation="horizontal" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="{elevationProfile ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
|
|
||||||
style={elevationProfile ? `height: ${bottomPanelSize}px` : ''}
|
|
||||||
>
|
|
||||||
<GPXStatistics
|
|
||||||
{gpxStatistics}
|
|
||||||
{slicedGPXStatistics}
|
|
||||||
panelSize={bottomPanelSize}
|
|
||||||
orientation={elevationProfile ? 'vertical' : 'horizontal'}
|
|
||||||
/>
|
|
||||||
{#if elevationProfile}
|
|
||||||
<ElevationProfile
|
|
||||||
{gpxStatistics}
|
|
||||||
{slicedGPXStatistics}
|
|
||||||
{additionalDatasets}
|
|
||||||
{elevationFill}
|
|
||||||
panelSize={bottomPanelSize}
|
|
||||||
showControls={elevationControls}
|
|
||||||
class="py-2"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
Reference in New Issue
Block a user