diff --git a/app/.prettierrc b/app/.prettierrc index a81af9a..39176ba 100644 --- a/app/.prettierrc +++ b/app/.prettierrc @@ -3,5 +3,6 @@ "singleQuote": true, "tabWidth": 2, "trailingComma": "all", - "printWidth": 80 + "printWidth": 80, + "plugins": ["prettier-plugin-svelte"] } diff --git a/app/src/app.svelte b/app/src/app.svelte index af99057..205681c 100644 --- a/app/src/app.svelte +++ b/app/src/app.svelte @@ -816,7 +816,11 @@ {#if AI_ASSISTANT_ENABLED} - + + aria-pressed={showAiChat}> {/if} @@ -1128,7 +1132,9 @@ class="h-[34px] gap-1.5 min-w-[104px] rounded-lg px-4 font-semibold text-white shadow-[0_4px_14px_-4px_rgba(220,20,60,0.6)] hover:bg-[var(--accent-hover)] disabled:bg-primary/25 disabled:text-white/60 disabled:opacity-100 disabled:shadow-none - {onboardingStep === 0 && !hasRenderedOnce ? 'onboarding-glow' : ''}" + {onboardingStep === 0 && !hasRenderedOnce + ? 'onboarding-glow' + : ''}" size="sm" > diff --git a/app/src/components/canvas/ElementHandle.svelte b/app/src/components/canvas/ElementHandle.svelte index b6f641e..dff7a6b 100644 --- a/app/src/components/canvas/ElementHandle.svelte +++ b/app/src/components/canvas/ElementHandle.svelte @@ -8,14 +8,19 @@ // Circular-arrow cursor for the rotation handle (clockwise arc + arrowhead). const ROTATE_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( '' + - '' + - '' + - '' + - '' + - '' + '' + + '' + + '' + + '' + + '', )}") 10 10, grab` - const RESIZE_CURSORS = { tl: 'nw-resize', tr: 'ne-resize', bl: 'sw-resize', br: 'se-resize' } + const RESIZE_CURSORS = { + tl: 'nw-resize', + tr: 'ne-resize', + bl: 'sw-resize', + br: 'se-resize', + } const CORNERS = ['tl', 'tr', 'bl', 'br'] let { @@ -23,16 +28,16 @@ label = '', selected = false, rotation = 0, - groupOffset = { dx: 0, dy: 0 }, // live offset when another group member is dragging + groupOffset = { dx: 0, dy: 0 }, // live offset when another group member is dragging resizable = false, locked = false, - onselect, // (event) — event carries shiftKey for multi-select - ondrag, // (dx, dy) live, every pointermove - ondragend, // (dx, dy) in scene/overlay coords - onrotate, // (degrees) live, every pointermove - onrotateend, // (degrees) committed on pointerup - onresize, // (corner, dx, dy, shiftKey) live, every pointermove - onresizeend, // (corner, dx, dy, shiftKey) committed on pointerup + onselect, // (event) — event carries shiftKey for multi-select + ondrag, // (dx, dy) live, every pointermove + ondragend, // (dx, dy) in scene/overlay coords + onrotate, // (degrees) live, every pointermove + onrotateend, // (degrees) committed on pointerup + onresize, // (corner, dx, dy, shiftKey) live, every pointermove + onresizeend, // (corner, dx, dy, shiftKey) committed on pointerup } = $props() let dragging = $state(false) @@ -46,7 +51,10 @@ // bounding box flash to its synthetic fallback for one frame cycle. const CLICK_PX = 3 function pastClickThreshold(e, origin) { - return Math.abs(e.clientX - origin.mx) > CLICK_PX || Math.abs(e.clientY - origin.my) > CLICK_PX + return ( + Math.abs(e.clientX - origin.mx) > CLICK_PX || + Math.abs(e.clientY - origin.my) > CLICK_PX + ) } // Display position: base bounds + live drag offset @@ -77,7 +85,7 @@ function onpointerdown(e) { e.stopPropagation() onselect?.(e) - if (locked) return // select is fine; drag is not + if (locked) return // select is fine; drag is not dragging = true dragOrigin = { mx: e.clientX, my: e.clientY } dragDelta = { dx: 0, dy: 0 } @@ -88,14 +96,21 @@ if (!dragging) return const svg = e.currentTarget.ownerSVGElement if (!svg) return - dragDelta = screenToOverlayDelta(svg, dragOrigin.mx, dragOrigin.my, e.clientX, e.clientY) + dragDelta = screenToOverlayDelta( + svg, + dragOrigin.mx, + dragOrigin.my, + e.clientX, + e.clientY, + ) ondrag?.(dragDelta.dx, dragDelta.dy) } function onpointerup(e) { if (!dragging) return dragging = false - if (pastClickThreshold(e, dragOrigin)) ondragend?.(dragDelta.dx, dragDelta.dy) + if (pastClickThreshold(e, dragOrigin)) + ondragend?.(dragDelta.dx, dragDelta.dy) dragDelta = { dx: 0, dy: 0 } } @@ -118,7 +133,11 @@ e.stopPropagation() rotating = true rotateStartValue = rotation - rotateStartAngle = sceneAngle(e.currentTarget.ownerSVGElement, e.clientX, e.clientY) + rotateStartAngle = sceneAngle( + e.currentTarget.ownerSVGElement, + e.clientX, + e.clientY, + ) rotateOrigin = { mx: e.clientX, my: e.clientY } e.currentTarget.setPointerCapture(e.pointerId) } @@ -154,7 +173,13 @@ if (!resizingCorner) return const svg = e.currentTarget.ownerSVGElement if (!svg) return - resizeDelta = screenToOverlayDelta(svg, resizeOrigin.mx, resizeOrigin.my, e.clientX, e.clientY) + resizeDelta = screenToOverlayDelta( + svg, + resizeOrigin.mx, + resizeOrigin.my, + e.clientX, + e.clientY, + ) onresize?.(resizingCorner, resizeDelta.dx, resizeDelta.dy, e.shiftKey) } @@ -168,10 +193,14 @@ function cornerPos(corner) { switch (corner) { - case 'tl': return { x: d.x, y: d.y } - case 'tr': return { x: d.x + d.w, y: d.y } - case 'bl': return { x: d.x, y: d.y + d.h } - case 'br': return { x: d.x + d.w, y: d.y + d.h } + case 'tl': + return { x: d.x, y: d.y } + case 'tr': + return { x: d.x + d.w, y: d.y } + case 'bl': + return { x: d.x, y: d.y + d.h } + case 'br': + return { x: d.x + d.w, y: d.y + d.h } } } @@ -184,7 +213,11 @@ width={Math.max(d.w + 8, 24)} height={Math.max(d.h + 8, 24)} fill="transparent" - style="cursor: {locked ? 'not-allowed' : dragging ? 'grabbing' : 'grab'}; pointer-events: all; outline: none" + style="cursor: {locked + ? 'not-allowed' + : dragging + ? 'grabbing' + : 'grab'}; pointer-events: all; outline: none" role="button" aria-label="{locked ? 'Locked' : 'Move'} {label}" tabindex="0" @@ -203,7 +236,11 @@ stroke-width={selected ? 1.5 : 1} stroke-dasharray={selected ? 'none' : '4 3'} rx="2" - style="pointer-events: none; stroke: {selected ? (locked ? '#F59E0B' : 'var(--primary)') : 'rgba(255,255,255,0.25)'}" + style="pointer-events: none; stroke: {selected + ? locked + ? '#F59E0B' + : 'var(--primary)' + : 'rgba(255,255,255,0.25)'}" /> @@ -222,8 +259,8 @@ font-size="10" fill="white" font-family="system-ui" - style="pointer-events: none; user-select: none" - >{label} + style="pointer-events: none; user-select: none">{label} {#each CORNERS as corner (corner)} @@ -258,8 +295,10 @@ @@ -269,7 +308,7 @@ role="button" aria-label="Rotate element" tabindex="0" - cx={cx} + {cx} cy={d.y - ROTATE_HANDLE_OFFSET} r="14" fill="transparent" @@ -279,7 +318,7 @@ onpointerup={rotatePointerUp} /> @@ -151,7 +158,9 @@
-
+
{#each buffered as s (s)}
-
+
@@ -204,7 +217,9 @@ {#if customTimeS !== null}
-
+
-
+
@@ -266,7 +293,9 @@ {#if distanceInfo && markerDistanceM !== null}
-
+
@@ -296,14 +327,19 @@ @@ -318,7 +354,9 @@
- + {formatTime(readoutCurrent)} / {formatTime(readoutTotal)}
@@ -348,7 +386,11 @@ linear-gradient(45deg, transparent 75%, #111 75%), linear-gradient(-45deg, transparent 75%, #111 75%); background-size: 8px 8px; - background-position: 0 0, 0 4px, 4px -4px, -4px 0; + background-position: + 0 0, + 0 4px, + 4px -4px, + -4px 0; } .marker-rectangle::-webkit-slider-thumb { width: 16px; diff --git a/app/src/components/canvas/VideoAlignmentBar.svelte b/app/src/components/canvas/VideoAlignmentBar.svelte index d352c3b..d9ef3c2 100644 --- a/app/src/components/canvas/VideoAlignmentBar.svelte +++ b/app/src/components/canvas/VideoAlignmentBar.svelte @@ -41,12 +41,8 @@ let videoStartRel = $derived(videoStartAbs - sceneStart) let videoEndRel = $derived(videoEndAbs - sceneStart) - let leftPct = $derived( - Math.max(0, (videoStartRel / overlayDuration) * 100), - ) - let rightPct = $derived( - Math.min(100, (videoEndRel / overlayDuration) * 100), - ) + let leftPct = $derived(Math.max(0, (videoStartRel / overlayDuration) * 100)) + let rightPct = $derived(Math.min(100, (videoEndRel / overlayDuration) * 100)) let widthPct = $derived(Math.max(0, rightPct - leftPct)) let overflowLeft = $derived(videoStartRel < 0) let overflowRight = $derived(videoEndRel > overlayDuration) @@ -143,7 +139,9 @@ onpointerup={endDrag} onpointercancel={endDrag} > -
+
{#if widthPct > 0} @@ -899,46 +1107,110 @@ -
- 3 +
+ 3
-

Render Video

-

Export the overlay to a file

+

+ Render Video +

+

+ Export the overlay to a file +

- - + +
-
{/if} @@ -951,25 +1223,29 @@ {#if app.config && app.hasActivity} - 1 ? (app.config?.scene?.target_duration ?? null) : null} - onseek={seek} - distanceInfo={(showDistanceBar || showCourseMarkerBar) ? distanceInfo : null} - customDistanceM={showDistanceBar ? customDistanceM : null} - oncustomdistancechange={onCustomDistanceChange} - customTimeS={customTimeS} - oncustomtimechange={onCustomTimeChange} - markerDistanceM={showCourseMarkerBar ? courseMarkerDistanceM : null} - markerStyle={selectedCourseMarker?.style ?? 'checkered'} - markerColor={selectedCourseMarker?.color ?? '#ef4444'} - onmarkerdistancechange={onCourseMarkerDistanceChange} - lapGate={lapGateForBar} - onlapgatechange={onLapGateChange} - /> + 1 + ? (app.config?.scene?.target_duration ?? null) + : null} + onseek={seek} + distanceInfo={showDistanceBar || showCourseMarkerBar + ? distanceInfo + : null} + customDistanceM={showDistanceBar ? customDistanceM : null} + oncustomdistancechange={onCustomDistanceChange} + {customTimeS} + oncustomtimechange={onCustomTimeChange} + markerDistanceM={showCourseMarkerBar ? courseMarkerDistanceM : null} + markerStyle={selectedCourseMarker?.style ?? 'checkered'} + markerColor={selectedCourseMarker?.color ?? '#ef4444'} + onmarkerdistancechange={onCourseMarkerDistanceChange} + lapGate={lapGateForBar} + onlapgatechange={onLapGateChange} + /> {/if} diff --git a/app/src/components/layout/LeftSidebar.svelte b/app/src/components/layout/LeftSidebar.svelte index fbc2a7c..9ed2b8f 100644 --- a/app/src/components/layout/LeftSidebar.svelte +++ b/app/src/components/layout/LeftSidebar.svelte @@ -72,7 +72,9 @@ }) function toggleTimelapse(on) { - app.updateScene({ target_duration: on ? TIMELAPSE_DEFAULT_SECS : undefined }) + app.updateScene({ + target_duration: on ? TIMELAPSE_DEFAULT_SECS : undefined, + }) } function setTargetDuration(v) { @@ -135,7 +137,6 @@ app.endEditBatch?.() drag = null } -
{/if} {/if} @@ -322,111 +354,116 @@ onclick={() => (sceneCollapsed = !sceneCollapsed)} class="w-full flex items-center justify-between px-4 py-3 cursor-pointer group" > - + Scene {#if !sceneCollapsed} -
- -
- Font - +
-
- Opacity - app.updateScene({ opacity: Number(e.target.value) })} - /> -
+
+ Opacity + + app.updateScene({ opacity: Number(e.target.value) })} + /> +
- -
-
- Color variables - -
- {#each Object.entries(app.config.scene.vars ?? {}) as [name, value] (name)} -
- { - const vars = { ...(app.config.scene.vars ?? {}) } - vars[name] = e.target.value - app.updateScene({ vars }) - }} - class="h-7 w-7 shrink-0 rounded-[4px] border border-white/10 bg-transparent cursor-pointer p-0.5" - title={value} - /> - { - const newName = e.target.value.trim().replace(/\s+/g, '_') - if (!newName || newName === name) { - e.target.value = name - return - } - const entries = Object.entries(app.config.scene.vars ?? {}) - const vars = Object.fromEntries( - entries.map(([k, v]) => [k === name ? newName : k, v]), - ) - app.updateScene({ vars }) - }} - class="min-w-0 flex-1 h-7 rounded-[6px] border-0 bg-[var(--panel2)] px-2 text-xs + > + + +
+ {#each Object.entries(app.config.scene.vars ?? {}) as [name, value] (name)} +
+ { + const vars = { ...(app.config.scene.vars ?? {}) } + vars[name] = e.target.value + app.updateScene({ vars }) + }} + class="h-7 w-7 shrink-0 rounded-[4px] border border-white/10 bg-transparent cursor-pointer p-0.5" + title={value} + /> + { + const newName = e.target.value.trim().replace(/\s+/g, '_') + if (!newName || newName === name) { + e.target.value = name + return + } + const entries = Object.entries(app.config.scene.vars ?? {}) + const vars = Object.fromEntries( + entries.map(([k, v]) => [k === name ? newName : k, v]), + ) + app.updateScene({ vars }) + }} + class="min-w-0 flex-1 h-7 rounded-[6px] border-0 bg-[var(--panel2)] px-2 text-xs text-zinc-300 font-mono focus:outline-none focus:ring-1 focus:ring-ring" - title="Variable name — reference as ${name} in any color field" - /> - + > + + +
+ {/each} + {#if Object.keys(app.config.scene.vars ?? {}).length === 0} +

+ No variables. Add one above and reference it as $name in any + color field. +

+ {/if}
- {/each} - {#if Object.keys(app.config.scene.vars ?? {}).length === 0} -

- No variables. Add one above and reference it as $name in any color - field. -

- {/if} -
-
+ {/if} {/if} diff --git a/app/src/components/overlays/AboutModal.svelte b/app/src/components/overlays/AboutModal.svelte index 316a243..b56058d 100644 --- a/app/src/components/overlays/AboutModal.svelte +++ b/app/src/components/overlays/AboutModal.svelte @@ -28,11 +28,17 @@ tabindex="-1" class="fixed inset-0 z-50 flex items-center justify-center pt-14" > - + -
+
+ >github.com/walkersutton/cyclemetry
diff --git a/app/src/components/overlays/ActivityPickerModal.svelte b/app/src/components/overlays/ActivityPickerModal.svelte index 2e4bc1a..307cd94 100644 --- a/app/src/components/overlays/ActivityPickerModal.svelte +++ b/app/src/components/overlays/ActivityPickerModal.svelte @@ -357,7 +357,9 @@ {/each}
{:else if loadError} -

{loadError}

+

+ {loadError} +

{:else if saved.length === 0}

No activities yet. Choose one from disk to get started. @@ -473,7 +475,9 @@ {#if stravaError} -

{stravaError}

+

+ {stravaError} +

{:else if stravaLoading}
{#each [1, 2, 3, 4] as i (i)} diff --git a/app/src/components/overlays/AssetPicker.svelte b/app/src/components/overlays/AssetPicker.svelte index 5769d88..6e00874 100644 --- a/app/src/components/overlays/AssetPicker.svelte +++ b/app/src/components/overlays/AssetPicker.svelte @@ -6,11 +6,7 @@ import { listAssets, importAsset } from '../../api/backend.js' import { dialogExtensions } from '../../lib/utils.js' - let { - current = '', - onselect, - oncancel, - } = $props() + let { current = '', onselect, oncancel } = $props() let assets = $state([]) let loading = $state(true) @@ -33,7 +29,10 @@ } function onKeydown(e) { - if (e.key === 'Escape') { e.preventDefault(); oncancel?.() } + if (e.key === 'Escape') { + e.preventDefault() + oncancel?.() + } } async function upload() { @@ -41,7 +40,10 @@ const path = await openFileDialog({ multiple: false, filters: [ - { name: 'Images', extensions: dialogExtensions(['png', 'webp', 'svg']) }, + { + name: 'Images', + extensions: dialogExtensions(['png', 'webp', 'svg']), + }, ], }) if (!path) return @@ -63,19 +65,27 @@ aria-label="Asset Library" tabindex="-1" class="fixed inset-0 z-[60] flex items-center justify-center pt-14" - onmousedown={(e) => { if (e.target === e.currentTarget) oncancel?.() }} + onmousedown={(e) => { + if (e.target === e.currentTarget) oncancel?.() + }} >
-
- +
-
+

Asset Library

{#if uploadError} - {uploadError} + {uploadError} {/if}
{:else}
@@ -116,12 +128,15 @@ onclick={() => onselect?.(asset.name)} title={asset.name} class={`relative flex flex-col items-center gap-1.5 p-2 rounded-[6px] border text-left transition-colors - ${isSelected - ? 'border-primary/60 bg-primary/10' - : 'border-zinc-800 bg-zinc-900/60 hover:border-zinc-600 hover:bg-zinc-800/60'}`} + ${ + isSelected + ? 'border-primary/60 bg-primary/10' + : 'border-zinc-800 bg-zinc-900/60 hover:border-zinc-600 hover:bg-zinc-800/60' + }`} > -
{#if asset.data_url} @@ -134,11 +149,16 @@
- {asset.name} + {asset.name} {#if isSelected} -
+
{/if} @@ -150,8 +170,9 @@
-

PNG, WebP, and SVG. Imported assets are saved to your local library.

+

+ PNG, WebP, and SVG. Imported assets are saved to your local library. +

-
diff --git a/app/src/components/overlays/ConfirmDialog.svelte b/app/src/components/overlays/ConfirmDialog.svelte index 7203119..562694f 100644 --- a/app/src/components/overlays/ConfirmDialog.svelte +++ b/app/src/components/overlays/ConfirmDialog.svelte @@ -38,19 +38,25 @@ aria-label={title} tabindex="-1" class="fixed inset-0 z-[60] flex items-center justify-center pt-14" - onmousedown={(e) => { if (e.target === e.currentTarget) oncancel?.() }} + onmousedown={(e) => { + if (e.target === e.currentTarget) oncancel?.() + }} >
-
+

{title}

{#if message}

{message}

{/if} {#if dontShowAgainLabel} -
{:else if app.errorMessage} {/if} diff --git a/app/src/components/overlays/ExportFormatDialog.svelte b/app/src/components/overlays/ExportFormatDialog.svelte index 7fe6bd5..063ce3d 100644 --- a/app/src/components/overlays/ExportFormatDialog.svelte +++ b/app/src/components/overlays/ExportFormatDialog.svelte @@ -55,8 +55,7 @@ ) // Shared column layout so the header cells and every row align. - const COLS = - 'grid grid-cols-[1fr_6rem_5rem_4.5rem_5rem] items-center gap-2' + const COLS = 'grid grid-cols-[1fr_6rem_5rem_4.5rem_5rem] items-center gap-2' onMount(() => { window.addEventListener('keydown', onKeydown) @@ -255,9 +254,9 @@ />

This export needs roughly {formatFileSize(selectedBytes)}, but the - output disk has only {formatFileSize(diskFreeBytes)} free. You can - still start — the render pauses itself before the disk fills and - resumes automatically when you free up space. + output disk has only {formatFileSize(diskFreeBytes)} free. You can still + start — the render pauses itself before the disk fills and resumes automatically + when you free up space.

{/if} diff --git a/app/src/components/overlays/NewTemplateDialog.svelte b/app/src/components/overlays/NewTemplateDialog.svelte index 3b80400..6fcabc0 100644 --- a/app/src/components/overlays/NewTemplateDialog.svelte +++ b/app/src/components/overlays/NewTemplateDialog.svelte @@ -33,12 +33,16 @@ aria-label="Create Template" tabindex="-1" class="fixed inset-0 z-[70] flex items-center justify-center pt-14" - onmousedown={(e) => { if (e.target === e.currentTarget) oncancel?.() }} + onmousedown={(e) => { + if (e.target === e.currentTarget) oncancel?.() + }} >
-
+

Create template

{#if app.renderingVideo && expanded} -
+
-
+
{#if paused} -
+
@@ -66,19 +72,44 @@

{:else} -
- - - +
+ + + - - + +
-

{finalizing ? 'Finalizing Video' : 'Generating Video'}

+

+ {finalizing ? 'Finalizing Video' : 'Generating Video'} +

- {finalizing ? 'Encoding output file…' : `${formatTime(p.overlaySecondsRendered)} / ${formatTime(p.overlayTotalSeconds)} of overlay rendered`} + {finalizing + ? 'Encoding output file…' + : `${formatTime(p.overlaySecondsRendered)} / ${formatTime(p.overlayTotalSeconds)} of overlay rendered`}

{/if} @@ -86,13 +117,23 @@ {#if format} -
+
- {format.label} - {format.container} + {format.label} + {format.container}
-

{format.summary ?? format.desc}

-
+

+ {format.summary ?? format.desc} +

+
{app.outputWidth}×{app.outputHeight} · {fps} fps @@ -105,21 +146,35 @@
- {pct}% + {pct}% {#if paused} - paused — low disk space + paused — low disk space {:else if estimating} - - + + estimating… {:else if !finalizing && p.estimatedSecondsRemaining != null} - {formatTime(p.estimatedSecondsRemaining)} remaining + {formatTime(p.estimatedSecondsRemaining)} remaining {/if}
@@ -148,7 +203,9 @@
-

Keep the app open during rendering

+

+ Keep the app open during rendering +

{/if} diff --git a/app/src/components/overlays/RenderStatusChip.svelte b/app/src/components/overlays/RenderStatusChip.svelte index 4dcf58e..99d21b3 100644 --- a/app/src/components/overlays/RenderStatusChip.svelte +++ b/app/src/components/overlays/RenderStatusChip.svelte @@ -51,15 +51,50 @@ - + {#if finalizing} - + {:else} - + {/if} - {paused ? 'Paused — low disk' : finalizing ? 'Finalizing…' : `Rendering ${pct}%`} + {paused + ? 'Paused — low disk' + : finalizing + ? 'Finalizing…' + : `Rendering ${pct}%`} diff --git a/app/src/components/overlays/Settings.svelte b/app/src/components/overlays/Settings.svelte index a59295a..3ce0b9c 100644 --- a/app/src/components/overlays/Settings.svelte +++ b/app/src/components/overlays/Settings.svelte @@ -31,7 +31,11 @@ tabindex="-1" class="fixed inset-0 z-50 flex items-center justify-center pt-14" > - + diff --git a/app/src/components/overlays/TemplatePickerModal.svelte b/app/src/components/overlays/TemplatePickerModal.svelte index 2163651..6e3acfc 100644 --- a/app/src/components/overlays/TemplatePickerModal.svelte +++ b/app/src/components/overlays/TemplatePickerModal.svelte @@ -33,7 +33,6 @@ } window.addEventListener('resize', onResize) - ;(async () => { try { communityList = await backend.fetchCommunityTemplates() @@ -60,7 +59,7 @@ // Community templates not yet installed let available = $derived( - communityList.filter((c) => !installed.some((i) => i.id === c.id)) + communityList.filter((c) => !installed.some((i) => i.id === c.id)), ) let communityIds = $derived(new Set(communityList.map((c) => c.id))) @@ -195,10 +194,13 @@ > -
- +
-
+

Choose Template

@@ -277,10 +284,15 @@ class="h-3.5 w-3.5 shrink-0 rounded-[3px]" /> {/if} - {tpl.name} + {tpl.name} {#if label} - {label} + {label} {/if} {/each}
{/if}
-
{/key}
diff --git a/app/src/components/overlays/UpdateBanner.svelte b/app/src/components/overlays/UpdateBanner.svelte index 2df8f21..202b089 100644 --- a/app/src/components/overlays/UpdateBanner.svelte +++ b/app/src/components/overlays/UpdateBanner.svelte @@ -19,7 +19,9 @@ status = 'available' } else if (!silent) { status = 'up_to_date' - setTimeout(() => { if (status === 'up_to_date') status = 'hidden' }, 3000) + setTimeout(() => { + if (status === 'up_to_date') status = 'hidden' + }, 3000) } else { status = 'hidden' } @@ -59,7 +61,9 @@ onMount(async () => { if (typeof window.__TAURI__ === 'undefined') return - const unlisten = await listen('check_for_updates', () => doCheck({ silent: false })) + const unlisten = await listen('check_for_updates', () => + doCheck({ silent: false }), + ) // Silent auto-check on startup — only show banner if update found if (!import.meta.env.DEV) { @@ -76,42 +80,42 @@ role="status" > {#if status === 'checking'} - + Checking for updates… - {:else if status === 'available'} v{update.version} available + >Update & Restart - + aria-label="Dismiss">× {:else if status === 'downloading'} - - Downloading{progress > 0 ? ` ${progress}%` : '…'} - + + Downloading{progress > 0 ? ` ${progress}%` : '…'} {:else if status === 'done'} Restarting… - {:else if status === 'up_to_date'} You're up to date - {:else if status === 'error'} Update check failed + aria-label="Dismiss">× {/if}
{/if} diff --git a/app/src/components/panels/AiChatPanel.svelte b/app/src/components/panels/AiChatPanel.svelte index a48e38f..e367975 100644 --- a/app/src/components/panels/AiChatPanel.svelte +++ b/app/src/components/panels/AiChatPanel.svelte @@ -34,7 +34,10 @@ await app.generateTemplate(trimmed, { edit: isEdit }) messages = [ ...messages, - { role: 'assistant', text: isEdit ? 'Template updated.' : 'New template created.' }, + { + role: 'assistant', + text: isEdit ? 'Template updated.' : 'New template created.', + }, ] } catch (e) { messages = [...messages, { role: 'error', text: e?.message ?? String(e) }] @@ -60,9 +63,13 @@
-
+
- + AI Assistant
@@ -70,7 +77,9 @@ {#if app.config}
-
+
{:else if msg.role === 'assistant'} @@ -132,7 +150,9 @@ class="max-w-[90%] rounded-[6px] bg-zinc-800/60 border border-zinc-700 px-3 py-2 flex items-center gap-2" > -

{msg.text}

+

+ {msg.text} +

{:else} @@ -156,7 +176,8 @@ - +
{/if} diff --git a/app/src/components/panels/ElementList.svelte b/app/src/components/panels/ElementList.svelte index de9f037..2421170 100644 --- a/app/src/components/panels/ElementList.svelte +++ b/app/src/components/panels/ElementList.svelte @@ -38,9 +38,9 @@ // ── Drag state ─────────────────────────────────────────────────────────────── let draggingId = $state(null) - let dropIndex = $state(null) // index into listItems() for between-item drops - let dropGroupId = $state(null) // group id when hovering over a group header (add-to-group) - let dropGroupedIndex = $state(null) // insert index within a group's element list + let dropIndex = $state(null) // index into listItems() for between-item drops + let dropGroupId = $state(null) // group id when hovering over a group header (add-to-group) + let dropGroupedIndex = $state(null) // insert index within a group's element list let dropGroupedGroupId = $state(null) // which group the within-group drop targets let pointerDrag = $state(null) let suppressClickId = $state(null) @@ -69,9 +69,18 @@ const byId = {} for (const el of app.config.elements) { const meta = elementMeta(el) - byId[el.id] = { id: el.id, type: meta.kind, name: meta.name, unit: meta.unit, metric: el.value } + byId[el.id] = { + id: el.id, + type: meta.kind, + name: meta.name, + unit: meta.unit, + metric: el.value, + } } - return [...(app.elementLayerOrder ?? [])].reverse().map((id) => byId[id]).filter(Boolean) + return [...(app.elementLayerOrder ?? [])] + .reverse() + .map((id) => byId[id]) + .filter(Boolean) }) // Structured list for rendering: groups appear where their front-most @@ -97,7 +106,12 @@ seenGroups.add(gid) const group = groupById.get(gid) const groupEls = els.filter((e) => elementToGroup.get(e.id) === gid) - items.push({ kind: 'group', id: gid, name: group?.name ?? gid, elements: groupEls }) + items.push({ + kind: 'group', + id: gid, + name: group?.name ?? gid, + elements: groupEls, + }) } } return items @@ -105,7 +119,8 @@ async function defaultsForPreset(preset) { const defaults = preset.defaults(app.config?.scene) - if (preset.type !== 'meter' || !app.gpxFilename || !app.config?.scene) return defaults + if (preset.type !== 'meter' || !app.gpxFilename || !app.config?.scene) + return defaults const metric = defaults.value ?? 'speed' const start = app.config.scene.start ?? 0 const end = app.config.scene.end ?? app.timelineDuration @@ -138,12 +153,15 @@ async function onImageSelected(filename) { addImagePending = false const defaults = ADD_PRESETS.find((p) => p.type === 'image').defaults() - let width = defaults.width, height = defaults.height + let width = defaults.width, + height = defaults.height try { const size = await backend.imageSize(filename) width = size.width height = size.height - } catch { /* fallback to preset defaults */ } + } catch { + /* fallback to preset defaults */ + } app.addElement('image', { ...defaults, file: filename, @@ -211,9 +229,19 @@ function onRowPointerDown(e, id, kind, fromGroupId = null) { if (e.button !== 0 || e.target.closest('[data-layer-action]')) return const startIdx = fromGroupId - ? listItems().findIndex((it) => it.kind === 'group' && it.id === fromGroupId) + ? listItems().findIndex( + (it) => it.kind === 'group' && it.id === fromGroupId, + ) : listItems().findIndex((it) => it.id === id) - pointerDrag = { id, kind, fromGroupId, pointerId: e.pointerId, startY: e.clientY, moved: false, shiftKey: e.shiftKey } + pointerDrag = { + id, + kind, + fromGroupId, + pointerId: e.pointerId, + startY: e.clientY, + moved: false, + shiftKey: e.shiftKey, + } dropIndex = startIdx e.currentTarget.setPointerCapture?.(e.pointerId) } @@ -241,7 +269,9 @@ // Within-group reorder: only when dragging a grouped element over siblings of its own group if (isDraggingElement && fromGroupId != null) { - const siblings = [...document.querySelectorAll(`[data-grouped-item="${fromGroupId}"]`)] + const siblings = [ + ...document.querySelectorAll(`[data-grouped-item="${fromGroupId}"]`), + ] if (siblings.length > 0) { const firstRect = siblings[0].getBoundingClientRect() const lastRect = siblings[siblings.length - 1].getBoundingClientRect() @@ -283,7 +313,10 @@ const rect = row.getBoundingClientRect() return clientY < rect.top + rect.height / 2 }) - dropIndex = hitRow === -1 ? listItems().length : parseInt(rows[hitRow].dataset.listItem) + dropIndex = + hitRow === -1 + ? listItems().length + : parseInt(rows[hitRow].dataset.listItem) } function onWindowPointerMove(e) { @@ -318,22 +351,37 @@ const { id, kind, fromGroupId } = pointerDrag // Within-group reorder - if (kind === 'element' && dropGroupedIndex != null && dropGroupedGroupId != null) { + if ( + kind === 'element' && + dropGroupedIndex != null && + dropGroupedGroupId != null + ) { const groupId = dropGroupedGroupId - const groupItem = listItems().find((it) => it.kind === 'group' && it.id === groupId) + const groupItem = listItems().find( + (it) => it.kind === 'group' && it.id === groupId, + ) if (!groupItem) return const fromIdx = groupItem.elements.findIndex((e) => e.id === id) if (fromIdx < 0) return const newGroupEls = [...groupItem.elements] const [moved] = newGroupEls.splice(fromIdx, 1) - const toIdx = Math.max(0, Math.min(newGroupEls.length, dropGroupedIndex - (fromIdx < dropGroupedIndex ? 1 : 0))) + const toIdx = Math.max( + 0, + Math.min( + newGroupEls.length, + dropGroupedIndex - (fromIdx < dropGroupedIndex ? 1 : 0), + ), + ) if (toIdx === fromIdx) { app.selectedElementId = id suppressClickId = id return } newGroupEls.splice(toIdx, 0, moved) - reorderWithinGroup(groupId, newGroupEls.map((e) => e.id)) + reorderWithinGroup( + groupId, + newGroupEls.map((e) => e.id), + ) app.selectedElementId = id suppressClickId = id return @@ -351,13 +399,17 @@ // Drag a grouped element out to a top-level position if (fromGroupId != null && dropGroupId == null) { const items = listItems() - const groupItem = items.find((it) => it.kind === 'group' && it.id === fromGroupId) + const groupItem = items.find( + (it) => it.kind === 'group' && it.id === fromGroupId, + ) if (!groupItem) return const el = groupItem.elements.find((e) => e.id === id) if (!el) return const groupBecomesEmpty = groupItem.elements.length === 1 - const groupIdx = items.findIndex((it) => it.kind === 'group' && it.id === fromGroupId) + const groupIdx = items.findIndex( + (it) => it.kind === 'group' && it.id === fromGroupId, + ) // Build list with element removed from its group (possibly dropping the now-empty group) const modifiedBase = items.flatMap((it) => { @@ -369,9 +421,10 @@ }) // Adjust index for the removed group item (if group was eliminated) - const adjustedIdx = groupBecomesEmpty && dropIndex > groupIdx - ? Math.max(0, dropIndex - 1) - : dropIndex + const adjustedIdx = + groupBecomesEmpty && dropIndex > groupIdx + ? Math.max(0, dropIndex - 1) + : dropIndex const clampedIdx = Math.max(0, Math.min(modifiedBase.length, adjustedIdx)) const newItems = [...modifiedBase] @@ -388,7 +441,10 @@ const next = [...items] const [moved] = next.splice(fromIdx, 1) - const toIdx = Math.max(0, Math.min(next.length, dropIndex - (fromIdx < dropIndex ? 1 : 0))) + const toIdx = Math.max( + 0, + Math.min(next.length, dropIndex - (fromIdx < dropIndex ? 1 : 0)), + ) next.splice(toIdx, 0, moved) if (toIdx !== fromIdx) { @@ -414,7 +470,8 @@ app.toggleElementSelection(id) } else { const selected = app.selectedElementIds.includes(id) - app.selectedElementId = selected && app.selectedElementIds.length === 1 ? null : id + app.selectedElementId = + selected && app.selectedElementIds.length === 1 ? null : id } suppressClickId = id } @@ -437,15 +494,14 @@ app.toggleElementSelection(id) } else { const selected = app.selectedElementIds.includes(id) - app.selectedElementId = selected && app.selectedElementIds.length === 1 ? null : id + app.selectedElementId = + selected && app.selectedElementIds.length === 1 ? null : id } } function onKeydown(e) { if (e.key === 'Escape') addMenuOpen = false } - -
-

Elements

+

+ Elements +

{#if (app.selectedElementIds?.length ?? 0) >= 2} -
+
+ tabindex="-1"> + tabindex="-1"> + tabindex="-1"> + tabindex="-1">
{/each} {/if} - {:else} {@const el = item} {@const selected = app.selectedElementIds.includes(el.id)} {@const Icon = ICONS[el.type] ?? BarChart2} - {@const listIdx = listItems().findIndex((it) => it.id === el.id && it.kind === 'element')} + {@const listIdx = listItems().findIndex( + (it) => it.id === el.id && it.kind === 'element', + )}
  • onRowPointerDown(e, el.id, 'element')} @@ -653,47 +763,65 @@ onclick={(e) => selectElement(e, el.id)} class={`w-full flex items-center gap-2 px-2.5 py-2 pr-20 rounded-[6px] text-left text-sm transition-colors cursor-grab active:cursor-grabbing - ${selected - ? 'bg-primary/10 text-primary border border-primary/30' - : 'text-zinc-300 hover:bg-[var(--panel2)] hover:text-zinc-100'}`} + ${ + selected + ? 'bg-primary/10 text-primary border border-primary/30' + : 'text-zinc-300 hover:bg-[var(--panel2)] hover:text-zinc-100' + }`} > {el.name} {#if el.unit} - {el.unit === 'imperial' ? 'imp' : el.unit} + {el.unit === 'imperial' ? 'imp' : el.unit} {/if} {#if missingRiderWeight(el.metric)} - + {/if} -
    +
    + tabindex="-1"> + tabindex="-1"> + tabindex="-1">
  • {/if} - {/each}
  • { if (e.target === e.currentTarget) addMenuOpen = false }} + onmousedown={(e) => { + if (e.target === e.currentTarget) addMenuOpen = false + }} >
    -
    -
    +
    +

    Add element

    -

    Choose an overlay type to place on the canvas.

    +

    + Choose an overlay type to place on the canvas. +

    {/each} diff --git a/app/src/components/panels/ElementProperties.svelte b/app/src/components/panels/ElementProperties.svelte index c35bc45..f85df0f 100644 --- a/app/src/components/panels/ElementProperties.svelte +++ b/app/src/components/panels/ElementProperties.svelte @@ -5,7 +5,24 @@ */ import { getContext } from 'svelte' import { SvelteMap } from 'svelte/reactivity' - import { AlertTriangle, AlignHorizontalJustifyCenter, AlignVerticalJustifyCenter, BarChart2, Folder, FolderOpen, Gauge as GaugeIcon, Hash, Image as ImageIcon, Lock, LockOpen, Map as MapIcon, Square, Thermometer, Type, Ungroup } from 'lucide-svelte' + import { + AlertTriangle, + AlignHorizontalJustifyCenter, + AlignVerticalJustifyCenter, + BarChart2, + Folder, + FolderOpen, + Gauge as GaugeIcon, + Hash, + Image as ImageIcon, + Lock, + LockOpen, + Map as MapIcon, + Square, + Thermometer, + Type, + Ungroup, + } from 'lucide-svelte' import Input from '../ui/Input.svelte' import OpacityControl from '../ui/OpacityControl.svelte' import Select from '../ui/Select.svelte' @@ -15,24 +32,56 @@ import RiderWeightPrompt from '../ui/RiderWeightPrompt.svelte' import AssetPicker from '../overlays/AssetPicker.svelte' import * as backend from '../../api/backend.js' - import { defaultColorBands, elementMeta, elementTypeName, LAP_METRICS as ALL_LAP_METRICS, isLapMetric } from '../../lib/elementTypes.js' + import { + defaultColorBands, + elementMeta, + elementTypeName, + LAP_METRICS as ALL_LAP_METRICS, + isLapMetric, + } from '../../lib/elementTypes.js' import { formatTime } from '../../lib/utils.js' - import { metricRangeIssues, metricValueIssue } from '../../lib/metricLimits.js' - import { normalizeElementField, normalizeTemplateIntegerField } from '../../lib/templateSchema.js' + import { + metricRangeIssues, + metricValueIssue, + } from '../../lib/metricLimits.js' + import { + normalizeElementField, + normalizeTemplateIntegerField, + } from '../../lib/templateSchema.js' const app = getContext('app') // Single source of truth: app.fonts (bundled, user-installed, and system). // Importing new fonts lives in the Templates menu (Add Custom Font…). - const fontGroup = (font) => (font.source === 'system' ? 'System fonts' : 'Font files') + const fontGroup = (font) => + font.source === 'system' ? 'System fonts' : 'Font files' function fontOpts(includeSceneDefault) { return [ ...(includeSceneDefault ? [{ value: '', label: 'Scene default' }] : []), - ...app.fonts.map((font) => ({ value: font.value, label: font.label, group: fontGroup(font) })), + ...app.fonts.map((font) => ({ + value: font.value, + label: font.label, + group: fontGroup(font), + })), ] } - const ALL_METRICS = ['speed', 'heartrate', 'power', 'power_to_weight', 'elevation', 'cadence', 'gradient', 'lean', 'temperature', 'gear', 'front_gear', 'rear_gear', 'time', 'distance'] + const ALL_METRICS = [ + 'speed', + 'heartrate', + 'power', + 'power_to_weight', + 'elevation', + 'cadence', + 'gradient', + 'lean', + 'temperature', + 'gear', + 'front_gear', + 'rear_gear', + 'time', + 'distance', + ] // Summary (aggregate) metrics: a single constant value over the whole ride // or the overlay window. Each maps to the base telemetry attribute it needs, @@ -81,7 +130,8 @@ // 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) + 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 @@ -121,9 +171,33 @@ laps_to_go: 'Laps to go', lap_fraction: 'Lap count (2/20)', } - 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'] + 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 @@ -168,7 +242,9 @@ // 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 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)) @@ -177,13 +253,35 @@ // 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' })), + ...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), + ...CUSTOM_METRICS, + ]) + const METER_METRICS = $derived([ + ...filterMetrics(ALL_METER_METRICS), + ...CUSTOM_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' }, @@ -191,8 +289,19 @@ { value: 'left', label: 'Fill leftward' }, ] // 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), ...CUSTOM_METRICS]) + const ALL_COLOR_BY_METRICS = [ + 'gradient', + 'speed', + 'power', + 'heartrate', + 'cadence', + 'temperature', + 'elevation', + ] + 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' }, @@ -201,15 +310,30 @@ // Unit the band thresholds are read in ('' = the metric's default display // unit, matching units::resolve on the Rust side). const COLOR_BY_UNITS = { - speed: [{ value: '', label: 'km/h' }, { value: 'mph', label: 'mph' }], - temperature: [{ value: '', label: '°C' }, { value: 'f', label: '°F' }], - elevation: [{ value: '', label: 'm' }, { value: 'ft', label: 'ft' }], + speed: [ + { value: '', label: 'km/h' }, + { value: 'mph', label: 'mph' }, + ], + temperature: [ + { value: '', label: '°C' }, + { value: 'f', label: '°F' }, + ], + elevation: [ + { value: '', label: 'm' }, + { value: 'ft', label: 'ft' }, + ], + } + const COLOR_BY_UNIT_HINTS = { + gradient: '%', + power: 'W', + heartrate: 'bpm', + cadence: 'rpm', } - const COLOR_BY_UNIT_HINTS = { gradient: '%', power: 'W', heartrate: 'bpm', cadence: 'rpm' } function colorByUnitHint(cb) { const metric = cb.value ?? 'gradient' const units = COLOR_BY_UNITS[metric] - if (units) return (units.find((u) => u.value === (cb.unit ?? '')) ?? units[0]).label + if (units) + return (units.find((u) => u.value === (cb.unit ?? '')) ?? units[0]).label return COLOR_BY_UNIT_HINTS[metric] ?? '' } const COURSE_MARKER_STYLES = [ @@ -249,48 +373,72 @@ // Rust rendering uses `hours_offset`; this list drives the friendly picker // and lets us compute the correct DST-aware offset at the activity's time. const TIMEZONES = [ - { tz: 'Pacific/Honolulu', label: 'Hawaii', group: 'Americas' }, - { tz: 'America/Anchorage', label: 'Alaska', group: 'Americas' }, - { tz: 'America/Los_Angeles', label: 'Pacific Time', group: 'Americas' }, - { tz: 'America/Denver', label: 'Mountain Time', group: 'Americas' }, - { tz: 'America/Phoenix', label: 'Arizona (no DST)', group: 'Americas' }, - { tz: 'America/Chicago', label: 'Central Time', group: 'Americas' }, - { tz: 'America/New_York', label: 'Eastern Time', group: 'Americas' }, - { tz: 'America/Halifax', label: 'Atlantic Time', group: 'Americas' }, - { tz: 'America/St_Johns', label: 'Newfoundland', group: 'Americas' }, - { tz: 'America/Sao_Paulo', label: 'Brasília', group: 'Americas' }, - { tz: 'America/Argentina/Buenos_Aires', label: 'Buenos Aires', group: 'Americas' }, - { tz: 'America/Santiago', label: 'Santiago', group: 'Americas' }, - { tz: 'Atlantic/Reykjavik', label: 'Iceland (no DST)', group: 'Europe' }, - { tz: 'Europe/London', label: 'London', group: 'Europe' }, - { tz: 'Europe/Paris', label: 'Paris, Berlin, Madrid', group: 'Europe' }, - { tz: 'Europe/Helsinki', label: 'Helsinki, Athens', group: 'Europe' }, - { tz: 'Europe/Moscow', label: 'Moscow (no DST)', group: 'Europe' }, - { tz: 'Africa/Lagos', label: 'Lagos, Casablanca', group: 'Africa' }, - { tz: 'Africa/Johannesburg', label: 'Johannesburg', group: 'Africa' }, - { tz: 'Africa/Nairobi', label: 'Nairobi', group: 'Africa' }, - { tz: 'Asia/Riyadh', label: 'Riyadh', group: 'Middle East' }, - { tz: 'Asia/Tehran', label: 'Tehran', group: 'Middle East' }, - { tz: 'Asia/Dubai', label: 'Dubai, Abu Dhabi', group: 'Middle East' }, - { tz: 'Asia/Kabul', label: 'Kabul', group: 'Middle East' }, - { tz: 'Asia/Karachi', label: 'Karachi', group: 'Asia' }, - { tz: 'Asia/Kolkata', label: 'Mumbai, Kolkata', group: 'Asia' }, - { tz: 'Asia/Kathmandu', label: 'Kathmandu', group: 'Asia' }, - { tz: 'Asia/Dhaka', label: 'Dhaka', group: 'Asia' }, - { tz: 'Asia/Rangoon', label: 'Yangon', group: 'Asia' }, - { tz: 'Asia/Bangkok', label: 'Bangkok, Jakarta', group: 'Asia' }, - { tz: 'Asia/Shanghai', label: 'Beijing, Shanghai', group: 'Asia' }, - { tz: 'Asia/Singapore', label: 'Singapore', group: 'Asia' }, - { tz: 'Asia/Tokyo', label: 'Tokyo', group: 'Asia' }, - { tz: 'Asia/Seoul', label: 'Seoul', group: 'Asia' }, - { tz: 'Australia/Perth', label: 'Perth', group: 'Australia / Pacific' }, - { tz: 'Australia/Darwin', label: 'Darwin (no DST)', group: 'Australia / Pacific' }, - { tz: 'Australia/Adelaide', label: 'Adelaide', group: 'Australia / Pacific' }, - { tz: 'Australia/Brisbane', label: 'Brisbane (no DST)', group: 'Australia / Pacific' }, - { tz: 'Australia/Sydney', label: 'Sydney, Melbourne', group: 'Australia / Pacific' }, - { tz: 'Pacific/Auckland', label: 'Auckland', group: 'Australia / Pacific' }, - { tz: 'Pacific/Chatham', label: 'Chatham Islands', group: 'Australia / Pacific' }, - { tz: 'Pacific/Apia', label: 'Samoa', group: 'Australia / Pacific' }, + { tz: 'Pacific/Honolulu', label: 'Hawaii', group: 'Americas' }, + { tz: 'America/Anchorage', label: 'Alaska', group: 'Americas' }, + { tz: 'America/Los_Angeles', label: 'Pacific Time', group: 'Americas' }, + { tz: 'America/Denver', label: 'Mountain Time', group: 'Americas' }, + { tz: 'America/Phoenix', label: 'Arizona (no DST)', group: 'Americas' }, + { tz: 'America/Chicago', label: 'Central Time', group: 'Americas' }, + { tz: 'America/New_York', label: 'Eastern Time', group: 'Americas' }, + { tz: 'America/Halifax', label: 'Atlantic Time', group: 'Americas' }, + { tz: 'America/St_Johns', label: 'Newfoundland', group: 'Americas' }, + { tz: 'America/Sao_Paulo', label: 'Brasília', group: 'Americas' }, + { + tz: 'America/Argentina/Buenos_Aires', + label: 'Buenos Aires', + group: 'Americas', + }, + { tz: 'America/Santiago', label: 'Santiago', group: 'Americas' }, + { tz: 'Atlantic/Reykjavik', label: 'Iceland (no DST)', group: 'Europe' }, + { tz: 'Europe/London', label: 'London', group: 'Europe' }, + { tz: 'Europe/Paris', label: 'Paris, Berlin, Madrid', group: 'Europe' }, + { tz: 'Europe/Helsinki', label: 'Helsinki, Athens', group: 'Europe' }, + { tz: 'Europe/Moscow', label: 'Moscow (no DST)', group: 'Europe' }, + { tz: 'Africa/Lagos', label: 'Lagos, Casablanca', group: 'Africa' }, + { tz: 'Africa/Johannesburg', label: 'Johannesburg', group: 'Africa' }, + { tz: 'Africa/Nairobi', label: 'Nairobi', group: 'Africa' }, + { tz: 'Asia/Riyadh', label: 'Riyadh', group: 'Middle East' }, + { tz: 'Asia/Tehran', label: 'Tehran', group: 'Middle East' }, + { tz: 'Asia/Dubai', label: 'Dubai, Abu Dhabi', group: 'Middle East' }, + { tz: 'Asia/Kabul', label: 'Kabul', group: 'Middle East' }, + { tz: 'Asia/Karachi', label: 'Karachi', group: 'Asia' }, + { tz: 'Asia/Kolkata', label: 'Mumbai, Kolkata', group: 'Asia' }, + { tz: 'Asia/Kathmandu', label: 'Kathmandu', group: 'Asia' }, + { tz: 'Asia/Dhaka', label: 'Dhaka', group: 'Asia' }, + { tz: 'Asia/Rangoon', label: 'Yangon', group: 'Asia' }, + { tz: 'Asia/Bangkok', label: 'Bangkok, Jakarta', group: 'Asia' }, + { tz: 'Asia/Shanghai', label: 'Beijing, Shanghai', group: 'Asia' }, + { tz: 'Asia/Singapore', label: 'Singapore', group: 'Asia' }, + { tz: 'Asia/Tokyo', label: 'Tokyo', group: 'Asia' }, + { tz: 'Asia/Seoul', label: 'Seoul', group: 'Asia' }, + { tz: 'Australia/Perth', label: 'Perth', group: 'Australia / Pacific' }, + { + tz: 'Australia/Darwin', + label: 'Darwin (no DST)', + group: 'Australia / Pacific', + }, + { + tz: 'Australia/Adelaide', + label: 'Adelaide', + group: 'Australia / Pacific', + }, + { + tz: 'Australia/Brisbane', + label: 'Brisbane (no DST)', + group: 'Australia / Pacific', + }, + { + tz: 'Australia/Sydney', + label: 'Sydney, Melbourne', + group: 'Australia / Pacific', + }, + { tz: 'Pacific/Auckland', label: 'Auckland', group: 'Australia / Pacific' }, + { + tz: 'Pacific/Chatham', + label: 'Chatham Islands', + group: 'Australia / Pacific', + }, + { tz: 'Pacific/Apia', label: 'Samoa', group: 'Australia / Pacific' }, ] /** @@ -305,17 +453,22 @@ timeZone: ianaTimezone, timeZoneName: 'shortOffset', }).formatToParts(date) - const offsetStr = parts.find(p => p.type === 'timeZoneName')?.value ?? 'GMT+0' + const offsetStr = + parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT+0' const m = offsetStr.match(/GMT([+-])(\d+)(?::(\d+))?/) if (!m) return 0 const sign = m[1] === '+' ? 1 : -1 - return sign * (parseInt(m[2], 10) + (parseInt(m[3] ?? '0', 10) / 60)) + return sign * (parseInt(m[2], 10) + parseInt(m[3] ?? '0', 10) / 60) } catch { return 0 } } - const tzOptions = TIMEZONES.map(t => ({ value: t.tz, label: t.label, group: t.group })) + const tzOptions = TIMEZONES.map((t) => ({ + value: t.tz, + label: t.label, + group: t.group, + })) // Per-metric explicit unit options. Metrics absent from this map (gradient, // lean, power, cadence, heartrate, time) have no unit choice and render raw. // Legacy 'metric'/'imperial' tokens still render — the Rust side normalizes @@ -342,7 +495,12 @@ } // Default unit token per metric, used when the element has none set yet // (matches the Rust-side default so the picker reflects what renders). - const DEFAULT_UNIT = { distance: 'km', speed: 'kmh', elevation: 'm', temperature: 'c' } + const DEFAULT_UNIT = { + distance: 'km', + speed: 'kmh', + elevation: 'm', + temperature: 'c', + } // Value suffix modes. "auto" tracks the unit picker (km↔mi, W, bpm, …); // "custom" appends the free-text `suffix`; "none" appends nothing. @@ -361,15 +519,25 @@ const s = selected() if (!s) return // Clear the manual suffix when leaving Custom so it can't linger unseen. - app.updateElement(s.id, { suffix_mode: v, suffix: v === 'custom' ? s.item.suffix : undefined }) + app.updateElement(s.id, { + suffix_mode: v, + suffix: v === 'custom' ? s.item.suffix : undefined, + }) } - const markerId = () => `marker-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` + const markerId = () => + `marker-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` // Resolve the unit token to show in the picker, mapping legacy // metric/imperial (or unset) to the matching precise token. function displayUnit(metric, unit) { const opts = UNITS_BY_METRIC[metric] ?? [] if (opts.some((o) => o.value === unit)) return unit - if (unit === 'imperial') return { distance: 'mi', speed: 'mph', elevation: 'ft', temperature: 'f' }[metric] + if (unit === 'imperial') + return { + distance: 'mi', + speed: 'mph', + elevation: 'ft', + temperature: 'f', + }[metric] return DEFAULT_UNIT[metric] } @@ -393,12 +561,13 @@ // 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] ?? '' + 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 @@ -408,7 +577,10 @@ } // Picker options with an Auto row on top that names the inherited unit. function unitOptions(metric) { - return [{ value: '', label: `Auto (${sceneUnit(metric)})` }, ...(UNITS_BY_METRIC[metric] ?? [])] + return [ + { value: '', label: `Auto (${sceneUnit(metric)})` }, + ...(UNITS_BY_METRIC[metric] ?? []), + ] } // Store a picked unit; '' (Auto) clears the field so the element inherits. function changeUnit(v) { @@ -428,17 +600,56 @@ function update(field, raw) { const s = selected() if (!s) return - const numFields = ['x', 'y', 'width', 'height', 'font_size', 'letter_spacing', 'opacity', 'fill_opacity', 'decimal_rounding', 'rotation', 'distance_target', 'time_target', 'hours_offset', 'radius', 'start_angle', 'sweep_angle', 'arc_width', 'needle_width', 'cap_radius', 'segments', 'gap', 'background_opacity', 'background_margin', 'border_width', 'border_opacity', 'scale_font_size', 'scale_offset', 'scale_tick_length', 'scale_tick_width', 'scale_ticks', 'pulse_bpm', 'pulse_amplitude'] + const numFields = [ + 'x', + 'y', + 'width', + 'height', + 'font_size', + 'letter_spacing', + 'opacity', + 'fill_opacity', + 'decimal_rounding', + 'rotation', + 'distance_target', + 'time_target', + 'hours_offset', + 'radius', + 'start_angle', + 'sweep_angle', + 'arc_width', + 'needle_width', + 'cap_radius', + 'segments', + 'gap', + 'background_opacity', + 'background_margin', + 'border_width', + 'border_opacity', + 'scale_font_size', + 'scale_offset', + 'scale_tick_length', + 'scale_tick_width', + 'scale_ticks', + 'pulse_bpm', + 'pulse_amplitude', + ] const rangeBoundFields = ['min', 'max'] let value = rangeBoundFields.includes(field) ? parseRangeBound(raw) - : numFields.includes(field) ? (raw === '' ? undefined : Number(raw)) : raw + : numFields.includes(field) + ? raw === '' + ? undefined + : Number(raw) + : raw value = normalizeElementField(field, value) app.updateElement(s.id, { [field]: value }) } function parseRangeBound(raw) { - const trimmed = String(raw ?? '').trim().toLowerCase() + const trimmed = String(raw ?? '') + .trim() + .toLowerCase() if (trimmed === '') return undefined if (trimmed === 'min' || trimmed === 'max') return trimmed const value = Number(trimmed) @@ -449,7 +660,9 @@ function metricRangeUnit(item) { if (!item?.value) return null - return UNITS_BY_METRIC[item.value] ? effectiveUnit(item.value, item.unit) : item.unit + return UNITS_BY_METRIC[item.value] + ? effectiveUnit(item.value, item.unit) + : item.unit } function rangeContext() { @@ -485,14 +698,15 @@ if (current?.status === 'loading' || current?.status === 'ready') return rangeCache.set(key, { status: 'loading' }) - backend.getActivityMetricRange( - context.gpx, - metric, - unit, - context.start, - context.end, - weightAffectsRange(metric) ? context.riderWeightKg : null, - ) + backend + .getActivityMetricRange( + context.gpx, + metric, + unit, + context.start, + context.end, + weightAffectsRange(metric) ? context.riderWeightKg : null, + ) .then((range) => { rangeCache.set(key, { status: 'ready', range }) }) @@ -507,9 +721,12 @@ function formatRangeValue(value) { if (!Number.isFinite(value)) return 'unavailable' const abs = Math.abs(value) - if (abs >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 1 }) - if (abs >= 100) return value.toLocaleString(undefined, { maximumFractionDigits: 2 }) - if (abs >= 10) return value.toLocaleString(undefined, { maximumFractionDigits: 3 }) + if (abs >= 1000) + return value.toLocaleString(undefined, { maximumFractionDigits: 1 }) + if (abs >= 100) + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }) + if (abs >= 10) + return value.toLocaleString(undefined, { maximumFractionDigits: 3 }) return value.toLocaleString(undefined, { maximumFractionDigits: 4 }) } @@ -535,7 +752,11 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual if (!item) return null const entry = rangeCache.get(rangeKey(item.value, metricRangeUnit(item))) if (entry?.status !== 'ready') return null - const issues = metricRangeIssues(item.value, metricRangeUnit(item), entry.range) + const issues = metricRangeIssues( + item.value, + metricRangeUnit(item), + entry.range, + ) const fields = ['min', 'max'] const field = fields.find((f) => { const bound = item[f] @@ -573,9 +794,16 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual const newConcrete = newUnit === '' ? sceneUnit('distance') : newUnit const updates = { unit: newUnit || undefined } const t = s.item.distance_target - if (oldUnit !== newConcrete && t != null && t !== '' && !Number.isNaN(Number(t))) { - const toM = (v, u) => (u === 'm' ? v : u === 'mi' ? v * 1609.34 : v * 1000) - const fromM = (m, u) => (u === 'm' ? m : u === 'mi' ? m / 1609.34 : m / 1000) + if ( + oldUnit !== newConcrete && + t != null && + t !== '' && + !Number.isNaN(Number(t)) + ) { + const toM = (v, u) => + u === 'm' ? v : u === 'mi' ? v * 1609.34 : v * 1000 + const fromM = (m, u) => + u === 'm' ? m : u === 'mi' ? m / 1609.34 : m / 1000 const meters = toM(Number(t), oldUnit) const conv = fromM(meters, newConcrete) updates.distance_target = @@ -589,7 +817,11 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual const s = selected() if (!s) return const numFields = ['width', 'opacity', 'past_opacity', 'future_opacity'] - const value = numFields.includes(field) ? (raw === '' ? undefined : Number(raw)) : raw + const value = numFields.includes(field) + ? raw === '' + ? undefined + : Number(raw) + : raw const current = s.item[obj] ?? {} app.updateElement(s.id, { [obj]: { ...current, [field]: value } }) } @@ -599,7 +831,11 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual const s = selected() if (!s) return const numFields = ['weight', 'opacity', 'edge_width'] - const value = numFields.includes(field) ? (raw === '' ? undefined : Number(raw)) : raw + const value = numFields.includes(field) + ? raw === '' + ? undefined + : Number(raw) + : raw const current = s.item.point ?? {} app.updateElement(s.id, { point: { ...current, [field]: value } }) } @@ -611,7 +847,9 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual function selectedCourseMarker() { const markers = courseMarkers() if (markers.length === 0) return null - return markers.find((m) => m.id === app.selectedCourseMarkerId) ?? markers[0] + return ( + markers.find((m) => m.id === app.selectedCourseMarkerId) ?? markers[0] + ) } function setCourseMarkers(markers) { @@ -626,7 +864,11 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual try { const start = app.config?.scene?.start ?? 0 const end = app.config?.scene?.end ?? app.timelineDuration - const info = await backend.getActivityDistanceInfo(app.gpxFilename, start, end) + const info = await backend.getActivityDistanceInfo( + app.gpxFilename, + start, + end, + ) return info.overlay_end_m ?? info.total_m ?? 0 } catch { return 0 @@ -658,17 +900,25 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual const marker = selectedCourseMarker() if (!marker) return const numFields = ['distance', 'width', 'height', 'rotation', 'opacity'] - const value = numFields.includes(field) ? (raw === '' ? undefined : Number(raw)) : raw - setCourseMarkers(courseMarkers().map((m) => ( - m === marker || (marker.id && m.id === marker.id) ? { ...m, [field]: value } : m - ))) + const value = numFields.includes(field) + ? raw === '' + ? undefined + : Number(raw) + : raw + setCourseMarkers( + courseMarkers().map((m) => + m === marker || (marker.id && m.id === marker.id) + ? { ...m, [field]: value } + : m, + ), + ) } function removeCourseMarker(id) { const marker = selectedCourseMarker() - const next = courseMarkers().filter((m) => ( - marker ? !(m === marker || (id && m.id === id)) : m.id !== id - )) + const next = courseMarkers().filter((m) => + marker ? !(m === marker || (id && m.id === id)) : m.id !== id, + ) setCourseMarkers(next) app.selectedCourseMarkerId = next[0]?.id ?? null } @@ -734,21 +984,32 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual } function numericBound(value, fallback) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback + return typeof value === 'number' && Number.isFinite(value) + ? value + : fallback } function updateImageSize(field, raw) { const s = selected() if (!s || s.type !== 'image') return const val = raw === '' ? undefined : Number(raw) - if (!val || val <= 0) { app.updateElement(s.id, { [field]: val }); return } + if (!val || val <= 0) { + app.updateElement(s.id, { [field]: val }) + return + } const nw = s.item.natural_width ?? s.item.width ?? 200 const nh = s.item.natural_height ?? s.item.height ?? 200 const ratio = nw / nh if (field === 'width') { - app.updateElement(s.id, { width: Math.round(val), height: Math.round(val / ratio) }) + app.updateElement(s.id, { + width: Math.round(val), + height: Math.round(val / ratio), + }) } else { - app.updateElement(s.id, { width: Math.round(val * ratio), height: Math.round(val) }) + app.updateElement(s.id, { + width: Math.round(val * ratio), + height: Math.round(val), + }) } } @@ -763,7 +1024,9 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual updates.height = Math.round(width * (size.height / size.width)) updates.natural_width = size.width updates.natural_height = size.height - } catch { /* fallback: keep existing dimensions */ } + } catch { + /* fallback: keep existing dimensions */ + } app.updateElement(s.id, updates) } @@ -778,12 +1041,7 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual const s = selected() if (!s) return '#ffffff' const it = s.item - return ( - it.color ?? - it.line?.color ?? - it.point?.color ?? - '#ffffff' - ) + return it.color ?? it.line?.color ?? it.point?.color ?? '#ffffff' } // Basic mode: one color drives every color on the element. Applied as a @@ -821,16 +1079,27 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual if (!s) return // Banded fills read best solid; lift a translucent fill to full opacity // in the same (single-undo) update. - const patch = { color_by: { value: 'gradient', bands: defaultColorBands('gradient') } } + const patch = { + color_by: { value: 'gradient', bands: defaultColorBands('gradient') }, + } const fillOpacity = s.item.fill?.opacity - if (s.item.value !== 'course' && typeof fillOpacity === 'number' && fillOpacity < 1) { + if ( + s.item.value !== 'course' && + typeof fillOpacity === 'number' && + fillOpacity < 1 + ) { patch.fill = { ...s.item.fill, opacity: 1 } } app.updateElement(s.id, patch) } // Changing the driving metric resets bands — thresholds are metric-specific. function setColorByMetric(metric) { - setColorBy({ ...colorBy(), value: metric, unit: undefined, bands: defaultColorBands(metric) }) + setColorBy({ + ...colorBy(), + value: metric, + unit: undefined, + bands: defaultColorBands(metric), + }) } function setColorByField(field, value) { setColorBy({ ...colorBy(), [field]: value }) @@ -839,20 +1108,32 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual return colorBy()?.bands ?? [] } function updateColorBand(i, patch) { - const bands = colorByBands().map((b, idx) => (idx === i ? { ...b, ...patch } : b)) + const bands = colorByBands().map((b, idx) => + idx === i ? { ...b, ...patch } : b, + ) setColorBy({ ...colorBy(), bands }) } function removeColorBand(i) { - setColorBy({ ...colorBy(), bands: colorByBands().filter((_, idx) => idx !== i) }) + setColorBy({ + ...colorBy(), + bands: colorByBands().filter((_, idx) => idx !== i), + }) } function addColorBand() { const bands = [...colorByBands()] - const maxes = bands.map((b) => b.max).filter((m) => typeof m === 'number').sort((a, b) => a - b) + const maxes = bands + .map((b) => b.max) + .filter((m) => typeof m === 'number') + .sort((a, b) => a - b) const top = maxes.at(-1) const step = maxes.length >= 2 ? maxes.at(-1) - maxes.at(-2) : 5 - const next = { max: top != null ? top + step : 0, color: bands.at(-1)?.color ?? '#ffffff' } + const next = { + max: top != null ? top + step : 0, + color: bands.at(-1)?.color ?? '#ffffff', + } // Keep the catch-all (no max) band last. - const catchAll = bands.length && bands.at(-1).max == null ? bands.pop() : null + const catchAll = + bands.length && bands.at(-1).max == null ? bands.pop() : null bands.push(next) if (catchAll) bands.push(catchAll) setColorBy({ ...colorBy(), bands }) @@ -891,33 +1172,46 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual } // Scale labels: null = disabled, [] = auto (min/mid/max), [v,...] = explicit values. - function scaleLabels() { return selected()?.item.scale_labels ?? null } - function scaleEnabled() { return scaleLabels() !== null && scaleLabels() !== undefined } + function scaleLabels() { + return selected()?.item.scale_labels ?? null + } + function scaleEnabled() { + return scaleLabels() !== null && scaleLabels() !== undefined + } function enableScale() { - const s = selected(); if (!s) return + const s = selected() + if (!s) return app.updateElement(s.id, { scale_labels: [] }) } function disableScale() { - const s = selected(); if (!s) return + const s = selected() + if (!s) return app.updateElement(s.id, { scale_labels: undefined }) } function addScaleLabel() { - const s = selected(); if (!s) return + const s = selected() + if (!s) return const cur = scaleLabels() ?? [] const min = numericBound(s.item.min, 0) const max = numericBound(s.item.max, 1) const mid = (min + max) / 2 - app.updateElement(s.id, { scale_labels: [...cur, cur.length === 0 ? min : mid] }) + app.updateElement(s.id, { + scale_labels: [...cur, cur.length === 0 ? min : mid], + }) } function updateScaleLabel(i, raw) { - const s = selected(); if (!s) return + const s = selected() + if (!s) return const cur = [...(scaleLabels() ?? [])] cur[i] = raw === '' ? 0 : Number(raw) app.updateElement(s.id, { scale_labels: cur }) } function removeScaleLabel(i) { - const s = selected(); if (!s) return - app.updateElement(s.id, { scale_labels: (scaleLabels() ?? []).filter((_, idx) => idx !== i) }) + const s = selected() + if (!s) return + app.updateElement(s.id, { + scale_labels: (scaleLabels() ?? []).filter((_, idx) => idx !== i), + }) } // ── Anchoring ────────────────────────────────────────────────────────────── @@ -946,8 +1240,22 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual // Text alignment presets per anchor point: keep the text inside the target // box (anchor to "left" edge → text grows rightward). Users can override // via the Alignment selects afterwards. - const ANCHOR_TEXT_ALIGN = { 'top-left': 'left', left: 'left', 'bottom-left': 'left', 'top-right': 'right', right: 'right', 'bottom-right': 'right' } - const ANCHOR_VERTICAL_ALIGN = { 'top-left': 'top', top: 'top', 'top-right': 'top', 'bottom-left': 'bottom', bottom: 'bottom', 'bottom-right': 'bottom' } + const ANCHOR_TEXT_ALIGN = { + 'top-left': 'left', + left: 'left', + 'bottom-left': 'left', + 'top-right': 'right', + right: 'right', + 'bottom-right': 'right', + } + const ANCHOR_VERTICAL_ALIGN = { + 'top-left': 'top', + top: 'top', + 'top-right': 'top', + 'bottom-left': 'bottom', + bottom: 'bottom', + 'bottom-right': 'bottom', + } function anchorTargetOpts() { const s = selected() @@ -965,7 +1273,12 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual return false } return els - .filter((e) => ANCHOR_TARGET_TYPES.includes(e.type) && e.id !== s.id && !chainHitsSelf(e)) + .filter( + (e) => + ANCHOR_TARGET_TYPES.includes(e.type) && + e.id !== s.id && + !chainHitsSelf(e), + ) .map((e) => ({ value: e.id, label: e.id })) } @@ -1051,15 +1364,23 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
    {#if app.selectedGroupId} - {@const group = (app.config?.scene?.groups ?? []).find((g) => g.id === app.selectedGroupId)} + {@const group = (app.config?.scene?.groups ?? []).find( + (g) => g.id === app.selectedGroupId, + )} {#if group}
    -

    Group

    +

    + Group +

    - +
    - {group.element_ids.length} element{group.element_ids.length !== 1 ? 's' : ''} + {group.element_ids.length} element{group.element_ids.length !== 1 + ? 's' + : ''}
    -

    Drag any element in this group on the canvas to move them all together.

    +

    + Drag any element in this group on the canvas to move them all + together. +

    {/if} {:else if !selected()}
    -

    Click an element on the canvas
    or in the list to edit it.

    +

    + Click an element on the canvas
    or in the list to edit it. +

    {:else} {@const { id, item, type } = selected()} {@const meta = elementMeta(item)} - {@const HeaderIcon = { type: Type, hash: Hash, bar: BarChart2, map: MapIcon, meter: Thermometer, gauge: GaugeIcon, rect: Square, image: ImageIcon }[meta.icon] ?? BarChart2} + {@const HeaderIcon = + { + type: Type, + hash: Hash, + bar: BarChart2, + map: MapIcon, + meter: Thermometer, + gauge: GaugeIcon, + rect: Square, + image: ImageIcon, + }[meta.icon] ?? BarChart2} -
    - +
    +
    -

    {meta.name}

    -

    {elementTypeName(item).toLowerCase()}

    +

    + {meta.name} +

    +

    + {elementTypeName(item).toLowerCase()} +

    - Advanced + Advanced
    - Align + Align + updateColorBand(i, { color: v })} + class="flex-1 min-w-0" + /> +
    {/each}
    @@ -1562,226 +2189,379 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual plots geography and `distance` is already the axis, so neither offers the choice. --> {#if item.value !== 'course' && item.value !== 'distance'} +
    +

    + X-Axis +

    + +

    + Off: evenly spaced by time. On: horizontal position reflects + distance travelled. +

    +
    + {/if} +
    -

    X-Axis

    -
    - {/if} - -
    -

    Fill

    - - -
    - - {#if item.value === 'course'} - -
    -

    Visibility

    - - -
    - {/if} - - - {@const pt = item.point ?? {}} -
    -

    Tracking Point

    - - - {#if !(pt.remove_edge_color ?? false)} - - {/if} - -
    - - {@const pl = item.point_label} -
    -

    Point Label

    - - {#if pl != null} -
    -
    - - -
    - -
    -
    + - -
    -

    Size

    -
    - -
    - -
    - {/if} - - - {#if item.value === 'course'} -
    -
    -

    Course Markers

    - -
    - {#if courseMarkers().length === 0} -

    No markers.

    - {:else} -
    - {#each courseMarkers() as marker, i (marker.id ?? i)} - - {/each} -
    - {@const marker = selectedCourseMarker()} - {#if marker} + {#if pl != null} +
    + + +
    -
    + + +
    +

    Size

    +
    + + +
    + +
    + {/if} + + + {#if item.value === 'course'} +
    +
    +

    + Course Markers +

    + +
    + {#if courseMarkers().length === 0} +

    No markers.

    + {:else} +
    + {#each courseMarkers() as marker, i (marker.id ?? i)} + + {/each} +
    + {@const marker = selectedCourseMarker()} + {#if marker} + -
    + {/if} {/if} @@ -1791,131 +2571,207 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual

    Metric

    changeUnit(v)} /> - +
    {#if rangeWarning(item)} -
    +
    {rangeWarning(item)}
    {/if} {#if showAdvanced} - -
    - - {#if (item.segments ?? 0) >= 1} -
    - {/if} {#if (item.segments ?? 0) >= 1} -
    -

    Fill

    -
    - Gradient (min → max) - -
    - {#if meterGradient().length === 0} - - {/if} - {#each meterGradient() as stop, i (i)} -
    - updateGradientStop(i, v)} class="flex-1 min-w-0" /> - +
    +

    Fill

    +
    + Gradient (min → max) +
    - {/each} -
    + {#if meterGradient().length === 0} + + {/if} + {#each meterGradient() as stop, i (i)} +
    + updateGradientStop(i, v)} + class="flex-1 min-w-0" + /> + +
    + {/each} +
    {:else} -
    -

    Fill

    - - - -
    +
    +

    Fill

    + + + +
    {/if}
    -

    Background

    +

    + Background +

    {#if item.background} - + {/if}
    @@ -1924,125 +2780,214 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual

    Border

    {#if item.border_color} -
    - - -
    +
    + + +
    {/if} {#if showAdvanced} -
    -
    -

    Scale

    - {#if scaleEnabled()} - - {:else} - - {/if} -
    - {#if scaleEnabled()} -
    -
    - Labels (empty = auto) - -
    - {#if (scaleLabels() ?? []).length === 0} -

    Auto: min, mid, max

    +
    +
    +

    + Scale +

    + {#if scaleEnabled()} + + {:else} + {/if} - {#each (scaleLabels() ?? []) as val, i (i)} -
    - updateScaleLabel(i, e.target.value)} - class="flex-1 font-mono text-xs" /> - -
    - {/each}
    - - -
    + {/if} +
    + + +
    +

    Size

    +
    - {/if} -
    - - -
    -

    Size

    -
    -
    - - -
    + {/if} {/if} @@ -2052,169 +2997,289 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual

    Metric

    changeUnit(v)} /> - +
    {#if rangeWarning(item)} -
    +
    {rangeWarning(item)}
    {/if} {#if showAdvanced} -
    -

    Dial

    - -
    -
    -
    -
    - -
    -
    - Progress gradient (start → end) - -
    - {#if meterGradient().length === 0} -

    No stops — uses progress color.

    - {/if} - {#each meterGradient() as stop, i (i)} -
    - updateGradientStop(i, v)} class="flex-1 min-w-0" /> - -
    - {/each} -
    - - - - - - -
    -
    -
    + +
    + + +
    + - -
    -

    Size

    -
    -
    - - -
    + {/if} {/if} @@ -2225,28 +3290,52 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
    {#if showAdvanced} - - - + + + {/if} @@ -2254,12 +3343,18 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual

    Fill

    @@ -2267,22 +3362,33 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual

    Border

    {#if item.border_color} -
    - - -
    +
    + + +
    {/if} {/if} @@ -2292,7 +3398,11 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual

    Asset

    - + {item.file || 'None selected'}
    {#if showAdvanced} - - + + {/if}
    {#if showAdvanced} -
    -

    Pulse animation

    - - {/if} - -
    +
    +

    + Pulse animation +

    + + {/if} + +
    {/if} {#if showAssetPicker} { applyAsset(name); showAssetPicker = false }} + onselect={(name) => { + applyAsset(name) + showAssetPicker = false + }} oncancel={() => (showAssetPicker = false)} /> {/if} @@ -2371,69 +3519,100 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual {#if ANCHORABLE_TYPES.includes(type)} -
    -

    Anchor

    - -
    - -
    diff --git a/app/src/components/ui/Badge.svelte b/app/src/components/ui/Badge.svelte index a0607e9..9e26e07 100644 --- a/app/src/components/ui/Badge.svelte +++ b/app/src/components/ui/Badge.svelte @@ -4,16 +4,19 @@ const variants = { default: 'bg-primary/15 text-primary border border-primary/30', secondary: 'bg-[var(--panel3)] text-zinc-300 border border-transparent', - destructive: 'bg-destructive/15 text-destructive border border-destructive/30', + destructive: + 'bg-destructive/15 text-destructive border border-destructive/30', success: 'bg-green-500/15 text-green-400 border border-green-500/30', outline: 'border border-zinc-700 text-muted-foreground', } - + {@render children?.()} diff --git a/app/src/components/ui/Button.svelte b/app/src/components/ui/Button.svelte index a0b06ea..e60c2a9 100644 --- a/app/src/components/ui/Button.svelte +++ b/app/src/components/ui/Button.svelte @@ -13,10 +13,14 @@ } = $props() const variants = { - default: 'bg-primary text-primary-foreground hover:bg-[var(--accent-hover)]', - outline: 'bg-[var(--panel2)] hover:bg-[var(--panel3)] text-muted-foreground hover:text-foreground', - ghost: 'hover:bg-[var(--panel2)] text-muted-foreground hover:text-foreground', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + default: + 'bg-primary text-primary-foreground hover:bg-[var(--accent-hover)]', + outline: + 'bg-[var(--panel2)] hover:bg-[var(--panel3)] text-muted-foreground hover:text-foreground', + ghost: + 'hover:bg-[var(--panel2)] text-muted-foreground hover:text-foreground', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', } const sizes = { default: 'h-9 px-4 py-2 text-sm', diff --git a/app/src/components/ui/Input.svelte b/app/src/components/ui/Input.svelte index 3a94961..3162edd 100644 --- a/app/src/components/ui/Input.svelte +++ b/app/src/components/ui/Input.svelte @@ -28,7 +28,12 @@ } function handleKeydown(e) { - if (isNumber && (e.metaKey || e.ctrlKey) && !e.shiftKey && (e.key === 'z' || e.key === 'Z')) { + if ( + isNumber && + (e.metaKey || e.ctrlKey) && + !e.shiftKey && + (e.key === 'z' || e.key === 'Z') + ) { e.preventDefault() app?.undo?.() e.currentTarget.blur() @@ -37,7 +42,9 @@ if ( isNumber && (e.metaKey || e.ctrlKey) && - ((e.shiftKey && (e.key === 'z' || e.key === 'Z')) || e.key === 'y' || e.key === 'Y') + ((e.shiftKey && (e.key === 'z' || e.key === 'Z')) || + e.key === 'y' || + e.key === 'Y') ) { e.preventDefault() app?.redo?.() diff --git a/app/src/components/ui/OpacityControl.svelte b/app/src/components/ui/OpacityControl.svelte index b073ea4..713315e 100644 --- a/app/src/components/ui/OpacityControl.svelte +++ b/app/src/components/ui/OpacityControl.svelte @@ -37,9 +37,7 @@ } function onTextInput(raw) { - const sanitized = raw - .replace(/[^\d.]/g, '') - .replace(/(\..*)\./g, '$1') + const sanitized = raw.replace(/[^\d.]/g, '').replace(/(\..*)\./g, '$1') inputDraft = sanitized if (sanitized === '' || sanitized === '.') return const parsed = parseFloat(sanitized) @@ -58,7 +56,8 @@ e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End' - ) return + ) + return if (/^\d$/.test(e.key) || e.key === '.') return e.preventDefault() } @@ -99,7 +98,8 @@ onpointerup={endRangeEdit} onpointercancel={endRangeEdit} onkeydown={(e) => { - if (e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End') beginRangeEdit() + if (e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End') + beginRangeEdit() }} onkeyup={endRangeEdit} oninput={(e) => emit(e.target.value)} diff --git a/app/src/components/ui/RiderWeightField.svelte b/app/src/components/ui/RiderWeightField.svelte index aa938b3..9386736 100644 --- a/app/src/components/ui/RiderWeightField.svelte +++ b/app/src/components/ui/RiderWeightField.svelte @@ -46,8 +46,7 @@ return } const entered = Number(raw) - const kg = - app.riderWeightUnit === 'lb' ? entered * 0.45359237 : entered + const kg = app.riderWeightUnit === 'lb' ? entered * 0.45359237 : entered if (!Number.isFinite(entered) || kg < MIN_KG || kg > MAX_KG) { error = `Enter a weight between ${bounds[0]} and ${bounds[1]} ${app.riderWeightUnit}.` return @@ -96,7 +95,9 @@ onkeydown={onKeydown} class="w-20 h-7 rounded-[6px] border-0 bg-[var(--panel2)] px-2 text-xs text-foreground font-mono focus:outline-none focus:ring-1 - {error ? 'ring-1 ring-destructive focus:ring-destructive' : 'focus:ring-ring'}" + {error + ? 'ring-1 ring-destructive focus:ring-destructive' + : 'focus:ring-ring'}" />