Add support for nautical units (#61) (#66)

* Add support for nautical units

* Make panel reactive to unit changes even after pushing down conversions into utility functions.

* Fix issue: km->mph conversion was not done right.

* Add support for nautical units to the Time dialog.

* Add support for nautical units to the embedding view and embedding playground.

* add missing parameter and rename

* "npx prettier" pass on the files changed in this PR

Does not include changes to `website/src/lib/db.ts`, because there would otherwise be lots of unrelated formatting changes

* Change elevation unit to meters for 'nautical' distances.

* hide elevation decimals

---------

Co-authored-by: bdbkun <1308709+mbof@users.noreply.github.com>
Co-authored-by: vcoppe <vianney.coppe@gmail.com>
This commit is contained in:
mbof
2024-08-26 03:51:05 -07:00
committed by GitHub
parent d939ef2f60
commit 766ebe0275
8 changed files with 301 additions and 212 deletions

View File

@@ -333,6 +333,7 @@
<Menubar.RadioGroup bind:value={$distanceUnits}>
<Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem>
<Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem>
<Menubar.RadioItem value="nautical">{$_('menu.nautical')}</Menubar.RadioItem>
</Menubar.RadioGroup>
</Menubar.SubContent>
</Menubar.Sub>

View File

@@ -2,9 +2,12 @@
import { settings } from '$lib/db';
import {
celsiusToFahrenheit,
distancePerHourToSecondsPerDistance,
kilometersToMiles,
metersToFeet,
getConvertedDistance,
getConvertedElevation,
getConvertedVelocity,
getDistanceUnits,
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS
} from '$lib/units';
@@ -20,31 +23,18 @@
<span class={$$props.class}>
{#if type === 'distance'}
{#if $distanceUnits === 'metric'}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''}
{:else}
{kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
{/if}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''}
{:else if type === 'elevation'}
{#if $distanceUnits === 'metric'}
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''}
{:else}
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
{/if}
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{showUnits ? getElevationUnits($distanceUnits) : ''}
{:else if type === 'speed'}
{#if $distanceUnits === 'metric'}
{#if $velocityUnits === 'speed'}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
{showUnits ? $_('units.minutes_per_kilometer') : ''}
{/if}
{:else if $velocityUnits === 'speed'}
{kilometersToMiles(value).toFixed(decimals ?? 2)}
{showUnits ? $_('units.miles_per_hour') : ''}
{#if $velocityUnits === 'speed'}
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))}
{showUnits ? $_('units.minutes_per_mile') : ''}
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{/if}
{:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'}

View File

@@ -1,129 +1,141 @@
import { PUBLIC_MAPBOX_TOKEN } from "$env/static/public";
import { basemaps } from "$lib/assets/layers";
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { basemaps } from '$lib/assets/layers';
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,
},
distanceMarkers: boolean,
directionMarkers: boolean,
distanceUnits: 'metric' | 'imperial',
velocityUnits: 'speed' | 'pace',
temperatureUnits: 'celsius' | 'fahrenheit',
theme: 'system' | 'light' | 'dark',
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;
};
distanceMarkers: boolean;
directionMarkers: boolean;
distanceUnits: 'metric' | 'imperial' | 'nautical';
velocityUnits: 'speed' | 'pace';
temperatureUnits: 'celsius' | 'fahrenheit';
theme: 'system' | 'light' | 'dark';
};
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,
},
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system',
token: '',
files: [],
basemap: 'mapboxOutdoors',
elevation: {
show: true,
height: 170,
controls: true,
fill: undefined,
speed: false,
hr: false,
cad: false,
temp: false,
power: false
},
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system'
};
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
}
export function getMergedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): EmbeddingOptions {
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
for (const key in options) {
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
} else {
mergedOptions[key] = options[key];
}
}
return mergedOptions;
export function getMergedEmbeddingOptions(
options: any,
defaultOptions: any = defaultEmbeddingOptions
): EmbeddingOptions {
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
for (const key in options) {
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
} else {
mergedOptions[key] = options[key];
}
}
return mergedOptions;
}
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 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 = Object.keys(basemaps).filter(basemap => !['ordnanceSurvey'].includes(basemap));
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(
(basemap) => !['ordnanceSurvey'].includes(basemap)
);
export function getURLForGoogleDriveFile(fileId: string): string {
return `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=AIzaSyA2ZadQob_hXiT2VaYIkAyafPvz_4ZMssk`;
return `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=AIzaSyA2ZadQob_hXiT2VaYIkAyafPvz_4ZMssk`;
}
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
let newOptions: any = {
token: PUBLIC_MAPBOX_TOKEN,
files: [],
};
if (options.has('state')) {
let state = JSON.parse(options.get('state')!);
if (state.ids) {
newOptions.files.push(...state.ids.map(getURLForGoogleDriveFile));
}
if (state.urls) {
newOptions.files.push(...state.urls);
}
}
if (options.has('source')) {
let basemap = options.get('source')!;
if (basemap === 'satellite') {
newOptions.basemap = 'mapboxSatellite';
} else if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') {
newOptions.basemap = 'openHikingMap';
}
}
if (options.has('imperial')) {
newOptions.distanceUnits = 'imperial';
}
if (options.has('running')) {
newOptions.velocityUnits = 'pace';
}
if (options.has('distance')) {
newOptions.distanceMarkers = true;
}
if (options.has('direction')) {
newOptions.directionMarkers = true;
}
if (options.has('slope')) {
newOptions.elevation = {
fill: 'slope',
};
}
return newOptions;
}
let newOptions: any = {
token: PUBLIC_MAPBOX_TOKEN,
files: []
};
if (options.has('state')) {
let state = JSON.parse(options.get('state')!);
if (state.ids) {
newOptions.files.push(...state.ids.map(getURLForGoogleDriveFile));
}
if (state.urls) {
newOptions.files.push(...state.urls);
}
}
if (options.has('source')) {
let basemap = options.get('source')!;
if (basemap === 'satellite') {
newOptions.basemap = 'mapboxSatellite';
} else if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') {
newOptions.basemap = 'openHikingMap';
}
}
if (options.has('imperial')) {
newOptions.distanceUnits = 'imperial';
}
if (options.has('running')) {
newOptions.velocityUnits = 'pace';
}
if (options.has('distance')) {
newOptions.distanceMarkers = true;
}
if (options.has('direction')) {
newOptions.directionMarkers = true;
}
if (options.has('slope')) {
newOptions.elevation = {
fill: 'slope'
};
}
return newOptions;
}

View File

@@ -221,6 +221,10 @@
<RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">

View File

@@ -10,7 +10,8 @@
import {
distancePerHourToSecondsPerDistance,
getConvertedVelocity,
milesToKilometers
milesToKilometers,
nauticalMilesToKilometers
} from '$lib/units';
import { CalendarDate, type DateValue } from '@internationalized/date';
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
@@ -129,6 +130,8 @@
}
if ($distanceUnits === 'imperial') {
speedValue = milesToKilometers(speedValue);
} else if ($distanceUnits === 'nautical') {
speedValue = nauticalMilesToKilometers(speedValue);
}
return speedValue;
}
@@ -190,8 +193,10 @@
<span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'}
{$_('units.miles_per_hour')}
{:else}
{:else if $distanceUnits === 'metric'}
{$_('units.kilometers_per_hour')}
{:else if $distanceUnits === 'nautical'}
{$_('units.knots')}
{/if}
</span>
{:else}
@@ -204,8 +209,10 @@
<span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'}
{$_('units.minutes_per_mile')}
{:else}
{:else if $distanceUnits === 'metric'}
{$_('units.minutes_per_kilometer')}
{:else if $distanceUnits === 'nautical'}
{$_('units.minutes_per_nautical_mile')}
{/if}
</span>
{/if}

View File

@@ -80,7 +80,7 @@ export function dexieSettingStore<T>(key: string, initial: T, initialize: boolea
}
export const settings = {
distanceUnits: dexieSettingStore<'metric' | 'imperial'>('distanceUnits', 'metric'),
distanceUnits: dexieSettingStore<'metric' | 'imperial' | 'nautical'>('distanceUnits', 'metric'),
velocityUnits: dexieSettingStore<'speed' | 'pace'>('velocityUnits', 'speed'),
temperatureUnits: dexieSettingStore<'celsius' | 'fahrenheit'>('temperatureUnits', 'celsius'),
elevationProfile: dexieSettingStore('elevationProfile', true),

View File

@@ -5,143 +5,214 @@ import { _ } from 'svelte-i18n';
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
export function kilometersToMiles(value: number) {
return value * 0.621371;
return value * 0.621371;
}
export function milesToKilometers(value: number) {
return value * 1.60934;
return value * 1.60934;
}
export function metersToFeet(value: number) {
return value * 3.28084;
return value * 3.28084;
}
export function kilometersToNauticalMiles(value: number) {
return value * 0.539957;
}
export function nauticalMilesToKilometers(value: number) {
return value * 1.852;
}
export function celsiusToFahrenheit(value: number) {
return value * 1.8 + 32;
return value * 1.8 + 32;
}
export function distancePerHourToSecondsPerDistance(value: number) {
if (value === 0) {
return 0;
}
return 3600 / value;
if (value === 0) {
return 0;
}
return 3600 / value;
}
export function secondsToHHMMSS(value: number) {
var hours = Math.floor(value / 3600);
var minutes = Math.floor(value / 60) % 60;
var seconds = Math.min(59, Math.round(value % 60));
var hours = Math.floor(value / 3600);
var minutes = Math.floor(value / 60) % 60;
var seconds = Math.min(59, Math.round(value % 60));
return [hours, minutes, seconds]
.map((v) => (v < 10 ? '0' + v : v))
.filter((v, i) => v !== '00' || i > 0)
.join(':');
return [hours, minutes, seconds]
.map((v) => (v < 10 ? '0' + v : v))
.filter((v, i) => v !== '00' || i > 0)
.join(':');
}
// Get a string representation of the value with units
export function getDistanceWithUnits(value: number, convert: boolean = true) {
if (convert) {
return getConvertedDistance(value).toFixed(2) + ' ' + getDistanceUnits();
} else {
return value.toFixed(2) + ' ' + getDistanceUnits();
}
if (convert) {
return getConvertedDistance(value).toFixed(2) + ' ' + getDistanceUnits();
} else {
return value.toFixed(2) + ' ' + getDistanceUnits();
}
}
export function getVelocityWithUnits(value: number, convert: boolean = true) {
if (get(velocityUnits) === 'speed') {
if (convert) {
return getConvertedVelocity(value).toFixed(2) + ' ' + getVelocityUnits();
} else {
return value.toFixed(2) + ' ' + getVelocityUnits();
}
} else {
if (convert) {
return secondsToHHMMSS(getConvertedVelocity(value)) + ' ' + getVelocityUnits();
} else {
return secondsToHHMMSS(value) + ' ' + getVelocityUnits();
}
}
if (get(velocityUnits) === 'speed') {
if (convert) {
return getConvertedVelocity(value).toFixed(2) + ' ' + getVelocityUnits();
} else {
return value.toFixed(2) + ' ' + getVelocityUnits();
}
} else {
if (convert) {
return secondsToHHMMSS(getConvertedVelocity(value)) + ' ' + getVelocityUnits();
} else {
return secondsToHHMMSS(value) + ' ' + getVelocityUnits();
}
}
}
export function getElevationWithUnits(value: number, convert: boolean = true) {
if (convert) {
return getConvertedElevation(value).toFixed(0) + ' ' + getElevationUnits();
} else {
return value.toFixed(0) + ' ' + getElevationUnits();
}
if (convert) {
return getConvertedElevation(value).toFixed(0) + ' ' + getElevationUnits();
} else {
return value.toFixed(0) + ' ' + getElevationUnits();
}
}
export function getHeartRateWithUnits(value: number) {
return value.toFixed(0) + ' ' + getHeartRateUnits();
return value.toFixed(0) + ' ' + getHeartRateUnits();
}
export function getCadenceWithUnits(value: number) {
return value.toFixed(0) + ' ' + getCadenceUnits();
return value.toFixed(0) + ' ' + getCadenceUnits();
}
export function getPowerWithUnits(value: number) {
return value.toFixed(0) + ' ' + getPowerUnits();
return value.toFixed(0) + ' ' + getPowerUnits();
}
export function getTemperatureWithUnits(value: number, convert: boolean = true) {
if (convert) {
return getConvertedTemperature(value).toFixed(0) + ' ' + getTemperatureUnits();
} else {
return value.toFixed(0) + ' ' + getTemperatureUnits();
}
if (convert) {
return getConvertedTemperature(value).toFixed(0) + ' ' + getTemperatureUnits();
} else {
return value.toFixed(0) + ' ' + getTemperatureUnits();
}
}
// Get the units
export function getDistanceUnits() {
return get(distanceUnits) === 'metric' ? get(_)('units.kilometers') : get(_)('units.miles');
export function getDistanceUnits(targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.kilometers');
case 'imperial':
return get(_)('units.miles');
case 'nautical':
return get(_)('units.nautical_miles');
}
}
export function getVelocityUnits() {
if (get(velocityUnits) === 'speed') {
return get(distanceUnits) === 'metric' ? get(_)('units.kilometers_per_hour') : get(_)('units.miles_per_hour');
} else {
return get(distanceUnits) === 'metric' ? get(_)('units.minutes_per_kilometer') : get(_)('units.minutes_per_mile');
}
export function getVelocityUnits(
targetVelocityUnits = get(velocityUnits),
targetDistanceUnits = get(distanceUnits)
) {
if (targetVelocityUnits === 'speed') {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.kilometers_per_hour');
case 'imperial':
return get(_)('units.miles_per_hour');
case 'nautical':
return get(_)('units.knots');
}
} else {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.minutes_per_kilometer');
case 'imperial':
return get(_)('units.minutes_per_mile');
case 'nautical':
return get(_)('units.minutes_per_nautical_mile');
}
}
}
export function getElevationUnits() {
return get(distanceUnits) === 'metric' ? get(_)('units.meters') : get(_)('units.feet');
export function getElevationUnits(targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.meters');
case 'imperial':
return get(_)('units.feet');
case 'nautical':
// See https://github.com/gpxstudio/gpx.studio/pull/66#issuecomment-2306568997
return get(_)('units.meters');
}
}
export function getHeartRateUnits() {
return get(_)('units.heartrate');
return get(_)('units.heartrate');
}
export function getCadenceUnits() {
return get(_)('units.cadence');
return get(_)('units.cadence');
}
export function getPowerUnits() {
return get(_)('units.power');
return get(_)('units.power');
}
export function getTemperatureUnits() {
return get(temperatureUnits) === 'celsius' ? get(_)('units.celsius') : get(_)('units.fahrenheit');
return get(temperatureUnits) === 'celsius' ? get(_)('units.celsius') : get(_)('units.fahrenheit');
}
// Convert only the value
export function getConvertedDistance(value: number) {
return get(distanceUnits) === 'metric' ? value : kilometersToMiles(value);
export function getConvertedDistance(value: number, targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
}
export function getConvertedElevation(value: number) {
return get(distanceUnits) === 'metric' ? value : metersToFeet(value);
export function getConvertedElevation(value: number, targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return metersToFeet(value);
case 'nautical':
return value;
}
}
export function getConvertedVelocity(value: number) {
if (get(velocityUnits) === 'speed') {
return get(distanceUnits) === 'metric' ? value : kilometersToMiles(value);
} else {
return get(distanceUnits) === 'metric' ? distancePerHourToSecondsPerDistance(value) : distancePerHourToSecondsPerDistance(kilometersToMiles(value));
}
export function getConvertedVelocity(
value: number,
targetVelocityUnits = get(velocityUnits),
targetDistanceUnits = get(distanceUnits)
) {
if (targetVelocityUnits === 'speed') {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
} else {
switch (targetDistanceUnits) {
case 'metric':
return distancePerHourToSecondsPerDistance(value);
case 'imperial':
return distancePerHourToSecondsPerDistance(kilometersToMiles(value));
case 'nautical':
return distancePerHourToSecondsPerDistance(kilometersToNauticalMiles(value));
}
}
}
export function getConvertedTemperature(value: number) {
return get(temperatureUnits) === 'celsius' ? value : celsiusToFahrenheit(value);
}
return get(temperatureUnits) === 'celsius' ? value : celsiusToFahrenheit(value);
}

View File

@@ -41,6 +41,7 @@
"distance_units": "Distance units",
"metric": "Metric",
"imperial": "Imperial",
"nautical": "Nautical",
"velocity_units": "Velocity units",
"temperature_units": "Temperature units",
"celsius": "Celsius",
@@ -363,12 +364,15 @@
"feet": "ft",
"kilometers": "km",
"miles": "mi",
"nautical_miles": "nm",
"celsius": "°C",
"fahrenheit": "°F",
"kilometers_per_hour": "km/h",
"miles_per_hour": "mph",
"minutes_per_kilometer": "min/km",
"minutes_per_mile": "min/mi",
"minutes_per_nautical_mile": "min/nm",
"knots": "kn",
"heartrate": "bpm",
"cadence": "rpm",
"power": "W"