portfolio_site/src/routes/vinge/vaukuivali/+page.svelte
Mihkel Martin Kasterpalu 2130ae5c39 Rewrite some of vaukuivali to optimize split timer
Use watch from runed for more finegrained updates
Start time only after the first scroll
Add fallbacks/estimations if we still didn't collect the time
Track the current checkpoint using only the decibel, as other info is
now in a Record accessible via the decibel as the key
2025-02-12 01:58:43 +02:00

315 lines
9.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import type { SoundCheckpoint } from '$lib/types';
import { onMount } from 'svelte';
import { Tween } from 'svelte/motion';
import { expoOut } from 'svelte/easing';
import { fade } from 'svelte/transition';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import Button from '$lib/components/ui/button/button.svelte';
import Image from '$lib/components/Image.svelte';
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
import ArrowUpToLine from 'lucide-svelte/icons/arrow-up-to-line';
import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
import { watch } from 'runed';
import { getTimeRemaining } from '$lib/utils';
import SevenSegmentDigit from './SevenSegmentDigit.svelte';
import { soundCheckpoints } from './checkpoints';
// Source: Claude 3.5 Sonnet
function scrollToDecibels(scroll: number) {
return Math.min(150, Math.log10(scroll / scrollScale + 1) * 40);
}
// Source: Claude 3.5 Sonnet
function decibelsToScroll(db: number) {
return (Math.pow(10, db / 40) - 1) * scrollScale;
}
// Source: Claude 3.5 Sonnet
function getCurrentCheckpoint(arr: number[], current: number) {
return (
arr.reduce(
(prev: SoundCheckpoint | undefined, item) =>
item <= current && (!prev || item > prev) ? item : prev,
undefined
) || arr.at(0)
);
}
function getElapsedTime(start: Date | undefined, end: Date | undefined) {
if (!start || !end) {
return '00:00.000';
}
const remaining = getTimeRemaining(start, end);
const remMinutes = String(remaining.minutes).padStart(2, '0');
const remSeconds = String(remaining.seconds).padStart(2, '0');
const remMilliseconds = String(remaining.milliseconds).padStart(3, '0');
return `${remMinutes}:${remSeconds}.${remMilliseconds}`;
}
let innerHeight = $state(0);
let innerWidth = $state(0);
let startTime: Date | undefined = $state();
let firstScroll: Date | undefined = $state();
let scrollScale = $derived(innerHeight * 0.1);
let containerHeight = $derived(decibelsToScroll(150) + innerHeight + innerHeight);
let displayDecimals = $derived(innerWidth > 1000);
let scrollY = new Tween(0, { duration: 150, easing: expoOut });
let currentDecibel = $derived(scrollToDecibels(scrollY.target));
let currentDecibelTweened = $derived(scrollToDecibels(scrollY.current));
let checkpointDecibels = $derived(Object.keys(soundCheckpoints).map((value) => Number(value)));
let currentCheckpoint = $derived(getCurrentCheckpoint(checkpointDecibels, currentDecibel));
let checkpointTimes: Record<number, Date> = $state({});
let decibelMeter = $derived.by(() => {
const clampedValue = Math.min(999.99, Math.max(0, currentDecibel));
const integerPart = Math.floor(clampedValue);
if (!displayDecimals) {
return String(integerPart).padStart(3, '0').split('').map(Number);
}
const decimalPart = Math.floor((clampedValue - integerPart) * 10);
return String(integerPart).padStart(3, '0').concat(String(decimalPart)).split('').map(Number);
});
function disableHomeAndEnd(e: KeyboardEvent & { currentTarget: EventTarget & Window }) {
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
}
}
watch.pre(
() => currentCheckpoint,
(curr, prev) => {
if (curr === prev) return;
if (checkpointTimes[curr]) return;
checkpointTimes[curr] = new Date();
// We reached the end
// Fill out any checkpoint times we missed with crude predictions
if (curr === checkpointDecibels.at(-1) && checkpointDecibels.length > 1) {
for (let i = 0; i < checkpointDecibels.length; i++) {
const db = checkpointDecibels[i];
const capturedTime = checkpointTimes[db];
if (!capturedTime) {
// If the next closest previous time is page load
// use first scroll for a more accurate prediction (if possible)
const prevDb = checkpointDecibels[Math.max(i - 1, 0)];
const prevTime = prevDb === 0 && firstScroll ? firstScroll : checkpointTimes[prevDb];
if (!prevTime) {
checkpointTimes[db] = firstScroll ? firstScroll : checkpointTimes[0];
continue;
}
const nextDb = checkpointDecibels[Math.min(i + 1, checkpointDecibels.length - 1)];
const nextTime = checkpointTimes[nextDb];
if (!nextTime) {
checkpointTimes[db] = prevTime;
continue;
}
checkpointTimes[db] = new Date((prevTime.getTime() + nextTime.getTime()) / 2);
}
}
}
}
);
// Get the time of first scroll
watch.pre(
() => scrollY.current,
() => {
if (!startTime) return;
if (firstScroll) return;
firstScroll = new Date();
checkpointTimes[checkpointDecibels[0]] = firstScroll;
}
);
onMount(() => {
scrollY.set(0, { duration: 0 });
startTime = new Date();
});
</script>
{#snippet checkpoint(db: number, passed: boolean)}
<div
class="rounded-r-lg transition-colors {passed
? 'bg-primary text-primary-foreground'
: 'bg-muted-foreground text-muted'} py-1 text-center {displayDecimals
? 'w-16 text-sm'
: 'w-14 text-xs'}"
>
{db}dBA
</div>
{/snippet}
{#snippet timeCard(db: number | undefined)}
<div class="rounded-md border px-4 py-3 font-mono text-sm">
<p class="leading-7">
<strong>{db}dBA</strong> -
<span>{getElapsedTime(firstScroll, db ? checkpointTimes[db] : firstScroll)}</span>
</p>
</div>
{/snippet}
<svelte:window
onkeydowncapture={disableHomeAndEnd}
bind:scrollY={scrollY.target}
bind:innerWidth
bind:innerHeight
/>
{#if innerWidth === 0 || innerHeight === 0}
<div
class="grid h-screen w-full items-center justify-center"
out:fade={{ duration: 150, easing: expoOut }}
>
<LoaderCircle class="animate-spin" />
</div>
{:else}
<div
style="height: {containerHeight}px;"
class="relative mb-24 w-full"
in:fade={{ duration: 300, easing: expoOut }}
>
<div class="sticky left-4 top-24 flex items-start rounded-xl">
<div class="flex flex-col items-start">
<div
class="ring-3 flex gap-2 rounded-md bg-stone-800 px-3 py-1 shadow shadow-black/15 ring-4 ring-inset ring-stone-900 {!displayDecimals
? '-ml-1.5 mb-4 scale-90'
: 'mb-6'}"
>
{#each decibelMeter as digit, i}
<div class="py-2 shadow-sm">
<SevenSegmentDigit {digit} decimal={displayDecimals && i == 2} />
</div>
{/each}
</div>
<div class="flex flex-col items-center gap-4">
<div
class="relative h-[70svh] w-4 bg-gradient-to-b from-lime-300 via-yellow-400 to-red-500 dark:from-lime-500 dark:via-yellow-400 dark:to-red-500"
>
{#each checkpointDecibels as db}
<div
transition:fade
style="top: calc({(db / 150) * 70}svh - 0.5rem)"
class="absolute flex items-center"
>
{@render checkpoint(db, currentDecibel >= db)}
</div>
{/each}
<div
class="absolute bottom-0 w-full backdrop-grayscale"
style="height: {100 - (currentDecibelTweened / 150) * 100}%"
></div>
</div>
</div>
</div>
<div
class="mx-auto flex w-full max-w-2xl flex-col-reverse items-center gap-8 self-center px-12 md:grid md:gap-0 md:*:[grid-area:1/1/2/2]"
>
<Image
image={soundCheckpoints[currentCheckpoint]?.image}
class="aspect-square object-cover "
/>
<header
class="flex flex-col items-center py-4 text-center font-title backdrop-blur-sm backdrop-grayscale md:bg-background/75 dark:md:bg-background/90"
>
<h1 class="mb-1 scroll-m-20 text-5xl font-extrabold tracking-tight lg:text-6xl">
{#if currentCheckpoint === 0}
Vau kui vali!
{:else}
{soundCheckpoints[currentCheckpoint]?.title}
{/if}
</h1>
{#if currentCheckpoint === 0}
<p class="max-w-prose text-2xl font-semibold leading-7 text-muted-foreground">
Nagu paljud võivad teada, on detsibellide skaala logaritmiline.<br /> 60dB on 2x valjem,
kui 50dB.
</p>
<p
class="max-w-prose text-2xl font-semibold leading-7 text-muted-foreground [&:not(:first-child)]:mt-6"
>
See info ei jõudnud mulle eriti kohale. <br />Intuitiivsemaks arusaamiseks tegin selle
lehe. <br /><strong>Proovi, keri alla.</strong>
</p>
{:else}
<p class="max-w-prose text-2xl font-semibold leading-7 text-primary/80">
{soundCheckpoints[currentCheckpoint]?.description}
</p>
{/if}
</header>
</div>
</div>
</div>
<Collapsible.Root class="w-full max-w-xs space-y-2">
<div class="flex items-center justify-between px-4">
<h4 class="text-sm font-semibold">Sul läks ikka kaua...</h4>
<Collapsible.Trigger>
{#snippet child({ props })}
<Button variant="ghost" size="sm" class="w-9 p-0" {...props}>
<ChevronsUpDown />
<span class="sr-only">Toggle</span>
</Button>
{/snippet}
</Collapsible.Trigger>
</div>
{@render timeCard(checkpointDecibels.at(-1))}
<Collapsible.Content class="space-y-2">
{#each checkpointDecibels.slice(1, -1).reverse() as db}
{@render timeCard(db)}
{/each}
</Collapsible.Content>
</Collapsible.Root>
<div class="mt-24 flex w-full justify-between">
<div>
<small class="text-sm font-medium leading-none"> Helitugevuste allikad: </small>
<ul class="my-4 ml-6 list-disc [&>li]:mt-2">
<li class="max-w-prose text-sm leading-4 text-muted-foreground">
Fastl, H., & Florentine, M. (2010). Loudness in Daily Environments. Springer Handbook of
Auditory Research, 199221. doi:10.1007/978-1-4419-6712-1_8
</li>
<li class="max-w-prose text-sm leading-4 text-muted-foreground">
<a
href="https://www.chem.purdue.edu/chemsafety/Training/PPETrain/dblevels.htm"
target="_blank"
class="underline underline-offset-4">Purdue University PPE training materials</a
>
</li>
</ul>
</div>
<Button
onclick={() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
size="default"
variant="secondary"
class="h-10 w-10"
>
<ArrowUpToLine class="!h-6 !w-6" />
</Button>
</div>
{/if}