Imported from vizi-rgb/density-methods (
heatmaps-frontend/AGENTS.md). Install upstream withnpx skills add vizi-rgb/density-methods --skill heatmaps-frontend. Copyright stays with the author.
AGENTS.md — heatmaps-frontend
React app: upload a video once, then incrementally add independent,
parameterized heatmap analyses (directional/speed/cluster/tripwire/roi)
against it, each landing as its own tile in a grid as it completes. Talks to
heatmaps-backend.
Full design docs — read these before making non-trivial changes:
docs/frontend-plan.md— architecture, stack, state machine, component breakdown.docs/api-integration.md— the backend contract as consumed here, incl. what's unverified.docs/acceptance-criteria.md— Definition of Done checklist.
Stack
Vite 8, React 19 + TypeScript ~6 (strict: noUnusedLocals,
noUnusedParameters, erasableSyntaxOnly), native fetch/EventSource —
no hls.js (removed deliberately, see gotcha below), pnpm. No router,
no state management library, no UI framework — stays small on purpose.
Layout
src/
api/client.ts # uploadVideo(file), createHeatmapJob(videoId, request), getHeatmapStatus(jobId), ApiRequestError
api/sseStream.ts # openHeatmapStream(jobId, onMessage, onError)
components/
FileUpload.tsx # file input + submit only — no options
VideoPreview.tsx # raw upload playback
PerspectiveCalibrator.tsx # 4-point calibration on a captured video frame (Konva)
HeatmapMenu.tsx # Simple/Composed mode toggle + Add
PrimitiveHeatmapFields.tsx # category + per-category params (shared: Simple mode & one composer layer)
VisualizerFields.tsx # the 3-field visualizer fieldset (shared: per-job & composed job-level)
HeatmapComposer.tsx # layer-stack builder: add/remove/reorder/operator/invert, live readout
TripwirePicker.tsx # modal: pick line (p1,p2) + inside point on a captured frame (Konva)
RoiPicker.tsx # modal: pick an arbitrary polygon (>=3 pts) on a captured frame (Konva)
HeatmapTile.tsx # owns ONE job end-to-end: fetch + SSE + render
HeatmapGrid.tsx # grid of tiles, empty-state placeholder
JobStatus.tsx # progress bar, reused inside HeatmapTile
utils/
describeHeatmapRequest.ts # client-side label for the 5 simple types, mirrors backend's build_label()
describeComposedLayers.ts # client-side label for composed jobs, mirrors backend's build_composed_label()
primitiveHeatmapFields.ts # PrimitiveFieldsValue + validity/build helpers (pure, no JSX — react-refresh needs component files to only export components)
visualizerFields.ts # VisualizerFieldsValue + validity/build helpers (same reason)
types/index.ts # AppState, HeatmapRequest, HeatmapLayer, ComposedHeatmapRequest, HeatmapJobRequest, VideoOutput, HeatmapJobEvent, HeatmapTileData
App.tsx # state machine + sessionStorage persistence (one blob: videoId/videoUrl/tiles)
State machine: IDLE → UPLOADING → READY (+ERROR for upload failure
only — per-tile failures are handled inside HeatmapTile, never global).
sessionStorage key heatmaps.session persists {videoId, videoUrl, tiles} as one JSON blob; on reload each HeatmapTile independently
re-fetches its own state and reopens SSE if not yet terminal — App.tsx
itself has no per-job logic at all.
Commands
pnpm install
pnpm dev # http://localhost:5173, proxies /api to http://localhost:8000 (vite.config.ts)
pnpm build # tsc -b && vite build
pnpm lint # eslint .
No test runner. pnpm build+pnpm lint are the only automated checks.
Non-obvious things worth knowing before touching this code
- No
hls.js, on purpose — don't reintroduce it without a real reason. Every video (raw upload preview and every heatmap result) is a plain MP4 now. HLS was dropped because its actual benefits (adaptive bitrate, progressive playback, CDN segment caching) were never used here — one fixed quality, self-hosted, and the backend never hands over a URL before a job iscompletedanyway. Removing it dropped the production bundle from ~707KB to ~202KB. If a future need for progressive playback (showing video while it's still processing) comes up, that requires backend changes too, not just switching<video>sources back to HLS. - One job = one heatmap type + params = one tile. Second design of this
app. The first let you select multiple types at upload and got an array
of outputs back from one job, shown as tabs. That's gone —
outputin every API response is now singular,HeatmapTileowns exactly one job, andHeatmapGridis a real grid (not tabs) because tiles are independent, not variants of the same job. - Cluster
group_sizeis an exact match server-side, not "N or more" — confirmed with the user. The number input just needs>= 2(matches backend's DBSCANmin_samples=2); don't add "at least N" framing to the label or UI copy. tripwire/roishare oneRegionBuckettype (inside/outside/inside->outside/outside->inside) and one label map (REGION_BUCKET_LABELSinHeatmapMenu.tsx) — the backend implementstripwireas a special case ofroi, so the bucket semantics are identical. Don't reintroduce a separateTripwireBucket.TripwirePicker/RoiPickerseed pre-existing points via auseEffectkeyed onnaturalSize, not directly inuseState. Theinitialprop is in natural (unscaled) image coordinates — the same space points are submitted in — but the Konva canvas draws in scaled stage coordinates, and the scale factor (stageWidth / naturalSize.width) is only known once the captured video frame has loaded. Seedingpointsstraight frominitialat mount time renders them off-canvas (a real bug hit once onTripwirePicker's "Zmień punkty" re-open flow) — always convert via* scaleinside an effect gated onnaturalSize, using auseRefto capture the mount-timeinitialvalue so the effect doesn't need it in its dependency array.- Tile labels are computed client-side (
describeHeatmapRequest.ts), immediately at Add-time, from the exact request object — not from the server'soutput.label(which only exists once a job completes, so waiting for it would mean tiles show a placeholder for their entire processing time). The two should always agree since both mirror the same logic (app/domain/job.py'sbuild_labelon the backend) — if you change one, change the other. useState(loadSession)lazy initializer, not a mountuseEffect, for restoringsessionStorageinApp.tsx.eslint-plugin-react-hooks7.x'sset-state-in-effectrule flags synchronoussetStateinside a mount effect as an anti-pattern (extra render) — the fix is computing bothsessionand the derived initialappStatevia lazyuseStateinitializers instead, not suppressing the rule.erasableSyntaxOnly(tsconfig) forbids TS constructor parameter-property shorthand (constructor(public status: number)) — declare the field and assign it in the constructor body instead (seeApiRequestErrorinapi/client.ts).- CORS/contract verified live, but no actual browser click-through
has been done on this redesign — the Chrome extension wasn't connected
in the environment this was built in. Verification so far:
tsc/eslint/pnpm buildclean, plus the exact HTTP calls this code makes were exercised against a real running backend from the realpnpm devorigin (confirmedaccess-control-allow-originon upload, job creation, and both static-media routes). Do a real visual click-through before treating the UI itself as verified — the manual scenario indocs/acceptance-criteria.mdis the one to run. react-refresh/only-export-components(eslint) forbids a component file from also exporting constants/types/functions. Bit twice while extractingPrimitiveHeatmapFields/VisualizerFieldsout ofHeatmapMenu.tsx— the fix is a same-namedutils/*.tsfile for the value type + pure validity/build helpers, with the.tsxfile importing from it and exporting only the component. Don't putexport const/export functionnext to a component in the same file, even for things that feel component-local.- Composed jobs reuse
HeatmapRequestas a nested leaf, not a new parallel form.HeatmapLayer.heatmapis exactly the same 5-variant union used by Simple mode —PrimitiveHeatmapFields(and its pure counterpart inutils/primitiveHeatmapFields.ts) is shared between Simple mode and one "add a layer" step insideHeatmapComposer, withshowVisualizer={false}in the composer since visualizer settings are job-level there, not per-layer (half_life_timestays per-layer either way — it's decay behavior of that one primitive, not a rendering setting). - This is a monorepo.
heatmaps-frontend/,heatmaps-backend/, andlib/are sibling directories under one git repo (density-methods/). Don't assume this repo's root is the git root.