Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+230
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Dark theme for a screen someone stares at while suffering (FR-9.12).
|
||||
*
|
||||
* Rules this stylesheet follows:
|
||||
* - Near-black ground, bright data. Contrast comes from the numbers, not
|
||||
* from boxes.
|
||||
* - No borders unless they carry meaning. Structure comes from space.
|
||||
* - Numbers are tabular so they do not jitter as digits change.
|
||||
* - Type scales with the viewport: the primary readouts must be legible from
|
||||
* a riding position about a metre away (FR-9.5).
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #05070a;
|
||||
--bg-lift: #0b0f15;
|
||||
--hairline: #161c25;
|
||||
|
||||
--ink: #f4f7fb;
|
||||
--ink-soft: #9fb0c2;
|
||||
--ink-dim: #5d6c7d;
|
||||
--ink-faint: #313d4a;
|
||||
|
||||
--route: #45d0ff;
|
||||
--route-deep: #123a4d;
|
||||
--climb: #ff9a3c;
|
||||
--climb-deep: #3d2712;
|
||||
--ok: #35d9a0;
|
||||
--warn: #ffcf4a;
|
||||
--bad: #ff5a52;
|
||||
--power: #dfe8f3;
|
||||
--power-raw: #3f4d5d;
|
||||
|
||||
--gap: clamp(0.75rem, 1.4vw, 1.5rem);
|
||||
--edge: clamp(1rem, 2.4vw, 2.75rem);
|
||||
|
||||
color-scheme: dark;
|
||||
font-synthesis: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
'Inter var', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', system-ui, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum' 1, 'cv01' 1;
|
||||
letter-spacing: -0.01em;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---------- shared primitives ---------------------------------------- */
|
||||
|
||||
.label {
|
||||
font-size: clamp(0.6rem, 0.72vw, 0.78rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hairline {
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45em;
|
||||
padding: 0.3em 0.7em;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-lift);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tone-ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.tone-warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.tone-bad {
|
||||
color: var(--bad);
|
||||
}
|
||||
.tone-idle {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
padding: 0.62em 1.05em;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-lift);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease,
|
||||
transform 90ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #131a24;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--route);
|
||||
color: #04121a;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: #6cdcff;
|
||||
color: #04121a;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn.ghost:hover {
|
||||
background: var(--bg-lift);
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
background: #2a1113;
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.btn[disabled] {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
display: inline-block;
|
||||
min-width: 1.5em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.28em;
|
||||
background: #10161f;
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ---------- uPlot, restyled for the dark theme ------------------------ */
|
||||
|
||||
.uplot,
|
||||
.u-wrap {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.u-title,
|
||||
.u-legend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.u-axis {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.u-select {
|
||||
background: rgba(69, 208, 255, 0.12);
|
||||
}
|
||||
|
||||
.u-cursor-x,
|
||||
.u-cursor-y {
|
||||
border-color: var(--ink-faint) !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #1b232e;
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* One number, sized by importance. The whole ride screen is built from these,
|
||||
* which is how the visual hierarchy stays honest: importance is a prop, not a
|
||||
* pile of one-off styles.
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
size?: 'hero' | 'big' | 'mid' | 'small';
|
||||
colour?: string;
|
||||
sub?: string | null;
|
||||
dim?: boolean;
|
||||
align?: 'start' | 'end';
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value,
|
||||
unit = '',
|
||||
size = 'mid',
|
||||
colour = 'var(--ink)',
|
||||
sub = null,
|
||||
dim = false,
|
||||
align = 'start',
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
||||
<span class="label">{label}</span>
|
||||
<span class="value" style:color={colour}>
|
||||
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
||||
</span>
|
||||
{#if sub}<span class="sub">{sub}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.readout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 300;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.035em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 0.34em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
margin-left: 0.22em;
|
||||
color: var(--ink-dim);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero .value {
|
||||
font-size: clamp(3.4rem, 7vw, 7.2rem);
|
||||
}
|
||||
.big .value {
|
||||
font-size: clamp(2.4rem, 4.6vw, 4.8rem);
|
||||
}
|
||||
.mid .value {
|
||||
font-size: clamp(1.6rem, 2.6vw, 2.8rem);
|
||||
font-weight: 400;
|
||||
}
|
||||
.small .value {
|
||||
font-size: clamp(1.05rem, 1.5vw, 1.65rem);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dim .value {
|
||||
color: var(--ink-dim) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The hero element: the route, with the rider's position on it.
|
||||
*
|
||||
* For a GPX or terrain profile this is a real elevation trace. For a
|
||||
* waveform profile there is no elevation, so it plots the profile's own
|
||||
* channel instead — same chart, same marker, honest label (FR-9.7).
|
||||
*
|
||||
* The ridden part is drawn bright and filled; what is still to come is dim.
|
||||
* At a glance, from a metre away, that is the one thing a rider wants: how
|
||||
* much of this is left, and does it go up.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { axis, observeSize, positionMarker } from '../lib/uplot';
|
||||
import type { ProfileView } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
profile: ProfileView | null;
|
||||
positionX: number;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
let { profile, positionX, revision }: Props = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let plot: uPlot | null = null;
|
||||
let disposeSize: (() => void) | null = null;
|
||||
let currentX = 0;
|
||||
|
||||
/** Elevation when we have it, otherwise the profile's own channel. */
|
||||
const source = $derived.by(() => {
|
||||
if (!profile) return null;
|
||||
const pairs = profile.elevation ?? profile.series;
|
||||
if (!pairs || pairs.length < 2) return null;
|
||||
return { pairs, isElevation: profile.elevation != null };
|
||||
});
|
||||
|
||||
function build(): void {
|
||||
if (!host || !source) return;
|
||||
plot?.destroy();
|
||||
plot = null;
|
||||
|
||||
const xs = source.pairs.map((p) => p[0]);
|
||||
const ys = source.pairs.map((p) => p[1]);
|
||||
const isMetres = profile?.xUnit === 'metres';
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
width: host.clientWidth || 800,
|
||||
height: host.clientHeight || 260,
|
||||
padding: [12, 8, 0, 0],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
scales: { x: { time: false } },
|
||||
axes: [
|
||||
axis({
|
||||
values: (_u, splits) =>
|
||||
splits.map((v) => (isMetres ? `${(v / 1000).toFixed(1)}` : formatMinutes(v))),
|
||||
}),
|
||||
axis({
|
||||
side: 3,
|
||||
size: 46,
|
||||
values: (_u, splits) => splits.map((v) => v.toFixed(0)),
|
||||
}),
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
// Everything still to come.
|
||||
stroke: '#2b4d5e',
|
||||
width: 1,
|
||||
fill: 'rgba(24, 58, 76, 0.55)',
|
||||
points: { show: false },
|
||||
},
|
||||
{
|
||||
// Ridden so far.
|
||||
stroke: '#45d0ff',
|
||||
width: 2,
|
||||
fill: 'rgba(69, 208, 255, 0.22)',
|
||||
points: { show: false },
|
||||
},
|
||||
],
|
||||
plugins: [positionMarker(() => currentX, '#ffffff')],
|
||||
};
|
||||
|
||||
plot = new uPlot(opts, [xs, ys, ys.map(() => null)] as unknown as uPlot.AlignedData, host);
|
||||
disposeSize?.();
|
||||
disposeSize = observeSize(host, (w, h) => plot?.setSize({ width: w, height: h }));
|
||||
update();
|
||||
}
|
||||
|
||||
function formatMinutes(v: number): string {
|
||||
const m = Math.round(v / 60);
|
||||
return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Split the trace at the current position; only the split array changes. */
|
||||
function update(): void {
|
||||
if (!plot || !source) return;
|
||||
currentX = positionX;
|
||||
const xs = source.pairs;
|
||||
const ridden: (number | null)[] = new Array(xs.length);
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
ridden[i] = xs[i][0] <= positionX ? xs[i][1] : null;
|
||||
}
|
||||
// Carry one point past the split so the bright fill meets the marker.
|
||||
const idx = ridden.findIndex((v) => v === null);
|
||||
if (idx > 0) ridden[idx] = xs[idx][1];
|
||||
plot.setData(
|
||||
[xs.map((p) => p[0]), xs.map((p) => p[1]), ridden] as unknown as uPlot.AlignedData,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
build();
|
||||
return () => {
|
||||
disposeSize?.();
|
||||
plot?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
// Rebuild only when the route itself changes; otherwise just re-split.
|
||||
let builtFor = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
const key = profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null;
|
||||
if (key !== builtFor) {
|
||||
builtFor = key;
|
||||
if (key) build();
|
||||
else {
|
||||
plot?.destroy();
|
||||
plot = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
revision;
|
||||
positionX;
|
||||
update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="route">
|
||||
<div class="canvas" bind:this={host}></div>
|
||||
{#if !source}
|
||||
<div class="empty">
|
||||
<span class="label">No route loaded</span>
|
||||
<p>Load a GPX or a YAML profile to see the terrain ahead.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if source && !source.isElevation}
|
||||
<span class="overlay label">
|
||||
{profile?.channel} profile — no elevation
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.route {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A live streaming chart backed by a decimating `History` (NFR-3). The data
|
||||
* arrays are typed-array views that never grow, so this stays cheap for a
|
||||
* two-hour ride.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import uPlot from 'uplot';
|
||||
import { axis, observeSize } from '../lib/uplot';
|
||||
import type { History } from '../lib/history';
|
||||
|
||||
interface SeriesSpec {
|
||||
stroke: string;
|
||||
width?: number;
|
||||
fill?: string;
|
||||
dash?: number[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
history: History;
|
||||
series: SeriesSpec[];
|
||||
revision: number;
|
||||
/** Fixed y range, or null to autoscale. */
|
||||
range?: [number, number] | null;
|
||||
zeroLine?: boolean;
|
||||
}
|
||||
|
||||
let { history, series, revision, range = null, zeroLine = false }: Props = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let plot: uPlot | null = null;
|
||||
let dispose: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!host) return;
|
||||
const opts: uPlot.Options = {
|
||||
width: host.clientWidth || 400,
|
||||
height: host.clientHeight || 140,
|
||||
padding: [8, 6, 0, 0],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: range ? { range: () => range as [number, number] } : {},
|
||||
},
|
||||
axes: [
|
||||
axis({
|
||||
values: (_u, splits) => splits.map((v) => formatClock(v)),
|
||||
}),
|
||||
axis({ side: 3, size: 42, values: (_u, splits) => splits.map((v) => v.toFixed(0)) }),
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
...series.map((s) => ({
|
||||
stroke: s.stroke,
|
||||
width: s.width ?? 1.5,
|
||||
fill: s.fill,
|
||||
dash: s.dash,
|
||||
points: { show: false },
|
||||
})),
|
||||
],
|
||||
hooks: zeroLine
|
||||
? {
|
||||
draw: [
|
||||
(u: uPlot) => {
|
||||
const y = u.valToPos(0, 'y', true);
|
||||
if (!Number.isFinite(y)) return;
|
||||
u.ctx.save();
|
||||
u.ctx.strokeStyle = '#243040';
|
||||
u.ctx.lineWidth = 1;
|
||||
u.ctx.beginPath();
|
||||
u.ctx.moveTo(u.bbox.left, y);
|
||||
u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
|
||||
u.ctx.stroke();
|
||||
u.ctx.restore();
|
||||
},
|
||||
],
|
||||
}
|
||||
: {},
|
||||
};
|
||||
plot = new uPlot(opts, history.view() as unknown as uPlot.AlignedData, host);
|
||||
dispose = observeSize(host, (w, h) => plot?.setSize({ width: w, height: h }));
|
||||
return () => {
|
||||
dispose?.();
|
||||
plot?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
function formatClock(v: number): string {
|
||||
const m = Math.floor(v / 60);
|
||||
return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}m`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
revision;
|
||||
if (plot && history.length > 1) {
|
||||
plot.setData(history.view() as unknown as uPlot.AlignedData, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="stream" bind:this={host}></div>
|
||||
|
||||
<style>
|
||||
.stream {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Client-side view state. Everything here is either received from Rust or is
|
||||
* purely presentational (which screen is showing, which toast is up).
|
||||
*/
|
||||
import { api, subscribe } from './bridge';
|
||||
import { History } from './history';
|
||||
import type {
|
||||
DeviceList,
|
||||
InputAck,
|
||||
LapSummary,
|
||||
Notice,
|
||||
RideFrame,
|
||||
RideState,
|
||||
SampleProfile,
|
||||
} from './types';
|
||||
|
||||
export type Screen = 'connect' | 'ride';
|
||||
|
||||
let toastSeq = 0;
|
||||
|
||||
class AppStore {
|
||||
screen = $state<Screen>('connect');
|
||||
frame = $state<RideFrame | null>(null);
|
||||
ride = $state<RideState | null>(null);
|
||||
devices = $state<DeviceList>({ scanning: false, devices: [] });
|
||||
samples = $state<SampleProfile[]>([]);
|
||||
toasts = $state<(Notice & { id: number })[]>([]);
|
||||
lastAck = $state<(InputAck & { at: number }) | null>(null);
|
||||
lastLap = $state<LapSummary | null>(null);
|
||||
showHelp = $state(false);
|
||||
showProfiles = $state(false);
|
||||
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
|
||||
revision = $state(0);
|
||||
|
||||
/** Bounded chart history — raw power, rolling power. */
|
||||
readonly power = new History(2);
|
||||
/** Bounded chart history — commanded gradient. */
|
||||
readonly grade = new History(1);
|
||||
|
||||
private lastElapsed = -1;
|
||||
|
||||
async init(): Promise<void> {
|
||||
const [ride, devices, samples] = await Promise.all([
|
||||
api.rideState(),
|
||||
api.deviceList(),
|
||||
api.sampleProfiles(),
|
||||
]);
|
||||
this.ride = ride;
|
||||
this.devices = devices;
|
||||
this.samples = samples;
|
||||
|
||||
await subscribe({
|
||||
onFrame: (f) => this.onFrame(f),
|
||||
onRideState: (s) => {
|
||||
this.ride = s;
|
||||
},
|
||||
onDevices: (d) => {
|
||||
this.devices = d;
|
||||
},
|
||||
onLap: (l) => {
|
||||
this.lastLap = l;
|
||||
},
|
||||
onNotice: (n) => this.toast(n),
|
||||
onInputAck: (a) => {
|
||||
this.lastAck = { ...a, at: performance.now() };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private onFrame(f: RideFrame): void {
|
||||
this.frame = f;
|
||||
const t = f.snapshot.elapsed_ms / 1000;
|
||||
// The ride clock only advances while running; a paused ride should not
|
||||
// stack duplicate points onto the charts.
|
||||
if (t > this.lastElapsed) {
|
||||
this.lastElapsed = t;
|
||||
this.power.push(t, [f.snapshot.telemetry.power_w ?? 0, f.derived.rollingPowerW]);
|
||||
this.grade.push(t, [f.snapshot.gradient_pct]);
|
||||
} else if (t < this.lastElapsed) {
|
||||
this.clearHistory();
|
||||
this.lastElapsed = t;
|
||||
}
|
||||
this.revision++;
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.power.clear();
|
||||
this.grade.clear();
|
||||
this.lastElapsed = -1;
|
||||
}
|
||||
|
||||
toast(n: Notice): void {
|
||||
const entry = { ...n, id: ++toastSeq };
|
||||
this.toasts = [...this.toasts, entry];
|
||||
const ttl = n.level === 'error' ? 8000 : 4000;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== entry.id);
|
||||
}, ttl);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
/** Run a command and surface any rejection as a toast rather than silently. */
|
||||
async run<T>(fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
this.toast({ level: 'error', message: String(e) });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const app = new AppStore();
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The only place that talks to Rust.
|
||||
*
|
||||
* Commands are intents; they never mutate local state directly. Truth comes
|
||||
* back on the event channel (§4.3).
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type {
|
||||
ControlMode,
|
||||
DeviceInfo,
|
||||
DeviceList,
|
||||
InputAck,
|
||||
LapSummary,
|
||||
Notice,
|
||||
ProfileView,
|
||||
RideFrame,
|
||||
RideState,
|
||||
RiderConfig,
|
||||
SafetyLimits,
|
||||
SampleProfile,
|
||||
} from './types';
|
||||
|
||||
export const EVENTS = {
|
||||
snapshot: 'ride://snapshot',
|
||||
rideState: 'ride://state',
|
||||
lap: 'ride://lap',
|
||||
devices: 'devices://updated',
|
||||
connection: 'devices://connection',
|
||||
notice: 'app://notice',
|
||||
inputAck: 'app://input-ack',
|
||||
} as const;
|
||||
|
||||
/** True when running inside the Tauri shell rather than a bare browser. */
|
||||
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
|
||||
async function call<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return invoke<T>(cmd, args);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ride lifecycle
|
||||
rideState: () => call<RideState>('ride_state'),
|
||||
start: () => call<RideState>('start_ride'),
|
||||
pause: () => call<RideState>('pause_ride'),
|
||||
resume: () => call<RideState>('resume_ride'),
|
||||
togglePause: () => call<RideState>('toggle_pause'),
|
||||
stop: () => call<RideState>('stop_ride'),
|
||||
reset: () => call<RideState>('reset_ride'),
|
||||
|
||||
// control modes and targets
|
||||
setMode: (mode: ControlMode) => call<RideState>('set_control_mode', { mode }),
|
||||
cycleMode: () => call<RideState>('cycle_control_mode'),
|
||||
nudgeGradient: (deltaPct: number) => call<RideState>('nudge_gradient', { deltaPct }),
|
||||
setGradient: (percent: number) => call<RideState>('set_gradient', { percent }),
|
||||
resetGradient: () => call<RideState>('reset_gradient'),
|
||||
setResistance: (level: number) => call<RideState>('set_target_resistance', { level }),
|
||||
setPower: (watts: number) => call<RideState>('set_target_power', { watts }),
|
||||
markLap: () => call<LapSummary>('mark_lap'),
|
||||
|
||||
// configuration
|
||||
riderConfig: () => call<RiderConfig>('rider_config'),
|
||||
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
|
||||
safetyLimits: () => call<SafetyLimits>('safety_limits'),
|
||||
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
|
||||
|
||||
// profiles
|
||||
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
|
||||
loadProfileText: (name: string, text: string, isGpx: boolean) =>
|
||||
call<ProfileView>('load_profile_from_text', { name, text, isGpx }),
|
||||
previewYaml: (yaml: string) => call<ProfileView>('preview_profile_yaml', { yaml }),
|
||||
clearProfile: () => call<RideState>('clear_profile'),
|
||||
sampleProfiles: () => call<SampleProfile[]>('sample_profiles'),
|
||||
|
||||
// devices
|
||||
deviceList: () => call<DeviceList>('device_list'),
|
||||
startScan: () => call<void>('start_scan'),
|
||||
stopScan: () => call<void>('stop_scan'),
|
||||
connect: (deviceId: string) => call<DeviceInfo>('connect_device', { deviceId }),
|
||||
disconnect: (deviceId: string) => call<DeviceInfo>('disconnect_device', { deviceId }),
|
||||
forget: (deviceId: string) => call<void>('forget_device', { deviceId }),
|
||||
trainerControllable: () => call<boolean>('trainer_controllable'),
|
||||
};
|
||||
|
||||
type Handlers = {
|
||||
onFrame?: (f: RideFrame) => void;
|
||||
onRideState?: (s: RideState) => void;
|
||||
onLap?: (l: LapSummary) => void;
|
||||
onDevices?: (d: DeviceList) => void;
|
||||
onNotice?: (n: Notice) => void;
|
||||
onInputAck?: (a: InputAck) => void;
|
||||
};
|
||||
|
||||
/** Subscribe to the whole event channel. Returns a single unsubscribe. */
|
||||
export async function subscribe(h: Handlers): Promise<UnlistenFn> {
|
||||
const offs: UnlistenFn[] = [];
|
||||
const add = async <T>(name: string, fn?: (p: T) => void) => {
|
||||
if (!fn) return;
|
||||
offs.push(await listen<T>(name, (e) => fn(e.payload)));
|
||||
};
|
||||
await add(EVENTS.snapshot, h.onFrame);
|
||||
await add(EVENTS.rideState, h.onRideState);
|
||||
await add(EVENTS.lap, h.onLap);
|
||||
await add(EVENTS.devices, h.onDevices);
|
||||
await add(EVENTS.notice, h.onNotice);
|
||||
await add(EVENTS.inputAck, h.onInputAck);
|
||||
return () => offs.forEach((off) => off());
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
||||
|
||||
const EM_DASH = '—';
|
||||
|
||||
export function clock(seconds: number | null | undefined): string {
|
||||
if (seconds == null || !Number.isFinite(seconds)) return EM_DASH;
|
||||
const s = Math.max(0, Math.round(seconds));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||
}
|
||||
|
||||
/** Shorter form for an ETA: "1h 04" / "42 min" / "38 s". */
|
||||
export function duration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || !Number.isFinite(seconds)) return EM_DASH;
|
||||
const s = Math.max(0, Math.round(seconds));
|
||||
if (s >= 3600) {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return `${h}h ${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
if (s >= 60) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
/** Wall-clock time of arrival, e.g. "14:37". */
|
||||
export function finishAt(secondsFromNow: number | null | undefined): string {
|
||||
if (secondsFromNow == null || !Number.isFinite(secondsFromNow)) return EM_DASH;
|
||||
const t = new Date(Date.now() + secondsFromNow * 1000);
|
||||
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function km(metres: number | null | undefined, digits = 2): string {
|
||||
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||
return (metres / 1000).toFixed(digits);
|
||||
}
|
||||
|
||||
export function num(v: number | null | undefined, digits = 0): string {
|
||||
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
|
||||
export function signed(v: number | null | undefined, digits = 1): string {
|
||||
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
||||
return `${v >= 0 ? '' : '−'}${Math.abs(v).toFixed(digits)}`;
|
||||
}
|
||||
|
||||
export function axisLabel(unit: 'seconds' | 'metres'): string {
|
||||
return unit === 'metres' ? 'distance' : 'time';
|
||||
}
|
||||
|
||||
export function axisValue(unit: 'seconds' | 'metres', x: number): string {
|
||||
return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x);
|
||||
}
|
||||
|
||||
export function rssiBars(rssi: number): number {
|
||||
if (rssi >= -55) return 4;
|
||||
if (rssi >= -67) return 3;
|
||||
if (rssi >= -78) return 2;
|
||||
if (rssi >= -88) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function targetText(
|
||||
target:
|
||||
| { Gradient: { percent: number } }
|
||||
| { Resistance: { level: number } }
|
||||
| { Power: { watts: number } }
|
||||
| null,
|
||||
): string {
|
||||
if (!target) return EM_DASH;
|
||||
if ('Gradient' in target) return `${signed(target.Gradient.percent, 1)}%`;
|
||||
if ('Resistance' in target) return `L${target.Resistance.level}`;
|
||||
return `${target.Power.watts} W`;
|
||||
}
|
||||
|
||||
export const MODE_LABEL: Record<string, string> = {
|
||||
ManualGrade: 'Manual grade',
|
||||
Resistance: 'Resistance',
|
||||
Profile: 'Profile',
|
||||
Erg: 'ERG',
|
||||
};
|
||||
|
||||
export function connectionText(
|
||||
state: string | { Lost: { reason: string } },
|
||||
): { label: string; tone: 'ok' | 'warn' | 'bad' | 'idle' } {
|
||||
if (typeof state !== 'string') return { label: `Lost — ${state.Lost.reason}`, tone: 'bad' };
|
||||
switch (state) {
|
||||
case 'Controlling':
|
||||
return { label: 'Connected', tone: 'ok' };
|
||||
case 'Connected':
|
||||
return { label: 'Connected', tone: 'warn' };
|
||||
case 'Connecting':
|
||||
return { label: 'Connecting', tone: 'warn' };
|
||||
case 'Reconnecting':
|
||||
return { label: 'Reconnecting', tone: 'warn' };
|
||||
case 'Scanning':
|
||||
return { label: 'Discovered', tone: 'idle' };
|
||||
default:
|
||||
return { label: 'Idle', tone: 'idle' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Bounded, self-decimating chart history (NFR-3).
|
||||
*
|
||||
* A two-hour ride at 4 Hz is 28 800 samples per series. Keeping them all is
|
||||
* both a memory leak and a rendering cost that grows through the ride — exactly
|
||||
* what NFR-3 forbids. Instead the buffer has a hard capacity: when it fills, it
|
||||
* averages adjacent pairs in place, halving the point count and doubling the
|
||||
* time each point represents. Later samples are then averaged in groups of that
|
||||
* same stride before being stored.
|
||||
*
|
||||
* The result is constant memory and a constant point count for any ride
|
||||
* length, with resolution degrading gracefully: full 250 ms detail for the
|
||||
* first ~15 minutes, 2-second buckets by two hours. Typed arrays throughout so
|
||||
* uPlot can consume them without a copy.
|
||||
*/
|
||||
|
||||
const CAPACITY = 3600;
|
||||
|
||||
export class History {
|
||||
readonly capacity: number;
|
||||
readonly seriesCount: number;
|
||||
/** Seconds since ride start. */
|
||||
readonly x: Float64Array;
|
||||
readonly y: Float64Array[];
|
||||
/** Number of populated points. */
|
||||
length = 0;
|
||||
/** Raw samples currently folded into one stored point. */
|
||||
stride = 1;
|
||||
|
||||
private pendingX = 0;
|
||||
private pendingY: Float64Array;
|
||||
private pendingN = 0;
|
||||
|
||||
constructor(seriesCount: number, capacity = CAPACITY) {
|
||||
this.capacity = capacity;
|
||||
this.seriesCount = seriesCount;
|
||||
this.x = new Float64Array(capacity);
|
||||
this.y = Array.from({ length: seriesCount }, () => new Float64Array(capacity));
|
||||
this.pendingY = new Float64Array(seriesCount);
|
||||
}
|
||||
|
||||
push(x: number, values: number[]): void {
|
||||
this.pendingX += x;
|
||||
for (let s = 0; s < this.seriesCount; s++) this.pendingY[s] += values[s] ?? 0;
|
||||
this.pendingN++;
|
||||
if (this.pendingN < this.stride) return;
|
||||
|
||||
if (this.length >= this.capacity) this.compact();
|
||||
|
||||
const i = this.length++;
|
||||
this.x[i] = this.pendingX / this.pendingN;
|
||||
for (let s = 0; s < this.seriesCount; s++) this.y[s][i] = this.pendingY[s] / this.pendingN;
|
||||
|
||||
this.pendingX = 0;
|
||||
this.pendingY.fill(0);
|
||||
this.pendingN = 0;
|
||||
}
|
||||
|
||||
/** Halve the resolution in place. O(capacity), amortised to O(1) per sample. */
|
||||
private compact(): void {
|
||||
const half = this.length >> 1;
|
||||
for (let i = 0; i < half; i++) {
|
||||
const a = i * 2;
|
||||
const b = a + 1;
|
||||
this.x[i] = (this.x[a] + this.x[b]) / 2;
|
||||
for (let s = 0; s < this.seriesCount; s++) {
|
||||
this.y[s][i] = (this.y[s][a] + this.y[s][b]) / 2;
|
||||
}
|
||||
}
|
||||
this.length = half;
|
||||
this.stride *= 2;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.length = 0;
|
||||
this.stride = 1;
|
||||
this.pendingX = 0;
|
||||
this.pendingY.fill(0);
|
||||
this.pendingN = 0;
|
||||
}
|
||||
|
||||
/** Views sized to the populated region, ready for `uPlot.setData`. */
|
||||
view(): Float64Array[] {
|
||||
return [this.x.subarray(0, this.length), ...this.y.map((a) => a.subarray(0, this.length))];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* TypeScript mirror of the Rust payloads.
|
||||
*
|
||||
* The authoritative definitions are `crates/core/src/types.rs` (frozen),
|
||||
* `src-tauri/src/events.rs`, `src-tauri/src/derive.rs` and
|
||||
* `src-tauri/src/profile_view.rs`. Nothing here is computed — these are shapes
|
||||
* that arrive over the event channel.
|
||||
*/
|
||||
|
||||
// --- crates/core/src/types.rs (frozen contract) -----------------------------
|
||||
|
||||
export interface Telemetry {
|
||||
elapsedMs: number;
|
||||
power_w: number | null;
|
||||
cadence_rpm: number | null;
|
||||
speed_kph: number | null;
|
||||
resistance_level: number | null;
|
||||
heart_rate_bpm: number | null;
|
||||
total_distance_m: number | null;
|
||||
total_energy_kcal: number | null;
|
||||
}
|
||||
|
||||
/** Serde externally-tagged enum. */
|
||||
export type ControlTarget =
|
||||
| { Gradient: { percent: number } }
|
||||
| { Resistance: { level: number } }
|
||||
| { Power: { watts: number } };
|
||||
|
||||
export type ControlMode = 'ManualGrade' | 'Resistance' | 'Profile' | 'Erg';
|
||||
|
||||
export type ConnectionState =
|
||||
| 'Idle'
|
||||
| 'Scanning'
|
||||
| 'Connecting'
|
||||
| 'Connected'
|
||||
| 'Controlling'
|
||||
| 'Reconnecting'
|
||||
| { Lost: { reason: string } };
|
||||
|
||||
export interface RideSnapshot {
|
||||
elapsed_ms: number;
|
||||
telemetry: {
|
||||
elapsed_ms: number;
|
||||
power_w: number | null;
|
||||
cadence_rpm: number | null;
|
||||
speed_kph: number | null;
|
||||
resistance_level: number | null;
|
||||
heart_rate_bpm: number | null;
|
||||
total_distance_m: number | null;
|
||||
total_energy_kcal: number | null;
|
||||
};
|
||||
virtual_speed_kph: number;
|
||||
virtual_distance_m: number;
|
||||
gradient_pct: number;
|
||||
elevation_gain_m: number;
|
||||
mode: ControlMode;
|
||||
target: ControlTarget | null;
|
||||
profile_progress: number | null;
|
||||
}
|
||||
|
||||
export interface RiderConfig {
|
||||
rider_kg: number;
|
||||
bike_kg: number;
|
||||
crr: number;
|
||||
cda: number;
|
||||
drivetrain_efficiency: number;
|
||||
air_density: number;
|
||||
wheel_circumference_m: number;
|
||||
}
|
||||
|
||||
export interface SafetyLimits {
|
||||
min_gradient_pct: number;
|
||||
max_gradient_pct: number;
|
||||
min_resistance: number;
|
||||
max_resistance: number;
|
||||
min_power_w: number;
|
||||
max_power_w: number;
|
||||
}
|
||||
|
||||
// --- src-tauri/src/derive.rs -------------------------------------------------
|
||||
|
||||
export type EtaKind = 'exact' | 'estimated' | 'held' | 'looping' | 'unavailable';
|
||||
export type XUnit = 'seconds' | 'metres';
|
||||
|
||||
export interface Derived {
|
||||
etaKind: EtaKind;
|
||||
timeRemainingS: number | null;
|
||||
distanceTotalM: number | null;
|
||||
distanceRemainingM: number | null;
|
||||
elevationM: number | null;
|
||||
ascentRemainingM: number | null;
|
||||
positionX: number;
|
||||
axisUnit: XUnit;
|
||||
axisTotal: number;
|
||||
loopIndex: number | null;
|
||||
smoothedSpeedKph: number;
|
||||
rollingPowerW: number;
|
||||
rollingPowerWindowS: number;
|
||||
avgPowerW: number;
|
||||
maxPowerW: number;
|
||||
normalisedPowerW: number | null;
|
||||
avgCadenceRpm: number;
|
||||
energyKj: number;
|
||||
}
|
||||
|
||||
/** What arrives on `ride://snapshot`. */
|
||||
export interface RideFrame {
|
||||
snapshot: RideSnapshot;
|
||||
derived: Derived;
|
||||
}
|
||||
|
||||
// --- src-tauri/src/profile_view.rs ------------------------------------------
|
||||
|
||||
export type Channel = 'gradient' | 'resistance' | 'power';
|
||||
|
||||
export interface BlockSummary {
|
||||
index: number;
|
||||
kind: 'constant' | 'ramp' | 'wave' | 'segments' | 'terrain';
|
||||
channel: Channel;
|
||||
label: string;
|
||||
startX: number;
|
||||
endX: number;
|
||||
unit: XUnit;
|
||||
}
|
||||
|
||||
export interface ProfileView {
|
||||
name: string;
|
||||
description: string | null;
|
||||
looping: boolean;
|
||||
source: string;
|
||||
channel: Channel;
|
||||
xUnit: XUnit;
|
||||
totalX: number;
|
||||
totalSeconds: number | null;
|
||||
totalMetres: number | null;
|
||||
series: [number, number][];
|
||||
elevation: [number, number][] | null;
|
||||
elevationMinM: number | null;
|
||||
elevationMaxM: number | null;
|
||||
totalAscentM: number | null;
|
||||
blocks: BlockSummary[];
|
||||
yaml: string;
|
||||
}
|
||||
|
||||
// --- src-tauri/src/events.rs -------------------------------------------------
|
||||
|
||||
export type RideStatus = 'idle' | 'running' | 'paused' | 'finished';
|
||||
|
||||
export interface LapSummary {
|
||||
index: number;
|
||||
elapsedMs: number;
|
||||
distanceM: number;
|
||||
avgPowerW: number;
|
||||
}
|
||||
|
||||
export interface RideState {
|
||||
status: RideStatus;
|
||||
mode: ControlMode;
|
||||
target: ControlTarget | null;
|
||||
gradientOffsetPct: number;
|
||||
manualGradientPct: number;
|
||||
resistanceLevel: number;
|
||||
powerTargetW: number;
|
||||
lap: number;
|
||||
laps: LapSummary[];
|
||||
profile: ProfileView | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export type DeviceKind = 'trainer' | 'clickLeft' | 'clickRight' | 'heartRate' | 'unknown';
|
||||
|
||||
export interface DeviceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
rssi: number;
|
||||
kind: DeviceKind;
|
||||
state: ConnectionState;
|
||||
controlAcquired: boolean;
|
||||
services: string[];
|
||||
remembered: boolean;
|
||||
batteryPct: number | null;
|
||||
unlockExpiresInS: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceList {
|
||||
scanning: boolean;
|
||||
devices: DeviceInfo[];
|
||||
}
|
||||
|
||||
export interface Notice {
|
||||
level: 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InputAck {
|
||||
action: string;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface SampleProfile {
|
||||
name: string;
|
||||
summary: string;
|
||||
text: string;
|
||||
isGpx: boolean;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Shared uPlot styling and sizing helpers. */
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
export const INK_DIM = '#5d6c7d';
|
||||
export const GRID = '#141a23';
|
||||
export const FONT = '600 11px Inter, system-ui, sans-serif';
|
||||
|
||||
export function axis(overrides: Partial<uPlot.Axis> = {}): uPlot.Axis {
|
||||
return {
|
||||
stroke: INK_DIM,
|
||||
font: FONT,
|
||||
labelFont: FONT,
|
||||
ticks: { stroke: GRID, width: 1, size: 4 },
|
||||
grid: { stroke: GRID, width: 1 },
|
||||
gap: 4,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep a chart sized to its container without a resize storm. */
|
||||
export function observeSize(el: HTMLElement, apply: (w: number, h: number) => void): () => void {
|
||||
let frame = 0;
|
||||
const ro = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) apply(Math.round(rect.width), Math.round(rect.height));
|
||||
});
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
ro.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical "you are here" marker, drawn straight onto the canvas after the
|
||||
* series. Cheaper and steadier than a series with one point.
|
||||
*/
|
||||
export function positionMarker(getX: () => number | null, colour: string): uPlot.Plugin {
|
||||
return {
|
||||
hooks: {
|
||||
draw: (u: uPlot) => {
|
||||
const x = getX();
|
||||
if (x == null || !Number.isFinite(x)) return;
|
||||
const left = u.valToPos(x, 'x', true);
|
||||
if (!Number.isFinite(left)) return;
|
||||
const ctx = u.ctx;
|
||||
const top = u.bbox.top;
|
||||
const bottom = u.bbox.top + u.bbox.height;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = colour;
|
||||
ctx.lineWidth = Math.max(1, Math.round(devicePixelRatio));
|
||||
ctx.moveTo(left, top);
|
||||
ctx.lineTo(left, bottom);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = colour;
|
||||
ctx.arc(left, bottom, 4 * devicePixelRatio, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user