From fc675f9ca2a899b381a143695b57cc04aa032272 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:35:40 +0000 Subject: [PATCH] feat: expose unmodelled source fields as custom metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cyclemetry models a fixed set of telemetry attributes, so any other numeric channel a rider's device records — lean angle, g-force, core temperature, a Connect IQ developer field — is dropped at parse time no matter that the file carries it. Adding one has meant editing ~16 hardcoded sites across the parsers, the resampler, the accessors and the editor's metric lists, which is why the list has stayed short. Capture them generically instead. Each numeric GPX/TCX leaf and each FIT record field outside the modelled profile (developer fields included) becomes a `custom:` metric backed by a series in Activity::custom, discovered per activity rather than declared in code. They flow through valid_attributes like any built-in, so they reach the editor's value, plot, meter and color-by pickers with no per-metric wiring, and render with the identity unit conversion — what the file recorded is what shows, with the element's manual suffix. Guards on what becomes a metric: names already feeding a built-in attribute are skipped so a channel can't appear twice, non-numeric and container elements are rejected, and the field count is capped (per point, and again over the union across points) so an open-ended namespace can't turn into hundreds of series and an unusable dropdown. A field missing from individual points — a sensor sampling slower than the GPS track — holds its last reading rather than dropping to zero. Resampling previously duplicated the same series list across sample_at_index and sample_between. Both collapse into SamplePos, which resolves a frame's grid position once and then reads any series from it, so the open-ended custom map rides the existing interpolate/trim path without a per-frame allocation. Addresses the generalisation half of #38; no built-in metric changes behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BkMQneMjCeBXkpdCpTucTE --- .../panels/ElementProperties.svelte | 35 +- src-tauri/render-core/src/activity.rs | 531 ++++++++++++++++-- src-tauri/render-core/src/frame.rs | 53 ++ 3 files changed, 559 insertions(+), 60 deletions(-) diff --git a/app/src/components/panels/ElementProperties.svelte b/app/src/components/panels/ElementProperties.svelte index 036fe8d..015ee19 100644 --- a/app/src/components/panels/ElementProperties.svelte +++ b/app/src/components/panels/ElementProperties.svelte @@ -75,6 +75,27 @@ // the scene-level start/finish gate (scene.lap_gate) — see the race bar in // PlaybackControls. They need GPS course data to detect crossings. + // Custom source fields: numeric channels the loaded file carried that have + // no built-in attribute — GPX/TCX leaves, FIT developer fields. + // Unlike every list above they're discovered per activity rather than + // declared here, so they come straight off the loaded activity's metrics. + const CUSTOM_PREFIX = 'custom:' + const isCustomMetric = (m) => typeof m === 'string' && m.startsWith(CUSTOM_PREFIX) + + // Source field names are whatever the recording device wrote ("lean_angle", + // "gForceX", "Lean Angle"). Soften them into something readable — split + // snake/kebab/camel case, sentence-case the result — without pretending to + // know what the field means. + function customMetricLabel(m) { + const raw = m.slice(CUSTOM_PREFIX.length) + const words = raw + .replace(/[_-]+/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim() + return words ? words.charAt(0).toUpperCase() + words.slice(1) : raw + } + // Friendly labels for metrics whose raw key isn't self-explanatory. const METRIC_LABELS = { power_to_weight: 'W/kg', @@ -99,7 +120,7 @@ laps_to_go: 'Laps to go', lap_fraction: 'Lap count (2/20)', } - const metricLabel = (m) => METRIC_LABELS[m] ?? m + 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'] @@ -133,6 +154,9 @@ return valid.includes('course') ? list : [] } + // A custom field is just a numeric series, so it works anywhere a built-in + // live metric does: value readouts, plots, meters and color-by bands. + const CUSTOM_METRICS = $derived((app.activityMetrics ?? []).filter(isCustomMetric)) const METRICS = $derived(filterMetrics(ALL_METRICS)) const SUMMARY_METRICS = $derived(filterSummary(ALL_SUMMARY_METRICS)) const RUNNING_METRICS = $derived(filterRunning(ALL_RUNNING_METRICS)) @@ -141,12 +165,13 @@ // summary metrics, each in its own labeled group. const VALUE_METRIC_OPTIONS = $derived([ ...METRICS.map((m) => ({ value: m, label: metricLabel(m), group: 'Live' })), + ...CUSTOM_METRICS.map((m) => ({ value: m, label: metricLabel(m), group: 'Custom' })), ...RUNNING_METRICS.map((m) => ({ value: m, label: metricLabel(m), group: 'Running' })), ...LAP_METRICS.map((m) => ({ value: m, label: metricLabel(m), group: 'Laps' })), ...SUMMARY_METRICS.map((m) => ({ value: m, label: metricLabel(m), group: 'Summary' })), ]) - const PLOT_METRICS = $derived(filterMetrics(ALL_PLOT_METRICS)) - const METER_METRICS = $derived(filterMetrics(ALL_METER_METRICS)) + const PLOT_METRICS = $derived([...filterMetrics(ALL_PLOT_METRICS), ...CUSTOM_METRICS]) + const METER_METRICS = $derived([...filterMetrics(ALL_METER_METRICS), ...CUSTOM_METRICS]) const METER_DIRECTIONS = [ { value: 'up', label: 'Fill upward' }, { value: 'down', label: 'Fill downward' }, @@ -155,7 +180,7 @@ ] // Metrics that can drive plot band colors (must have per-frame plot data). const ALL_COLOR_BY_METRICS = ['gradient', 'speed', 'power', 'heartrate', 'cadence', 'temperature', 'elevation'] - const COLOR_BY_METRICS = $derived(filterMetrics(ALL_COLOR_BY_METRICS)) + const COLOR_BY_METRICS = $derived([...filterMetrics(ALL_COLOR_BY_METRICS), ...CUSTOM_METRICS]) const COLOR_BY_MODES = [ { value: 'fill', label: 'Fill under curve' }, { value: 'line', label: 'Line' }, @@ -1401,7 +1426,7 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual

Metric