From 2130ae5c3994a18ef793a5376d77c617e7808e9e Mon Sep 17 00:00:00 2001 From: Mihkel Martin Kasterpalu <qpeuitovxg@use.startmail.com> Date: Wed, 12 Feb 2025 01:58:43 +0200 Subject: [PATCH] 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 --- package.json | 1 + pnpm-lock.yaml | 13 +- src/lib/types.ts | 6 + src/routes/vinge/vaukuivali/+page.svelte | 324 ++++++--------------- src/routes/vinge/vaukuivali/checkpoints.ts | 178 +++++++++++ 5 files changed, 278 insertions(+), 244 deletions(-) create mode 100644 src/routes/vinge/vaukuivali/checkpoints.ts diff --git a/package.json b/package.json index 219502f..0157660 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "better-sqlite3": "^11.8.0", "drizzle-orm": "^0.38.4", "nanoid": "^5.0.9", + "runed": "^0.23.3", "spotify-web-api-node": "^5.0.2", "svelte-kit-sessions": "^0.4.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 179c8d0..0a8fe9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: nanoid: specifier: ^5.0.9 version: 5.0.9 + runed: + specifier: ^0.23.3 + version: 0.23.3(svelte@5.19.1) spotify-web-api-node: specifier: ^5.0.2 version: 5.0.2 @@ -2323,8 +2326,8 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - runed@0.23.2: - resolution: {integrity: sha512-AhHCb5/B+YQW6ar1pzhGQOQy+byfjCH63ofuhrexSWwQKhC0EbQ60Z/wMYwETLo3ZubhwlNryxBt0seOMOrVFQ==} + runed@0.23.3: + resolution: {integrity: sha512-qmL6JOvI9fg2XrSI9eP8bVIaAyk1ztVZsoj37hTs4BSuOOyeLkrIPI16mwarXFYbxSfyJGCwAWgfpSq+ehQmgg==} peerDependencies: svelte: ^5.7.0 @@ -3615,7 +3618,7 @@ snapshots: '@floating-ui/dom': 1.6.13 '@internationalized/date': 3.7.0 esm-env: 1.2.2 - runed: 0.23.2(svelte@5.19.1) + runed: 0.23.3(svelte@5.19.1) svelte: 5.19.1 svelte-toolbelt: 0.7.1(svelte@5.19.1) tabbable: 6.2.0 @@ -4592,7 +4595,7 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.23.2(svelte@5.19.1): + runed@0.23.3(svelte@5.19.1): dependencies: esm-env: 1.2.2 svelte: 5.19.1 @@ -4818,7 +4821,7 @@ snapshots: svelte-toolbelt@0.7.1(svelte@5.19.1): dependencies: clsx: 2.1.1 - runed: 0.23.2(svelte@5.19.1) + runed: 0.23.3(svelte@5.19.1) style-to-object: 1.0.8 svelte: 5.19.1 diff --git a/src/lib/types.ts b/src/lib/types.ts index a3b8db0..4fccaeb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -80,3 +80,9 @@ export type TagsObj = Record<string, Tag>; export type Answer = typeof answers.$inferSelect; export type Question = typeof questions.$inferSelect & { answers: Answer[] }; + +export interface SoundCheckpoint { + title: string; + description: string; + image: EnhancedImage | undefined; +} diff --git a/src/routes/vinge/vaukuivali/+page.svelte b/src/routes/vinge/vaukuivali/+page.svelte index e53c798..9baccbb 100644 --- a/src/routes/vinge/vaukuivali/+page.svelte +++ b/src/routes/vinge/vaukuivali/+page.svelte @@ -1,4 +1,6 @@ <script lang="ts"> + import type { SoundCheckpoint } from '$lib/types'; + import { onMount } from 'svelte'; import { Tween } from 'svelte/motion'; import { expoOut } from 'svelte/easing'; @@ -11,220 +13,11 @@ import ArrowUpToLine from 'lucide-svelte/icons/arrow-up-to-line'; import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down'; - import SevenSegmentDigit from './SevenSegmentDigit.svelte'; + import { watch } from 'runed'; import { getTimeRemaining } from '$lib/utils'; - import roomImg from '$lib/assets/vaukuivali/roomtone.jpg?enhanced'; - import watchImg from '$lib/assets/vaukuivali/oldwatch.jpg?enhanced'; - import convoImg from '$lib/assets/vaukuivali/conversation.jpg?enhanced'; - import gennImg from '$lib/assets/vaukuivali/genn.webp?enhanced'; - import tvImg from '$lib/assets/vaukuivali/tv.jpg?enhanced'; - import trafficImg from '$lib/assets/vaukuivali/kaubamaja.jpg?enhanced'; - import harleyImg from '$lib/assets/vaukuivali/harley.jpg?enhanced'; - import landingImg from '$lib/assets/vaukuivali/landing.jpg?enhanced'; - import carCrashImg from '$lib/assets/vaukuivali/carcrash.jpg?enhanced'; - import chainsawImg from '$lib/assets/vaukuivali/chainsaw.jpg?enhanced'; - import jetImg from '$lib/assets/vaukuivali/fighters.jpg?enhanced'; - import hearingaidImg from '$lib/assets/vaukuivali/eardamage.jpg?enhanced'; - import { ImageCreditType, type EnhancedImage } from '$lib/types'; - - interface SoundCheckpoint { - db: number; - title: string; - description: string; - crossedTime: undefined | Date; - image: EnhancedImage | undefined; - } - - const soundCheckpoints: SoundCheckpoint[] = $state([ - { - db: 0, - title: '', - description: 'Kesket metsa mingis koopas, kedagi pole ümber', - image: undefined, - crossedTime: undefined - }, - { - db: 30, - title: '"Vaikus"', - description: 'ehk elutoa pasiivne müra', - image: { - src: roomImg, - credit: { - type: ImageCreditType.web, - author: 'Kam Idris', - href: 'https://unsplash.com/@ka_idris' - }, - alt: 'Modernse ja minimalistliku disainiga siseruum.' - }, - crossedTime: undefined - }, - { - db: 40, - title: 'Tikk takk', - description: 'Mehaanilise kella tiksumine (va täistundidel)', - image: { - src: watchImg, - credit: { - type: ImageCreditType.web, - author: 'János Venczák', - href: 'https://unsplash.com/@venczakjanos' - }, - alt: 'Lahti võetud vanamoodne käekell. Näha on kella sisemust, hammasrattaid.' - }, - crossedTime: undefined - }, - { - db: 50, - title: 'Tava jutt', - description: 'Rahulik vestlus kodus', - image: { - src: convoImg, - credit: { - type: ImageCreditType.web, - author: 'Toa Heftiba', - href: 'https://unsplash.com/@heftiba' - }, - alt: 'Noor paar köögis. Mees lõikab taldriku peal pannkooki, naine istub ta kõrval pliidi peal ja vaatab.' - }, - crossedTime: undefined - }, - { - db: 60, - title: 'Ma sain nurgad täis', - description: 'Bingo õhtu Gennis (keset mängu)', - image: { - src: gennImg, - credit: { - type: ImageCreditType.web, - author: 'Laila Kaasik', - href: 'https://tartu.postimees.ee/8154041/lallavad-pidutsejad-panid-tartu-otsima-tasakaalu-ooelu-ja-oorahu-vahel' - }, - alt: 'Genialistide klubi tegutseb Tartus Magasini tänavas. Pilt on õhtusest ajast, rohkelt inimesti klubi välialal.' - }, - crossedTime: undefined - }, - { - db: 70, - title: 'Pult on kadunud', - description: 'Telekas, mis mängib natuke liiga valjult', - image: { - src: tvImg, - credit: { - type: ImageCreditType.web, - author: 'Jonas Leupe', - href: 'https://unsplash.com/@jonasleupe' - }, - alt: 'Keegi vaatab televiisorist filmi. Esiplaanil fookuses teleka pult, tagaplaanil udune tuba, mille seina vastas on telekas.' - }, - crossedTime: undefined - }, - { - db: 80, - title: 'USAs oleks hullem', - description: 'Riia mäe liiklus (ootad bussi Kaubamaja ees)', - image: { - src: trafficImg, - credit: { - type: ImageCreditType.web, - author: 'Google Street View', - href: 'https://maps.app.goo.gl/ZfADP4LnUid7d571A' - }, - alt: 'Aastal 2012 tehtud Google Street View pilt. Näha on Tartu Kaubamaja ning selle Riia tänava küljel olevat bussipeatust.' - }, - crossedTime: undefined - }, - { - db: 90, - title: 'USAs oleks rohkem', - description: 'Harley sõidab sinust mööda', - image: { - src: harleyImg, - credit: { - type: ImageCreditType.web, - author: 'Harley-Davidson', - href: 'https://unsplash.com/@harleydavidson' - }, - alt: 'Uue välimusega Harley-Davidson mootorrattas sõidab kiiresti mööda sirget maanteed.' - }, - crossedTime: undefined - }, - { - db: 100, - title: 'Põgenesid terminalist', - description: 'Boeing 707 1 meremiil enne maandumist', - image: { - src: landingImg, - credit: { - type: ImageCreditType.web, - author: 'Scott Fillmer', - href: 'https://unsplash.com/@scottfillmer' - }, - alt: 'Continental Airlines Boeing 777 maandub uduses Houston IAH lennujaamas.' - }, - crossedTime: undefined - }, - { - db: 110, - title: 'Maanteeraev', - description: 'Autosignaal 1m kauguselt', - image: { - src: carCrashImg, - credit: { - type: ImageCreditType.instagram, - author: 'Jordan Besson', - href: 'https://www.instagram.com/mr.blue.photographie' - }, - alt: 'Dramaatiline auto trikk filmi jaoks. Kahe auto kokkupõrge.' - }, - crossedTime: undefined - }, - { - db: 120, - title: 'Mootorsaag', - description: 'Nüüd on juba valus. Soovitan kanda kõrvatroppe.', - image: { - src: chainsawImg, - credit: { - type: ImageCreditType.web, - author: 'Benjamin Jopen', - href: 'https://unsplash.com/@benjopen' - }, - alt: 'Oranži ja musta värvi mootorsega lõigatakse langenud puud väiksemateks tükkideks.' - }, - crossedTime: undefined - }, - { - db: 130, - title: 'Kuidas sa nii lähedale said?', - description: 'Turboreaktiivmootoriga hävitaja lendutõus 15m kauguselt', - image: { - src: jetImg, - credit: { - type: ImageCreditType.web, - author: 'Colin Lloyd', - href: 'https://unsplash.com/@onthesearchforpineapples' - }, - alt: 'Kaheksa F-16 hävitajat lendavad koos formatsioonis taevas.' - }, - crossedTime: undefined - }, - { - db: 150, - title: 'Aia mu kõrvad', - description: 'Tubli töö! Su trummikile rebenes!', - image: { - src: hearingaidImg, - credit: { - type: ImageCreditType.web, - author: 'Mark Paton', - href: 'https://unsplash.com/@heftiba' - }, - alt: 'Lähivõte inimesest sisestamas oma kõrva kuuldeaparaati.' - }, - crossedTime: undefined - } - ]); + import SevenSegmentDigit from './SevenSegmentDigit.svelte'; + import { soundCheckpoints } from './checkpoints'; // Source: Claude 3.5 Sonnet function scrollToDecibels(scroll: number) { @@ -237,11 +30,11 @@ } // Source: Claude 3.5 Sonnet - function getCurrentCheckpoint(arr: SoundCheckpoint[], current: number) { + function getCurrentCheckpoint(arr: number[], current: number) { return ( arr.reduce( (prev: SoundCheckpoint | undefined, item) => - item.db <= current && (!prev || item.db > prev.db) ? item : prev, + item <= current && (!prev || item > prev) ? item : prev, undefined ) || arr.at(0) ); @@ -262,7 +55,9 @@ let innerHeight = $state(0); let innerWidth = $state(0); - let prevCheckpoint: SoundCheckpoint | undefined = $state(undefined); + + let startTime: Date | undefined = $state(); + let firstScroll: Date | undefined = $state(); let scrollScale = $derived(innerHeight * 0.1); let containerHeight = $derived(decibelsToScroll(150) + innerHeight + innerHeight); @@ -272,7 +67,10 @@ let currentDecibel = $derived(scrollToDecibels(scrollY.target)); let currentDecibelTweened = $derived(scrollToDecibels(scrollY.current)); - let currentCheckpoint = $derived(getCurrentCheckpoint(soundCheckpoints, currentDecibel)); + + 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)); @@ -293,19 +91,64 @@ } } - $effect(() => { - if ( - currentCheckpoint?.title && - currentCheckpoint != prevCheckpoint && - !currentCheckpoint?.crossedTime - ) { - currentCheckpoint.crossedTime = new Date(); + 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(() => { - soundCheckpoints[0].crossedTime = new Date(); - scrollY.target = 0; + scrollY.set(0, { duration: 0 }); + + startTime = new Date(); }); </script> @@ -321,11 +164,11 @@ </div> {/snippet} -{#snippet timeCard(point: SoundCheckpoint)} +{#snippet timeCard(db: number | undefined)} <div class="rounded-md border px-4 py-3 font-mono text-sm"> <p class="leading-7"> - <strong>{point.db}dBA</strong> - - <span>{getElapsedTime(soundCheckpoints.at(0)?.crossedTime, point.crossedTime)}</span> + <strong>{db}dBA</strong> - + <span>{getElapsedTime(firstScroll, db ? checkpointTimes[db] : firstScroll)}</span> </p> </div> {/snippet} @@ -367,7 +210,7 @@ <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 soundCheckpoints as { db }} + {#each checkpointDecibels as db} <div transition:fade style="top: calc({(db / 150) * 70}svh - 0.5rem)" @@ -386,18 +229,21 @@ <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={currentCheckpoint?.image} class="aspect-square object-cover " /> + <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?.db === 0} + {#if currentCheckpoint === 0} Vau kui vali! {:else} - {currentCheckpoint?.title} + {soundCheckpoints[currentCheckpoint]?.title} {/if} </h1> - {#if currentCheckpoint?.db === 0} + {#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. @@ -410,7 +256,7 @@ </p> {:else} <p class="max-w-prose text-2xl font-semibold leading-7 text-primary/80"> - {currentCheckpoint?.description} + {soundCheckpoints[currentCheckpoint]?.description} </p> {/if} </header> @@ -430,10 +276,10 @@ {/snippet} </Collapsible.Trigger> </div> - {@render timeCard(soundCheckpoints.at(-1) as SoundCheckpoint)} + {@render timeCard(checkpointDecibels.at(-1))} <Collapsible.Content class="space-y-2"> - {#each soundCheckpoints.slice(1, -1).reverse() as point} - {@render timeCard(point)} + {#each checkpointDecibels.slice(1, -1).reverse() as db} + {@render timeCard(db)} {/each} </Collapsible.Content> </Collapsible.Root> @@ -450,7 +296,7 @@ <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 + class="underline underline-offset-4">Purdue University PPE training materials</a > </li> </ul> diff --git a/src/routes/vinge/vaukuivali/checkpoints.ts b/src/routes/vinge/vaukuivali/checkpoints.ts new file mode 100644 index 0000000..b867836 --- /dev/null +++ b/src/routes/vinge/vaukuivali/checkpoints.ts @@ -0,0 +1,178 @@ +import { ImageCreditType, type SoundCheckpoint } from '$lib/types'; + +import roomImg from '$lib/assets/vaukuivali/roomtone.jpg?enhanced'; +import watchImg from '$lib/assets/vaukuivali/oldwatch.jpg?enhanced'; +import convoImg from '$lib/assets/vaukuivali/conversation.jpg?enhanced'; +import gennImg from '$lib/assets/vaukuivali/genn.webp?enhanced'; +import tvImg from '$lib/assets/vaukuivali/tv.jpg?enhanced'; +import trafficImg from '$lib/assets/vaukuivali/kaubamaja.jpg?enhanced'; +import harleyImg from '$lib/assets/vaukuivali/harley.jpg?enhanced'; +import landingImg from '$lib/assets/vaukuivali/landing.jpg?enhanced'; +import carCrashImg from '$lib/assets/vaukuivali/carcrash.jpg?enhanced'; +import chainsawImg from '$lib/assets/vaukuivali/chainsaw.jpg?enhanced'; +import jetImg from '$lib/assets/vaukuivali/fighters.jpg?enhanced'; +import hearingaidImg from '$lib/assets/vaukuivali/eardamage.jpg?enhanced'; + +export const soundCheckpoints: Record<number, SoundCheckpoint> = { + 0: { + title: '', + description: 'Kesket metsa mingis koopas, kedagi pole ümber', + image: undefined + }, + 30: { + title: '"Vaikus"', + description: 'ehk elutoa pasiivne müra', + image: { + src: roomImg, + credit: { + type: ImageCreditType.web, + author: 'Kam Idris', + href: 'https://unsplash.com/@ka_idris' + }, + alt: 'Modernse ja minimalistliku disainiga siseruum.' + } + }, + 40: { + title: 'Tikk takk', + description: 'Mehaanilise kella tiksumine (va täistundidel)', + image: { + src: watchImg, + credit: { + type: ImageCreditType.web, + author: 'János Venczák', + href: 'https://unsplash.com/@venczakjanos' + }, + alt: 'Lahti võetud vanamoodne käekell. Näha on kella sisemust, hammasrattaid.' + } + }, + 50: { + title: 'Tava jutt', + description: 'Rahulik vestlus kodus', + image: { + src: convoImg, + credit: { + type: ImageCreditType.web, + author: 'Toa Heftiba', + href: 'https://unsplash.com/@heftiba' + }, + alt: 'Noor paar köögis. Mees lõikab taldriku peal pannkooki, naine istub ta kõrval pliidi peal ja vaatab.' + } + }, + 60: { + title: 'Ma sain nurgad täis', + description: 'Bingo õhtu Gennis (keset mängu)', + image: { + src: gennImg, + credit: { + type: ImageCreditType.web, + author: 'Laila Kaasik', + href: 'https://tartu.postimees.ee/8154041/lallavad-pidutsejad-panid-tartu-otsima-tasakaalu-ooelu-ja-oorahu-vahel' + }, + alt: 'Genialistide klubi tegutseb Tartus Magasini tänavas. Pilt on õhtusest ajast, rohkelt inimesti klubi välialal.' + } + }, + 70: { + title: 'Pult on kadunud', + description: 'Telekas, mis mängib natuke liiga valjult', + image: { + src: tvImg, + credit: { + type: ImageCreditType.web, + author: 'Jonas Leupe', + href: 'https://unsplash.com/@jonasleupe' + }, + alt: 'Keegi vaatab televiisorist filmi. Esiplaanil fookuses teleka pult, tagaplaanil udune tuba, mille seina vastas on telekas.' + } + }, + 80: { + title: 'USAs oleks hullem', + description: 'Riia mäe liiklus (ootad bussi Kaubamaja ees)', + image: { + src: trafficImg, + credit: { + type: ImageCreditType.web, + author: 'Google Street View', + href: 'https://maps.app.goo.gl/ZfADP4LnUid7d571A' + }, + alt: 'Aastal 2012 tehtud Google Street View pilt. Näha on Tartu Kaubamaja ning selle Riia tänava küljel olevat bussipeatust.' + } + }, + 90: { + title: 'USAs oleks rohkem', + description: 'Harley sõidab sinust mööda', + image: { + src: harleyImg, + credit: { + type: ImageCreditType.web, + author: 'Harley-Davidson', + href: 'https://unsplash.com/@harleydavidson' + }, + alt: 'Uue välimusega Harley-Davidson mootorrattas sõidab kiiresti mööda sirget maanteed.' + } + }, + 100: { + title: 'Põgenesid terminalist', + description: 'Boeing 707 1 meremiil enne maandumist', + image: { + src: landingImg, + credit: { + type: ImageCreditType.web, + author: 'Scott Fillmer', + href: 'https://unsplash.com/@scottfillmer' + }, + alt: 'Continental Airlines Boeing 777 maandub uduses Houston IAH lennujaamas.' + } + }, + 110: { + title: 'Maanteeraev', + description: 'Autosignaal 1m kauguselt', + image: { + src: carCrashImg, + credit: { + type: ImageCreditType.instagram, + author: 'Jordan Besson', + href: 'https://www.instagram.com/mr.blue.photographie' + }, + alt: 'Dramaatiline auto trikk filmi jaoks. Kahe auto kokkupõrge.' + } + }, + 120: { + title: 'Mootorsaag', + description: 'Nüüd on juba valus. Soovitan kanda kõrvatroppe.', + image: { + src: chainsawImg, + credit: { + type: ImageCreditType.web, + author: 'Benjamin Jopen', + href: 'https://unsplash.com/@benjopen' + }, + alt: 'Oranži ja musta värvi mootorsega lõigatakse langenud puud väiksemateks tükkideks.' + } + }, + 130: { + title: 'Kuidas sa nii lähedale said?', + description: 'Turboreaktiivmootoriga hävitaja lendutõus 15m kauguselt', + image: { + src: jetImg, + credit: { + type: ImageCreditType.web, + author: 'Colin Lloyd', + href: 'https://unsplash.com/@onthesearchforpineapples' + }, + alt: 'Kaheksa F-16 hävitajat lendavad koos formatsioonis taevas.' + } + }, + 150: { + title: 'Aia mu kõrvad', + description: 'Tubli töö! Su trummikile rebenes!', + image: { + src: hearingaidImg, + credit: { + type: ImageCreditType.web, + author: 'Mark Paton', + href: 'https://unsplash.com/@heftiba' + }, + alt: 'Lähivõte inimesest sisestamas oma kõrva kuuldeaparaati.' + } + } +};