fdaudens's picture
Add OSINT and scrolly-sveltekit skills (#1)
2327390
<script lang="ts">
/**
* NoticeMosaic - Scroll-triggered mosaic of eviction notices
*
* Displays document images in a scattered "evidence board" layout,
* progressively revealing them as activeStep increases. Includes an
* animated text placard showing details for each notice.
*/
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
export interface Notice {
image: string;
alt: string;
title: string;
subtitle: string;
excerpt: string;
}
interface Props {
activeStep: number;
notices: Notice[];
backgroundColor?: string;
}
let { activeStep, notices, backgroundColor = '#1a1715' }: Props = $props();
let viewportHeight = $state(0);
let isMobile = $state(false);
// Current notice index based on activeStep
let currentNoticeIndex = $derived(Math.min(activeStep, notices.length - 1));
// Pre-computed positions for each notice (deterministic based on index)
function getPosition(index: number): [string, string, number] {
const positions: [number, number, number][] = [
[20, 15, -3],
[50, 25, 2.5],
[15, 45, -1.5],
[55, 10, 3]
];
const pos = positions[index % positions.length];
// Add slight randomization for organic feel (seeded by index)
const seed = index * 7919;
const jitterX = ((seed % 10) - 5) * 0.5;
const jitterY = (((seed * 3) % 10) - 5) * 0.5;
const jitterRot = (((seed * 7) % 10) - 5) * 0.2;
return [
`${pos[0] + jitterX}%`,
`${pos[1] + jitterY}%`,
pos[2] + jitterRot
];
}
onMount(() => {
viewportHeight = window.innerHeight;
isMobile = window.innerWidth < 640;
const handleResize = () => {
viewportHeight = window.innerHeight;
isMobile = window.innerWidth < 640;
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
</script>
<div class="notice-mosaic" style:background={backgroundColor}>
<!-- Notice images -->
{#each notices as notice, i}
{@const pos = getPosition(i)}
{#if i <= currentNoticeIndex}
<div
class="notice-image"
class:mobile={isMobile}
style:left={pos[0]}
style:top={pos[1]}
style:transform="rotate({pos[2]}deg)"
in:fade={{ duration: 350 }}
>
<img src={notice.image} alt={notice.alt} loading="lazy" />
</div>
{/if}
{/each}
<!-- Text placard -->
<div class="notice-placard" class:mobile={isMobile}>
{#if notices[currentNoticeIndex]}
{#key currentNoticeIndex}
<div
class="placard-content"
in:fade={{ duration: 250, delay: 100 }}
out:fade={{ duration: 150 }}
>
<p class="placard-title">{notices[currentNoticeIndex].title}</p>
<p class="placard-subtitle">{notices[currentNoticeIndex].subtitle}</p>
<p class="placard-excerpt">{notices[currentNoticeIndex].excerpt}</p>
</div>
{/key}
{:else}
<div class="placard-content placard-empty">
<p class="placard-subtitle">Scroll to reveal eviction notices</p>
</div>
{/if}
</div>
</div>
<style>
.notice-mosaic {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
/* Notice image cards - sized like photos on a desk */
.notice-image {
position: absolute;
max-width: 25vw;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.1),
0 4px 8px rgba(0, 0, 0, 0.1),
0 8px 16px rgba(0, 0, 0, 0.15),
0 16px 32px rgba(0, 0, 0, 0.2);
border-radius: 2px;
background: #f5f2eb;
padding: 3px;
}
.notice-image.mobile {
max-width: 50vw;
}
.notice-image img {
display: block;
width: 100%;
height: auto;
border-radius: 1px;
}
/* Text placard - museum card style with fixed height to prevent jumping */
.notice-placard {
position: absolute;
bottom: 6%;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 480px;
height: 150px;
background: linear-gradient(135deg, #2a2622 0%, #1f1c19 100%);
border-left: 3px solid #c45c4a;
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
border-radius: 2px;
overflow: hidden;
}
.notice-placard.mobile {
bottom: 4%;
width: 94%;
height: 140px;
}
/* Content is absolutely positioned so fade doesn't affect container size */
.placard-content {
position: absolute;
inset: 0;
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.notice-placard.mobile .placard-content {
padding: 1rem 1.25rem;
}
.placard-empty {
justify-content: center;
}
.placard-title {
font-family: 'Source Sans 3', system-ui, sans-serif;
font-size: 1.25rem;
font-weight: 700;
color: #ffffff;
letter-spacing: 0.01em;
margin: 0;
}
.placard-subtitle {
font-family: 'Source Sans 3', system-ui, sans-serif;
font-size: 0.9375rem;
font-weight: 500;
color: #d4d0c8;
margin: 0;
}
.placard-excerpt {
font-family: 'Courier Prime', 'Courier New', monospace;
font-size: 1rem;
font-weight: 500;
font-style: italic;
color: #e8715f;
margin: 0.5rem 0 0 0;
line-height: 1.5;
}
/* Mobile adjustments */
@media (max-width: 640px) {
.placard-title {
font-size: 1rem;
}
.placard-subtitle {
font-size: 0.8125rem;
}
.placard-excerpt {
font-size: 0.875rem;
}
}
</style>