252 lines
9.3 KiB
Svelte
252 lines
9.3 KiB
Svelte
<script lang="ts">
|
||
import { browser } from '$app/environment';
|
||
import { goto } from '$app/navigation';
|
||
import { page } from '$app/state';
|
||
import { convert, convertYolo, canConvert } from '$lib/convert';
|
||
import { FRACTIONAL_DIGITS } from '$lib/config';
|
||
import { formatBig } from '$lib/format';
|
||
import Big from 'big.js';
|
||
import type { Unit, Group, ResultItem } from '$lib/types';
|
||
import ConverterCard from '../components/ConverterCard.svelte';
|
||
|
||
/**
|
||
* Converter page — loads units dynamically from server props.
|
||
* Supports same-group and cross-group (YOLO) conversions.
|
||
*/
|
||
|
||
let { data }: { data: { units: Unit[]; groups: Group[]; yoloLabel: string; yoloDescription: string; yoloVisibility: 'auto' | 'never' } } = $props();
|
||
|
||
let units: Unit[] = $derived(data.units);
|
||
let groups: Group[] = $derived(data.groups);
|
||
let yoloLabel: string = $derived(data.yoloLabel);
|
||
let yoloDescription: string = $derived(data.yoloDescription);
|
||
let yoloVisibility: 'auto' | 'never' = $derived(data.yoloVisibility);
|
||
let showYoloToggle: boolean = $derived(
|
||
yoloVisibility !== 'never' && groups.length > 1
|
||
);
|
||
|
||
// ── Reactive state ──────────────────────────────────────────────────────────
|
||
let inputValue = $state<number>(1);
|
||
let fromUnitId = $state<string>('');
|
||
let highlightedUnitId = $state<string | null>(null);
|
||
let yoloMode = $state<boolean>(false);
|
||
|
||
// Initialize fromUnitId to first unit once data arrives
|
||
$effect(() => {
|
||
if (fromUnitId === '' && units.length > 0) {
|
||
fromUnitId = units[0].id;
|
||
}
|
||
});
|
||
|
||
// ── Load YOLO preference from localStorage ──────────────────────────────────
|
||
// Single effect: read on mount, then write on every change.
|
||
// The `initialized` guard prevents the write from firing before the read on mount.
|
||
let yoloInitialized = $state(false);
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
if (!yoloInitialized) {
|
||
// First run: read stored value
|
||
const stored = localStorage.getItem('yolo-mode');
|
||
if (stored === 'true') yoloMode = true;
|
||
yoloInitialized = true;
|
||
return;
|
||
}
|
||
// Subsequent runs: persist change
|
||
localStorage.setItem('yolo-mode', String(yoloMode));
|
||
});
|
||
|
||
// ── Initialize from URL params on first client-side load ───────────────────
|
||
if (browser) {
|
||
const params = page.url.searchParams;
|
||
const pValue = params.get('value');
|
||
const pFrom = params.get('from');
|
||
const pHighlight = params.get('highlight');
|
||
|
||
if (pValue !== null) {
|
||
const n = parseFloat(pValue);
|
||
if (!isNaN(n)) inputValue = n;
|
||
}
|
||
if (pFrom !== null && data.units.some((u) => u.id === pFrom)) {
|
||
fromUnitId = pFrom;
|
||
}
|
||
if (pHighlight !== null && data.units.some((u) => u.id === pHighlight)) {
|
||
highlightedUnitId = pHighlight;
|
||
}
|
||
}
|
||
|
||
// ── Derived: results for ALL units ─────────────────────────────────────────
|
||
let fromUnit = $derived(units.find((u) => u.id === fromUnitId) ?? units[0]);
|
||
|
||
let results: ResultItem[] = $derived.by(() => {
|
||
if (!fromUnit) return [];
|
||
const bigInput = Big(inputValue); // hoist — one allocation per recompute, not per unit
|
||
return units.map((u) => {
|
||
const sameGroup = canConvert(fromUnit, u);
|
||
if (sameGroup) {
|
||
return { unit: u, convertedValue: convert(bigInput, fromUnit, u), isYolo: false };
|
||
} else if (yoloMode) {
|
||
return { unit: u, convertedValue: convertYolo(bigInput, fromUnit, u, groups), isYolo: true };
|
||
} else {
|
||
return { unit: u, convertedValue: null, isYolo: false };
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── Derived: highlighted result ─────────────────────────────────────────────
|
||
let highlightedResult = $derived(
|
||
highlightedUnitId !== null
|
||
? (results.find((r) => r.unit.id === highlightedUnitId) ?? null)
|
||
: null
|
||
);
|
||
|
||
// ── URL sync ────────────────────────────────────────────────────────────────
|
||
// Use history.replaceState directly — goto() triggers a SvelteKit navigation
|
||
// which re-renders the page and steals focus from the input on every keystroke.
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
const params = new URLSearchParams();
|
||
params.set('value', String(inputValue));
|
||
params.set('from', fromUnitId);
|
||
if (highlightedUnitId !== null) {
|
||
params.set('highlight', highlightedUnitId);
|
||
}
|
||
const next = params.toString();
|
||
if (next !== window.location.search.slice(1)) {
|
||
history.replaceState(history.state, '', `?${next}`);
|
||
}
|
||
});
|
||
|
||
// ── Event handlers ──────────────────────────────────────────────────────────
|
||
function handleUnitChange(id: string) {
|
||
fromUnitId = id;
|
||
if (highlightedUnitId === id) {
|
||
highlightedUnitId = null;
|
||
}
|
||
}
|
||
|
||
function handleHighlight(id: string) {
|
||
highlightedUnitId = id === highlightedUnitId ? null : id;
|
||
}
|
||
|
||
function handleSetInput(id: string) {
|
||
fromUnitId = id;
|
||
}
|
||
|
||
function handleCtrlClick(id: string, convertedValue: Big) {
|
||
inputValue = parseFloat(convertedValue.toFixed(FRACTIONAL_DIGITS));
|
||
fromUnitId = id;
|
||
}
|
||
|
||
// ── Derived: results per group for display — single O(n) pass ──────────────
|
||
let orderedResults: { groupLabel: string | null; alwaysShowLabel: boolean; items: ResultItem[] }[] = $derived.by(() => {
|
||
// Build a Map of groupId → items in a single pass over results
|
||
const byGroup = new Map<string | null, ResultItem[]>();
|
||
for (const r of results) {
|
||
const key = r.unit.group ?? null;
|
||
let bucket = byGroup.get(key);
|
||
if (!bucket) { bucket = []; byGroup.set(key, bucket); }
|
||
bucket.push(r);
|
||
}
|
||
// Emit sections in group-definition order, then orphaned at end
|
||
const sections: { groupLabel: string | null; alwaysShowLabel: boolean; items: ResultItem[] }[] = [];
|
||
for (const group of groups) {
|
||
const items = byGroup.get(group.id);
|
||
if (items?.length) {
|
||
const sorted = group.sortOrder === 'alpha'
|
||
? [...items].sort((a, b) => {
|
||
const labelCmp = a.unit.labelPlural.localeCompare(b.unit.labelPlural);
|
||
return labelCmp !== 0 ? labelCmp : a.unit.symbol.localeCompare(b.unit.symbol);
|
||
})
|
||
: items;
|
||
sections.push({ groupLabel: group.label, alwaysShowLabel: group.alwaysShowLabel ?? false, items: sorted });
|
||
}
|
||
}
|
||
const orphaned = byGroup.get(null);
|
||
if (orphaned?.length) sections.push({ groupLabel: 'Ungrouped', alwaysShowLabel: false, items: orphaned });
|
||
return sections;
|
||
});
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>Humor Units — Converter</title>
|
||
</svelte:head>
|
||
|
||
<div class="highlighted-result" class:has-highlight={highlightedResult !== null} aria-live="polite">
|
||
{#if highlightedResult !== null && highlightedResult.convertedValue !== null}
|
||
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
|
||
<div class="highlighted-equation">
|
||
<span class="highlighted-eq-from">{formatBig(new Big(inputValue))} {fromUnit?.labelPlural}</span>
|
||
<span class="highlighted-eq-sep">=</span>
|
||
<span class="highlighted-eq-to">
|
||
{#if highlightedResult.isYolo}<span class="yolo-marker">~</span>{/if}{formatBig(highlightedResult.convertedValue)} {highlightedResult.unit.labelPlural}
|
||
</span>
|
||
</div>
|
||
{:else if highlightedResult !== null && highlightedResult.convertedValue === null}
|
||
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
|
||
<span class="highlighted-placeholder">N/A — enable YOLO mode for cross-group conversions</span>
|
||
{:else}
|
||
<span class="highlighted-placeholder">Click a result to highlight it</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="yolo-toggle-row" class:yolo-hidden={!showYoloToggle}>
|
||
{#if showYoloToggle}
|
||
<label class="yolo-label">
|
||
<input type="checkbox" bind:checked={yoloMode} role="switch" />
|
||
<span class="yolo-label-text">{yoloLabel}</span>
|
||
{#if yoloDescription}
|
||
<small class="yolo-desc">{yoloDescription}</small>
|
||
{/if}
|
||
</label>
|
||
{/if}
|
||
</div>
|
||
|
||
<ConverterCard
|
||
{units}
|
||
{groups}
|
||
{inputValue}
|
||
{fromUnitId}
|
||
{highlightedUnitId}
|
||
{results}
|
||
{orderedResults}
|
||
oninputchange={(v) => (inputValue = v)}
|
||
onunitchange={handleUnitChange}
|
||
onhighlight={handleHighlight}
|
||
onsetinput={handleSetInput}
|
||
onctrlclick={handleCtrlClick}
|
||
/>
|
||
|
||
<style>
|
||
.yolo-toggle-row {
|
||
margin-bottom: 1rem;
|
||
min-height: 0;
|
||
}
|
||
.yolo-toggle-row.yolo-hidden {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.yolo-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.yolo-label-text {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.yolo-desc {
|
||
color: var(--pico-muted-color);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.yolo-marker {
|
||
color: var(--pico-muted-color);
|
||
font-weight: bold;
|
||
margin-right: 1px;
|
||
}
|
||
</style>
|