diff --git a/CLAUDE.md b/CLAUDE.md index 42a837c..3b3d3bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + `` 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 diff --git a/src/collage/Collage.tsx b/src/collage/Collage.tsx index cab346f..27b7e15 100644 --- a/src/collage/Collage.tsx +++ b/src/collage/Collage.tsx @@ -12,7 +12,7 @@ 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 { @@ -20,9 +20,11 @@ interface Props { 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 @@ -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 @@ -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 ( { expect(first).toEqual(again) }) }) + +function tile(sci: string, n: number, overrides: Partial = {}): 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' })]), + ) + }) +}) diff --git a/src/collage/layout.ts b/src/collage/layout.ts index 9ac7f76..bb99886 100644 --- a/src/collage/layout.ts +++ b/src/collage/layout.ts @@ -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' @@ -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) +} diff --git a/src/lib/hash.ts b/src/lib/hash.ts new file mode 100644 index 0000000..3bf212e --- /dev/null +++ b/src/lib/hash.ts @@ -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 +} diff --git a/src/server/render.ts b/src/server/render.ts index ec8066a..5b9027e 100644 --- a/src/server/render.ts +++ b/src/server/render.ts @@ -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 @@ -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 {