Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,48 @@ Rules:
`saezuri-illustrations` repo (a flat folder the app auto-downloads per detected
species); the AvianVisitors cutouts under `public/assets/` remain gitignored dev
placeholders so the UI is testable locally. Growing that set is ongoing, not a v1 gate.
## Common commands
## Code map

Where things live, so a change lands in the right place fast.

To be filled in once the scaffold lands. Expected shape:
- **Collage render path:** `src/pages/CollagePage.tsx` fetches the data and renders
`src/collage/Collage.tsx`, which measures the viewport, resolves each species to art,
packs the tiles, and maps them to `src/collage/BirdTile.tsx` (one absolutely-positioned
`<button><img></button>` per bird). Silhouette hover is arbitrated at the container
(`hitTest.ts`), not per tile — the tiles are `pointer-events: none`.
- **Layout / packer:** `src/collage/layout.ts` — `computeLayout(inputs, vp)` is the
deterministic, seeded (`src/lib/prng.ts`) count-driven sizing + silhouette packer
(`pack.ts`); reimplemented from AvianVisitors, not copied. Same inputs + viewport ⇒ same
layout, so polls and resizes don't churn. `layoutSignature(tiles)` fingerprints the
arrangement (`sci`/`n`/`key`, viewport-independent) via `src/lib/hash.ts` (`fnv1a`).
- **Entrance bloom:** the `gtile-in` keyframes in `src/index.css` (`.gtile.entering`),
disabled under `prefers-reduced-motion`. It is a **one-shot CSS mount animation** — it
replays only when React remounts a tile, i.e. when the tile `key`
`` `${blossomKey}:${sig}:${sci}` `` changes: `blossomKey` is the window preset and `sig`
is `layoutSignature`. So the bloom fires on load, window switch, and any in-place update
(poll / focus revalidation) that yields a genuinely different layout — not on identical
polls or plain resizes. There is no positional transition on `.gtile`; tiles that don't
remount just snap.
- **Data hooks (browser reads static files only):** `src/hooks/useRecentSpecies.ts`
(`/snapshot.json`, 12s poll) selects the active window; `src/hooks/useLayoutManifest.ts`
(`/layout-manifest.json`, 30s poll) supplies per-species masks/dims/versions. Both use
SWR's default `revalidateOnFocus`. The dictionary hooks (`useDictionaryIndex.ts`,
`useSpeciesDictionary.ts`) deliberately set `revalidateOnFocus: false`. There is no
`SWRConfig` provider. User preferences (`useThemePreference.ts`, `useLanguagePreference.ts`)
are per-client `localStorage` under `saezuri:*` keys.
- **Domain (framework-free, shared with the server):** `src/domain/` — `species.ts`
(aggregation + localization), `asset.ts` (`resolveArt`, `imagePath` with the `?v=` hash
cache-bust), `snapshot.ts`, `manifest.ts`, `slug.ts`.
- **Refresh service (the sole BirdNET-Go client):** `src/server/` — holds the SSE stream,
gates/aggregates species, and publishes `/snapshot.json`, `/layout-manifest.json`, and
the e-ink PNG frames (`render.ts`, reusing `computeLayout`). Run it with `npm run refresh:dev`.

## Common commands

- `npm run dev` for the Vite dev server.
- `npm run build` for the production bundle.
- `npm run dev` (Vite dev server) / `npm run dev:mock` (`VITE_MOCK=1`, synthesizes species
from the local manifest so the collage runs with no backend).
- `npm test` (vitest), `npm run typecheck` (`tsc --noEmit`), `npm run check` (Biome).
- `npm run build` for the production bundle; `npm run refresh:dev` runs the refresh service.
- `docker compose up --build` to run the container against a configured `BIRDNETGO_URL`.
## Git hygiene

Expand Down
18 changes: 13 additions & 5 deletions src/collage/Collage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ import type { Species } from '../domain/species.ts'
import { BirdTile } from './BirdTile.tsx'
import { HoverChip } from './HoverChip.tsx'
import { hitTest } from './hitTest.ts'
import { computeLayout, type LayoutInput, type Viewport } from './layout.ts'
import { computeLayout, type LayoutInput, layoutSignature, type Viewport } from './layout.ts'
import { decodeMaskCached } from './pack.ts'

interface Props {
species: Species[]
manifest: LayoutManifest
/** Bloom tiles in on mount (disable for screenshots). */
animate?: boolean
/** Namespaces the tile keys so a change remounts every tile — used to replay
* the entrance bloom when the whole set turns over (e.g. switching windows),
* while a same-key poll still re-blooms only newly-arrived birds. */
/** Namespaces the tile keys so a change remounts every tile and replays the
* entrance bloom. Combined here with a signature of the current layout, so the
* bloom also replays when an in-place update (poll / focus revalidation) yields
* a genuinely different arrangement — not only when the window switches. Pass
* the window preset; it keeps windows in separate key namespaces. */
blossomKey?: string
/** Rendered when there are no birds in the window. */
emptyState?: ReactNode
Expand Down Expand Up @@ -101,6 +103,12 @@ export function Collage({ species, manifest, animate = true, blossomKey = '', em
return computeLayout(inputs, vp)
}, [species, manifest, vp])

// Fingerprint the arrangement (species / counts / art slots, not pixel coords)
// so the tile keys below change — and the bloom replays — exactly when a poll or
// focus revalidation lands a genuinely different layout, and never on a plain
// resize or an identical poll. Memoized so hover re-renders don't recompute it.
const sig = useMemo(() => layoutSignature(tiles), [tiles])

const fallbackUrl = imagePath(manifest.fallbackKey, manifest.ver?.[manifest.fallbackKey])
const cx = vp.width / 2
const cy = vp.height / 2
Expand Down Expand Up @@ -137,7 +145,7 @@ export function Collage({ species, manifest, animate = true, blossomKey = '', em
const dist = Math.hypot(t.x + t.w / 2 - cx, t.y + t.h / 2 - cy)
return (
<BirdTile
key={`${blossomKey}:${t.sci}`}
key={`${blossomKey}:${sig}:${t.sci}`}
tile={t}
animate={animate}
delayMs={Math.min(MAX_BLOOM_DELAY_MS, dist * BLOOM_DELAY_PER_PX)}
Expand Down
43 changes: 42 additions & 1 deletion src/collage/layout.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import { computeLayout, type LayoutInput, tuning } from './layout.ts'
import {
computeLayout,
type LaidTile,
type LayoutInput,
layoutSignature,
tuning,
} from './layout.ts'
import type { DecodedMask } from './pack.ts'

function solidMask(w: number, h: number): DecodedMask {
Expand Down Expand Up @@ -69,3 +75,38 @@ describe('computeLayout', () => {
expect(first).toEqual(again)
})
})

function tile(sci: string, n: number, overrides: Partial<LaidTile> = {}): LaidTile {
return { ...input(sci, n), x: 0, y: 0, w: 10, h: 10, parked: false, ...overrides }
}

describe('layoutSignature', () => {
const base = [tile('a', 3), tile('b', 1), tile('c', 5)]

it('is stable for the same species, counts, and art keys', () => {
expect(layoutSignature(base)).toBe(layoutSignature([tile('a', 3), tile('b', 1), tile('c', 5)]))
})

it('ignores tile order', () => {
expect(layoutSignature(base)).toBe(layoutSignature([tile('c', 5), tile('a', 3), tile('b', 1)]))
})

it('ignores pixel coordinates, so it holds across a resize', () => {
const moved = base.map((t) => ({ ...t, x: t.x + 100, y: t.y + 40, w: t.w * 2, h: t.h * 2 }))
expect(layoutSignature(moved)).toBe(layoutSignature(base))
})

it('changes when a detection count changes', () => {
expect(layoutSignature([tile('a', 3)])).not.toBe(layoutSignature([tile('a', 4)]))
})

it('changes when a species enters or leaves', () => {
expect(layoutSignature(base)).not.toBe(layoutSignature([tile('a', 3), tile('b', 1)]))
})

it('changes when the resolved art key changes (fallback→art, perched→flight)', () => {
expect(layoutSignature([tile('a', 3, { key: 'a' })])).not.toBe(
layoutSignature([tile('a', 3, { key: 'a-2' })]),
)
})
})
14 changes: 14 additions & 0 deletions src/collage/layout.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fnv1a } from '../lib/hash.ts'
import { type DecodedMask, isParked, maskPack, type PlaceableTile } from './pack.ts'

// Count-driven sizing + packing, reimplemented from study of AvianVisitors'
Expand Down Expand Up @@ -173,3 +174,16 @@ export function computeLayout(inputs: readonly LayoutInput[], vp: Viewport): Lai
parked: isParked(t),
}))
}

/** A short fingerprint of what makes a layout the layout it is: which species are
* present (`sci`), how loud each is (`n`, which drives tile size), and which art
* slot each resolved to (`key`, which sets the mask + aspect and flips on
* fallback→real-art or perched→flight). Sorted so tile order never matters, and
* deliberately blind to pixel coordinates — so it is stable across viewport
* resizes and identical polls, and changes only when the arrangement genuinely
* differs. The collage feeds it into the tile keys so a changed layout remounts
* the tiles and replays the entrance bloom (see Collage.tsx). */
export function layoutSignature(tiles: readonly LaidTile[]): string {
const parts = tiles.map((t) => `${t.sci}:${t.n}:${t.key}`).sort()
return fnv1a(parts.join('|')).toString(36)
}
15 changes: 15 additions & 0 deletions src/lib/hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// FNV-1a, the 32-bit variant. A fast, dependency-free string hash used to fold a
// long fingerprint down to a short, stable token — the e-ink frame signature and
// the collage's re-bloom key both fold their per-species strings through this so
// two identical inputs always collapse to the same value.
const FNV_OFFSET = 0x811c9dc5
const FNV_PRIME = 0x01000193

export function fnv1a(str: string): number {
let h = FNV_OFFSET
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i)
h = Math.imul(h, FNV_PRIME)
}
return h >>> 0
}
13 changes: 1 addition & 12 deletions src/server/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { decodeMaskCached } from '../collage/pack.ts'
import { resolveArt, rollFlight } from '../domain/asset.ts'
import type { LayoutManifest } from '../domain/manifest.ts'
import type { Species } from '../domain/species.ts'
import { fnv1a } from '../lib/hash.ts'
import { createPrng } from '../lib/prng.ts'

// Frame compositor: renders a window's gated species into a fixed-size PNG for
Expand All @@ -27,18 +28,6 @@ export interface FrameOptions {
const SHADOW = { color: 'rgba(26,22,18,0.1)', blur: 6, offsetY: 2 }
const DEFAULT_AR = 1.4

const FNV_OFFSET = 0x811c9dc5
const FNV_PRIME = 0x01000193

function fnv1a(str: string): number {
let h = FNV_OFFSET
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i)
h = Math.imul(h, FNV_PRIME)
}
return h >>> 0
}

/** Stable seed per species so its pose holds across renders (no e-ink churn),
* while ~FLY_PROB of the roster still fly. */
function seedFor(sci: string): number {
Expand Down
Loading