Height Detective
A histogram-height detective viz. The caller passes a heights number array, a selectedIndex (the bar under inspection), a question plus options and a correctIndex. The component renders the histogram with the selected bar accented, a dashed target-height reference line when supplied, and a radiogroup of tappable options that locks on the first correct pick and surfaces optional per-option feedback.
Generic enough to cover any "is the inspected height related to some target value?" interaction — prefix-sum subarray-summing-to-k detective puzzles, monotonic-stack height comparisons, largest-rectangle leader checks, water-trapping height duels — without baking in scoring, audio, or phase progression.
You're at height 6 and need a gap of 5. Has the staircase been at height 1 before?
Installation
npx shadcn@latest add https://craftbits.dev/r/height-detective.jsonUsage
import { HeightDetective } from "@craft-bits/core";
<HeightDetective
heights={[0, 1, 3, 6, 4, 9]}
selectedIndex={3}
targetHeight={1}
question="Has the staircase been at height 1 before?"
options={["Yes", "No"]}
correctIndex={0}
/>Controlled — parent owns the answer:
const [answer, setAnswer] = useState<number | null>(null);
<HeightDetective
heights={heights}
selectedIndex={i}
question={q}
options={opts}
correctIndex={correct}
answer={answer}
onAnswerChange={setAnswer}
onCorrect={() => playSound("insight")}
/>Per-option feedback (renders below the option row after a pick):
<HeightDetective
heights={heights}
selectedIndex={3}
question="Has the staircase been at height 1 before?"
options={["Yes", "No"]}
correctIndex={0}
feedback={{
0: "Height 1 was recorded at step 1. Gap 6 - 1 = 5 means a matching subarray.",
1: "Look again — the notebook holds {0, 1, 3}. Height 1 IS there.",
}}
/>Axis labels below each bar:
<HeightDetective
heights={[0, 1, 3, 6, 4, 9]}
selectedIndex={3}
question={q}
options={opts}
correctIndex={0}
labels={["0", "1", "2", "3", "4", "5"]}
/>Understanding the component
- One selected bar. Every bar paints in a neutral chip; only the bar at
selectedIndexgets the activetone. - Target reference line. Pass
targetHeightand a dashed horizontal line spans the chart at that y-value. Useful forcurrent - krelations in subarray-sum patterns or leader heights in largest-rectangle duels. - Question + options + correctIndex. The answer panel is a radiogroup with one option per string. Picking sets the answer; the option locks in the success tone when correct and the error tone when wrong.
- Controlled + uncontrolled answer.
answer+onAnswerChangeis the Radix controlled pattern;defaultAnswermakes the component own its own selection.onCorrectfires once when the first correct pick lands. - Per-option feedback. Pass
feedbackkeyed by option index — the string for the picked index renders below the option row. - Axis fits selected + target. The y-axis scales to the maximum of every height and the target so the dashed line is always in frame.
- Reduced motion. Bar entry stagger, value-change transitions, and feedback fades collapse to instant under
prefers-reduced-motion: reduce.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
heights | number[] | required | Bar heights. |
selectedIndex | number | required | Index of the bar painted in the active tone. |
question | string | required | Detective question rendered above the answer row. |
options | string[] | required | Tappable answer options. |
correctIndex | number | required | Index of the correct option. |
targetHeight | number | — | Dashed reference line drawn across the chart at this y-value. |
answer | number | null | — | Controlled answer index. |
defaultAnswer | number | null | null | Uncontrolled initial answer index. |
onAnswerChange | (next: number) => void | — | Fires on every tap. |
onCorrect | () => void | — | Fires once when the first correct pick lands. |
feedback | Record<number, string> | — | Per-option feedback. |
lockOnCorrect | boolean | true | Lock the answer row after the first correct pick. |
labels | string[] | — | One-line strip below each bar. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Highlight tone for the selected bar and target line. |
barWidth | number | 36 | Bar width in pixels. |
barGap | number | 6 | Gap between bars in pixels. |
plotHeight | number | 140 | Plot area height in pixels. |
header | ReactNode | — | Content rendered above the histogram. |
footer | ReactNode | — | Content rendered below the answer row. |
transition | Transition | SPRINGS.smooth | Override transitions. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The outer
<svg>isrole="img"with a<title>summarising the heights, the selected bar, and the target height. - The answer row is
role="radiogroup"witharia-labelledbypointing at the question paragraph, and each option isrole="radio"witharia-checked. - Every option carries an explicit
aria-labelnaming the option number and its text. - After a pick, a
role="status"aria-live="polite"paragraph announces the feedback. - The component exposes
data-lockedanddata-correcton the root so consumer apps can hook custom styles or assistive tooling. - Tone is never the only signal — correct / wrong picks switch a
yes/nomicro-label in addition to the colour. - Motion respects
prefers-reduced-motion: reduce. - Every option button is at least 44px tall so the tap target satisfies WCAG 2.5.8 AAA.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/decision/HeightDetective.tsx). The source was a 1500-line five-phase lesson component bundling brute-force enumeration, prefix-sum staircase walk, hashmap notebook, magic-move code reveal, prediction gates, hint bars, audio cues, and act-by-act score rollup. The library extract keeps only the height-detective primitive — a histogram with one selected bar, an optional target-height reference line, a question with options + correctIndex, per-option feedback, controlled / uncontrolled answer — and lets the caller compose any phases, scoring, narration, or sound on top via theheader/footerslots.