From 37d779adb8b77ba7967b280366ec7b006f3f3677 Mon Sep 17 00:00:00 2001 From: Walker Sutton Date: Wed, 30 Sep 2026 00:11:55 -0400 Subject: [PATCH] chore(app): format .svelte files with prettier-plugin-svelte app/.prettierrc never loaded prettier-plugin-svelte, so `pnpm format` and the pre-commit Prettier check silently skipped every .svelte file. Editors that do format Svelte (the VS Code Svelte extension bundles the plugin) rewrote whole files on save, and that drift slipped past the hooks as thousands of lines of noise in feature diffs. Load the plugin and apply it once. Formatting only: the unminified Vite build is byte-identical ignoring whitespace, and the only text changes are reflowed paragraph copy, which HTML collapses. Co-Authored-By: Claude Opus 5.5 --- app/.prettierrc | 3 +- app/src/app.svelte | 14 +- .../components/canvas/ElementHandle.svelte | 103 +- .../components/canvas/PlaybackControls.svelte | 92 +- .../canvas/VideoAlignmentBar.svelte | 12 +- .../components/canvas/VideoBackdrop.svelte | 4 +- app/src/components/canvas/WysiwygLayer.svelte | 442 ++- app/src/components/layout/CenterCanvas.svelte | 518 ++- app/src/components/layout/LeftSidebar.svelte | 511 +-- app/src/components/overlays/AboutModal.svelte | 23 +- .../overlays/ActivityPickerModal.svelte | 8 +- .../components/overlays/AssetPicker.svelte | 63 +- .../components/overlays/ConfirmDialog.svelte | 12 +- app/src/components/overlays/ErrorToast.svelte | 41 +- .../overlays/ExportFormatDialog.svelte | 9 +- .../overlays/NewTemplateDialog.svelte | 8 +- .../overlays/RenderProgressOverlay.svelte | 103 +- .../overlays/RenderStatusChip.svelte | 43 +- app/src/components/overlays/Settings.svelte | 6 +- .../overlays/TemplatePickerModal.svelte | 72 +- .../components/overlays/UpdateBanner.svelte | 34 +- app/src/components/panels/AiChatPanel.svelte | 39 +- app/src/components/panels/ElementList.svelte | 308 +- .../panels/ElementProperties.svelte | 3263 +++++++++++------ app/src/components/ui/Badge.svelte | 15 +- app/src/components/ui/Button.svelte | 12 +- app/src/components/ui/Input.svelte | 11 +- app/src/components/ui/OpacityControl.svelte | 10 +- app/src/components/ui/Select.svelte | 5 +- app/src/components/ui/Switch.svelte | 15 +- app/src/components/ui/Tooltip.svelte | 18 +- 31 files changed, 3973 insertions(+), 1844 deletions(-) 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 8c8cb40..805f949 100644 --- a/app/src/app.svelte +++ b/app/src/app.svelte @@ -822,7 +822,11 @@ {#if AI_ASSISTANT_ENABLED} - + + aria-pressed={showAiChat}> {/if} @@ -1134,7 +1138,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} @@ -877,46 +1085,110 @@ -
- 3 +
+ 3
-

Render Video

-

Export the overlay to a file

+

+ Render Video +

+

+ Export the overlay to a file +

- - + +
-
{/if} @@ -929,25 +1201,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 064d71f..4a95e46 100644 --- a/app/src/components/overlays/ExportFormatDialog.svelte +++ b/app/src/components/overlays/ExportFormatDialog.svelte @@ -51,8 +51,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) @@ -244,9 +243,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 3483274..2e983fb 100644 --- a/app/src/components/overlays/Settings.svelte +++ b/app/src/components/overlays/Settings.svelte @@ -30,7 +30,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 024c8d8..a685792 100644 --- a/app/src/components/panels/ElementList.svelte +++ b/app/src/components/panels/ElementList.svelte @@ -32,9 +32,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) @@ -63,9 +63,17 @@ 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 } + byId[el.id] = { + id: el.id, + type: meta.kind, + name: meta.name, + unit: meta.unit, + } } - 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 @@ -91,7 +99,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 @@ -99,7 +112,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 @@ -132,12 +146,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, @@ -205,9 +222,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) } @@ -235,7 +262,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() @@ -277,7 +306,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) { @@ -312,22 +344,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 @@ -345,13 +392,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) => { @@ -363,9 +414,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] @@ -382,7 +434,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) { @@ -408,7 +463,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 } @@ -431,15 +487,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')} @@ -642,42 +748,57 @@ 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} -
    +
    + 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 015ee19..1d34bc2 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' @@ -14,24 +31,56 @@ import ColorInput from '../ui/ColorInput.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, @@ -80,7 +129,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 @@ -120,9 +170,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', + ] function filterMetrics(list) { const valid = app.activityMetrics @@ -156,7 +230,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)) @@ -165,13 +241,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' }, @@ -179,8 +277,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' }, @@ -189,15 +298,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 = [ @@ -237,48 +361,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' }, ] /** @@ -293,17 +441,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 @@ -330,7 +483,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. @@ -349,15 +507,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] } @@ -383,7 +551,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) { @@ -403,17 +574,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) @@ -424,7 +634,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() { @@ -460,14 +672,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 }) }) @@ -482,9 +695,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 }) } @@ -510,7 +726,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] @@ -548,9 +768,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 = @@ -564,7 +791,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 } }) } @@ -574,7 +805,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 } }) } @@ -586,7 +821,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) { @@ -601,7 +838,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 @@ -633,17 +874,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 } @@ -709,21 +958,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), + }) } } @@ -738,7 +998,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) } @@ -753,12 +1015,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 @@ -796,16 +1053,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 }) @@ -814,20 +1082,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 }) @@ -866,33 +1146,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 ────────────────────────────────────────────────────────────── @@ -921,8 +1214,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() @@ -940,7 +1247,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 })) } @@ -1026,15 +1338,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}
    @@ -1523,226 +2142,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} @@ -1752,128 +2524,204 @@ 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}
    @@ -1882,125 +2730,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} @@ -2010,166 +2947,286 @@ 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} @@ -2180,28 +3237,52 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
    {#if showAdvanced} - - - + + + {/if} @@ -2209,12 +3290,18 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual

    Fill

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

    Border

    {#if item.border_color} -
    - - -
    +
    + + +
    {/if} {/if} @@ -2247,7 +3345,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} @@ -2326,69 +3466,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/Select.svelte b/app/src/components/ui/Select.svelte index 53e4652..c0b8aae 100644 --- a/app/src/components/ui/Select.svelte +++ b/app/src/components/ui/Select.svelte @@ -22,7 +22,10 @@ const g = optGroup(o) if (g && !names.includes(g)) names.push(g) } - return names.map((name) => ({ name, opts: options.filter((o) => optGroup(o) === name) })) + return names.map((name) => ({ + name, + opts: options.filter((o) => optGroup(o) === name), + })) }) diff --git a/app/src/components/ui/Switch.svelte b/app/src/components/ui/Switch.svelte index dd22ec8..a7e562e 100644 --- a/app/src/components/ui/Switch.svelte +++ b/app/src/components/ui/Switch.svelte @@ -16,7 +16,10 @@ aria-label={ariaLabel} {id} {disabled} - onclick={() => { checked = !checked; onchange?.(checked) }} + onclick={() => { + checked = !checked + onchange?.(checked) + }} class={cn( 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-150', 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', @@ -25,8 +28,10 @@ className, )} > - + diff --git a/app/src/components/ui/Tooltip.svelte b/app/src/components/ui/Tooltip.svelte index 53c87c2..9dd4f3a 100644 --- a/app/src/components/ui/Tooltip.svelte +++ b/app/src/components/ui/Tooltip.svelte @@ -14,11 +14,17 @@ function show() { if (timer) clearTimeout(timer) - if (delay > 0) timer = setTimeout(() => { visible = true }, delay) + if (delay > 0) + timer = setTimeout(() => { + visible = true + }, delay) else visible = true } function hide() { - if (timer) { clearTimeout(timer); timer = null } + if (timer) { + clearTimeout(timer) + timer = null + } visible = false } @@ -42,10 +48,12 @@ side === 'bottom' && 'top-full mt-1.5', side === 'left' && 'right-full top-1/2 -translate-y-1/2 mr-1.5', side === 'right' && 'left-full top-1/2 -translate-y-1/2 ml-1.5', - (side === 'top' || side === 'bottom') && align === 'center' && 'left-1/2 -translate-x-1/2', + (side === 'top' || side === 'bottom') && + align === 'center' && + 'left-1/2 -translate-x-1/2', (side === 'top' || side === 'bottom') && align === 'start' && 'left-0', (side === 'top' || side === 'bottom') && align === 'end' && 'right-0', - )} - >{content} + )}>{content} {/if}