Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/components/layout/CenterCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@
const _hasActivity = app.hasActivity
void app.gpxFilename // reactive dep: re-run when GPX changes
void app.units // reactive dep: the unit-system preference flips readouts
void app.compactUnits // reactive dep: the km/h↔kmh style flips readouts
void previewW // reactive dep: re-render when the base target size changes
void previewH // (export resolution, preview box size, or display pixel ratio)

Expand Down
11 changes: 11 additions & 0 deletions app/src/components/overlays/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@
]}
onchange={(v) => (app.units = v)}
/>
<p class="text-[11px] text-zinc-500">
Speed unit style — how compound speed units are written.
</p>
<Select
value={app.compactUnits ? 'compact' : 'slash'}
options={[
{ value: 'slash', label: 'km/h' },
{ value: 'compact', label: 'kmh' },
]}
onchange={(v) => (app.compactUnits = v === 'compact')}
/>
</div>

<!-- Rider weight — powers the W/kg metric. Stored on this device only and
Expand Down
37 changes: 36 additions & 1 deletion app/src/components/panels/ElementProperties.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@
const metricLabel = (m) => METRIC_LABELS[m] ?? (isCustomMetric(m) ? customMetricLabel(m) : m)
const ALL_PLOT_METRICS = ['elevation', 'speed', 'heartrate', 'power', 'cadence', 'gradient', 'temperature', 'front_gear', 'rear_gear', 'course', 'distance']
const ALL_METER_METRICS = ['speed', 'heartrate', 'power', 'power_to_weight', 'elevation', 'cadence', 'gradient', 'temperature', 'front_gear', 'rear_gear']
// Metrics a label can represent the *unit* of (those with a metric/imperial
// distinction, mirroring units::has_unit_system on the Rust side). Picking one
// makes the label render that unit (e.g. "mph"/"km/h") and track the settings
// toggle, instead of frozen static text.
const UNIT_OF_OPTIONS = [
{ value: '', label: 'Off (static text)' },
{ value: 'speed', label: 'Speed (mph / km/h)' },
{ value: 'distance', label: 'Distance (mi / km)' },
{ value: 'elevation', label: 'Elevation (ft / m)' },
{ value: 'temperature', label: 'Temperature (°F / °C)' },
]

function filterMetrics(list) {
const valid = app.activityMetrics
Expand Down Expand Up @@ -376,6 +387,19 @@
function effectiveUnit(metric, unit) {
return unit == null ? sceneUnit(metric) : displayUnit(metric, unit)
}
// Human unit label for a `unit_of` label under the active setting, mirroring
// units::unit_label on the Rust side. Used only for the editor hint; the
// rendered text is resolved in Rust.
function effectiveUnitLabel(metric) {
const imperial = sceneUnitSystem() === 'imperial'
const label = {
speed: imperial ? 'mph' : 'km/h',
distance: imperial ? 'mi' : 'km',
elevation: imperial ? 'ft' : 'm',
temperature: imperial ? '°F' : '°C',
}[metric] ?? ''
return app.compactUnits ? label.replace('/', '') : label
}
// Value shown in a unit picker: '' = Auto (inherit scene), else the concrete
// token (legacy metric/imperial map onto their precise option).
function unitValue(metric, unit) {
Expand Down Expand Up @@ -1114,7 +1138,18 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
<!-- Text: all text properties in one place -->
<section class="mb-4 space-y-2">
<p class="text-[10px] uppercase tracking-wider text-zinc-600">Text</p>
<Input value={item.text ?? ''} oninput={(e) => update('text', e.target.value)} />
{#if item.unit_of}
<p class="text-[11px] leading-relaxed text-zinc-500">
Shows the {UNIT_OF_OPTIONS.find((o) => o.value === item.unit_of)?.label.split(' ')[0].toLowerCase() ?? item.unit_of} unit
(e.g. {effectiveUnitLabel(item.unit_of)}) and follows your unit setting.
</p>
{:else}
<Input value={item.text ?? ''} oninput={(e) => update('text', e.target.value)} />
{/if}
<label class="space-y-1 block">
<span class="text-xs text-zinc-500">Unit label</span>
<Select value={item.unit_of ?? ''} options={UNIT_OF_OPTIONS} onchange={(v) => update('unit_of', v || undefined)} />
</label>
<label class="space-y-1 block">
<span class="text-xs text-zinc-500">Font</span>
<Select value={item.font ?? ''} options={fontOpts(true)} onchange={(v) => update('font', v || undefined)} />
Expand Down
8 changes: 7 additions & 1 deletion app/src/lib/elementTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,13 @@ export const ADD_PRESETS = [
export function elementMeta(el) {
if (!el) return { icon: 'bar', name: '', kind: 'plot', unit: null }
if (el.type === 'label')
return { icon: 'type', name: el.text || 'Label', kind: 'label', unit: null }
return {
icon: 'type',
// Unit labels have no static text; name them after the metric they track.
name: el.unit_of ? `${el.unit_of} unit` : el.text || 'Label',
kind: 'label',
unit: null,
}
if (el.type === 'value')
return {
icon: 'hash',
Expand Down
33 changes: 29 additions & 4 deletions app/src/state/appState.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ export function createAppState() {
localStorage.getItem('units') ??
(_persisted?.scene?.units === 'imperial' ? 'imperial' : 'metric'),
)
// Compound-unit style for compound speed units: false → "km/h" (default),
// true → "kmh". App-level like `units`: stored on this device, injected into
// scene.compact_units at render, never saved into the template.
let compactUnits = $state(
(localStorage.getItem('compactUnits') ??
String(_persisted?.scene?.compact_units === true)) === 'true',
)
let outputDir = $state(localStorage.getItem('outputDir') ?? null)
let defaultOutputDir = $state(null)
let outputWidth = $state(
Expand Down Expand Up @@ -236,6 +243,9 @@ export function createAppState() {
$effect(() => {
localStorage.setItem('units', units)
})
$effect(() => {
localStorage.setItem('compactUnits', String(compactUnits))
})
$effect(() => {
localStorage.setItem('outputWidth', String(outputWidth))
})
Expand Down Expand Up @@ -643,7 +653,9 @@ export function createAppState() {
// to every element left on Auto; rider weight rides as a separate render
// argument (riderWeightKg).
function withAppSettings(cfg) {
return cfg ? { ...cfg, scene: { ...cfg.scene, units } } : cfg
return cfg
? { ...cfg, scene: { ...cfg.scene, units, compact_units: compactUnits } }
: cfg
}

function updateScene(updates) {
Expand Down Expand Up @@ -840,11 +852,16 @@ export function createAppState() {
return base
})

// units moved to an app-level preference (see `units` state) — strip the
// legacy scene field so it never rides in saved templates again.
// units and compact_units moved to app-level preferences (see `units` /
// `compactUnits` state) — strip the scene fields so they never ride in
// saved templates again.
const sceneBase = Object.fromEntries(
Object.entries(config.scene ?? {}).filter(
([k]) => k !== 'editor' && k !== 'groups' && k !== 'units',
([k]) =>
k !== 'editor' &&
k !== 'groups' &&
k !== 'units' &&
k !== 'compact_units',
),
)
return { ...config, scene: { ...sceneBase, groups }, elements }
Expand Down Expand Up @@ -1489,6 +1506,14 @@ export function createAppState() {
set units(v) {
units = v === 'imperial' ? 'imperial' : 'metric'
},
// Compact compound-unit style ("km/h" vs "kmh"); app-level, injected into
// scene.compact_units, never saved to templates.
get compactUnits() {
return compactUnits
},
set compactUnits(v) {
compactUnits = v === true || v === 'true' || v === 'compact'
},
// Config with app-level settings (unit system) injected — what every
// render/preview call sends; template saves keep using `config`.
get renderConfig() {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/render-core/src/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2149,6 +2149,7 @@ mod tests {
groups: Vec::new(),
vars: std::collections::HashMap::new(),
units: None,
compact_units: None,
}
}

Expand Down
89 changes: 70 additions & 19 deletions src-tauri/render-core/src/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,9 +604,10 @@ impl ChartCache {
// Pre-format all lines and find max width to decide which side
// to place the label block. Near the right edge the block flips
// left so it never overflows the chart surface.
let compact = pl.compact_units.unwrap_or(false);
let lines: Vec<String> = units
.iter()
.map(|unit| format_point_label(raw, &self.value_attr, unit, dec))
.map(|unit| format_point_label(raw, &self.value_attr, unit, dec, compact))
.collect();
let max_text_w = lines
.iter()
Expand Down Expand Up @@ -637,9 +638,13 @@ impl ChartCache {
/// Format a plotted value for a point label in the requested unit system,
/// producing "<number> <SUFFIX>" (e.g. "960 M", "3150 FT"). `raw` is the
/// attribute's native unit (elevation: metres, speed: m/s, temp: °C).
fn format_point_label(raw: f64, attr: &str, unit: &str, decimals: i32) -> String {
fn format_point_label(raw: f64, attr: &str, unit: &str, decimals: i32, compact: bool) -> String {
let (conv, suffix) = units::resolve(attr, Some(unit));
format!("{} {}", round_str(conv.apply(raw), decimals), suffix)
format!(
"{} {}",
round_str(conv.apply(raw), decimals),
units::compact_unit(&suffix, compact)
)
}

fn round_str(v: f64, decimals: i32) -> String {
Expand Down Expand Up @@ -1112,42 +1117,88 @@ mod tests {
#[test]
fn elevation_metric_and_imperial_match_screenshot_format() {
// 960 m -> "960 M"; 960 * 3.28084 = 3149.6 -> "3150 FT"
assert_eq!(format_point_label(960.0, "elevation", "metric", 0), "960 M");
assert_eq!(
format_point_label(960.0, "elevation", "imperial", 0),
format_point_label(960.0, "elevation", "metric", 0, false),
"960 M"
);
assert_eq!(
format_point_label(960.0, "elevation", "imperial", 0, false),
"3150 FT"
);
}

#[test]
fn speed_temperature_and_decimals() {
assert_eq!(format_point_label(10.0, "speed", "metric", 0), "36 KM/H");
assert_eq!(format_point_label(10.0, "speed", "imperial", 0), "22 MPH");
assert_eq!(
format_point_label(0.0, "temperature", "imperial", 0),
format_point_label(10.0, "speed", "metric", 0, false),
"36 KM/H"
);
assert_eq!(
format_point_label(10.0, "speed", "imperial", 0, false),
"22 MPH"
);
assert_eq!(
format_point_label(0.0, "temperature", "imperial", 0, false),
"32 F"
);
assert_eq!(
format_point_label(959.74, "elevation", "metric", 1),
format_point_label(959.74, "elevation", "metric", 1, false),
"959.7 M"
);
}

#[test]
fn unknown_attribute_echoes_unit_uppercased() {
assert_eq!(format_point_label(5.0, "power", "watts", 0), "5 WATTS");
assert_eq!(
format_point_label(5.0, "power", "watts", 0, false),
"5 WATTS"
);
}

#[test]
fn precise_unit_tokens() {
assert_eq!(format_point_label(10.0, "speed", "kmh", 0), "36 KM/H");
assert_eq!(format_point_label(10.0, "speed", "mph", 0), "22 MPH");
assert_eq!(format_point_label(10.0, "speed", "ms", 0), "10 M/S");
assert_eq!(format_point_label(5000.0, "distance", "km", 0), "5 KM");
assert_eq!(format_point_label(5000.0, "distance", "m", 0), "5000 M");
assert_eq!(format_point_label(1609.34, "distance", "mi", 0), "1 MI");
assert_eq!(format_point_label(960.0, "elevation", "ft", 0), "3150 FT");
assert_eq!(format_point_label(0.0, "temperature", "f", 0), "32 F");
assert_eq!(format_point_label(20.0, "temperature", "c", 0), "20 C");
assert_eq!(
format_point_label(10.0, "speed", "kmh", 0, false),
"36 KM/H"
);
assert_eq!(format_point_label(10.0, "speed", "mph", 0, false), "22 MPH");
assert_eq!(format_point_label(10.0, "speed", "ms", 0, false), "10 M/S");
assert_eq!(
format_point_label(5000.0, "distance", "km", 0, false),
"5 KM"
);
assert_eq!(
format_point_label(5000.0, "distance", "m", 0, false),
"5000 M"
);
assert_eq!(
format_point_label(1609.34, "distance", "mi", 0, false),
"1 MI"
);
assert_eq!(
format_point_label(960.0, "elevation", "ft", 0, false),
"3150 FT"
);
assert_eq!(
format_point_label(0.0, "temperature", "f", 0, false),
"32 F"
);
assert_eq!(
format_point_label(20.0, "temperature", "c", 0, false),
"20 C"
);
}

/// Compact style drops the "/" in compound speed units for chart point
/// labels too; non-compound units are unaffected.
#[test]
fn compact_units_drop_the_slash() {
assert_eq!(format_point_label(10.0, "speed", "kmh", 0, true), "36 KMH");
assert_eq!(format_point_label(10.0, "speed", "ms", 0, true), "10 MS");
assert_eq!(format_point_label(10.0, "speed", "mph", 0, true), "22 MPH");
assert_eq!(
format_point_label(5000.0, "distance", "km", 0, true),
"5 KM"
);
}
}
1 change: 1 addition & 0 deletions src-tauri/render-core/src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1893,6 +1893,7 @@ fn resolve_suffix(cfg: &ValueConfig) -> Option<String> {
Some("auto") => {
let unit_attr = unit_base_metric(&cfg.value);
units::display_suffix(unit_attr, cfg.unit.as_deref())
.map(|s| units::compact_unit(&s, cfg.compact_units.unwrap_or(false)))
}
_ => cfg.suffix.clone().filter(|s| !s.is_empty()),
}
Expand Down
Loading
Loading