{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hachure-fall",
  "title": "Hachure Fall",
  "description": "A route elevation profile drawn as 18th-century military engravers drew slope: strokes perpendicular to the profile line, hanging down the fall direction, whose length and density both derive from local grade. No colour ramp, no vertical-exaggeration slider.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/hachure-fall/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// HachureFall — a route elevation profile drawn the way 18th-century military\n// engravers drew slope: short strokes perpendicular to the profile line,\n// hanging down the fall direction, whose LENGTH and DENSITY are both driven\n// by one governing scalar, the local grade g = dh/dx. A flat kilometre earns\n// no ink; a 12% ramp earns a dense comb. There is deliberately no colour\n// ramp anywhere in this file — colour is the legend hachures were invented\n// to remove, and re-introducing it here would defeat the point.\n//\n// GEOMETRY: the elevation curve itself is auto-fit to a fixed internal band\n// (CURVE_BAND_H) purely so the route has a recognisable silhouette — that\n// vertical fit is an ordinary chart auto-scale, computed once from the data,\n// never exposed as a control. It is NOT the source of stroke length. Stroke\n// length comes from the real-world grade percentage (elevation-metres over\n// distance-metres), multiplied by the fixed constant K = STROKE_UNIT_PX /\n// UNIT_GRADE_PCT, printed in the corner as \"1 stroke-height = 5% grade\".\n// Because the two scales are independent, two routes drawn at different\n// auto-fit heights still produce numerically comparable hachures — which is\n// the whole reason a vertical-exaggeration SLIDER is refused here: exposing\n// one would let a reader silently change what an identical stroke length\n// means between two profiles, exactly the dishonesty hachure marks exist to\n// prevent. K is a module constant, not a prop.\n//\n// Every segment's hachures are emitted as ONE <path> (a run of \"M..L..\"\n// subpaths), not one DOM node per stroke — generated from data, never\n// hand-placed. Spacing follows spacing = S0 / (1 + |g|) (g as a fraction),\n// so a steep segment's ticks crowd together while a flat one thins to\n// nothing under MIN_LEN_PX and draws no ticks at all.\n//\n// SCRUB CURSOR: pointer or keyboard sets a target km; the rendered cursor\n// (and which segment is \"active\" — promoted from --ns-muted to --foreground,\n// its ticks lifted ~2px like bristles under a thumb) tracks a 90ms\n// critically damped spring toward that target, not the raw input. An\n// undamped 1:1 follow flickers across segment boundaries on noisy real\n// elevation data; a longer/underdamped spring feels detached from the\n// pointer. The spring only runs while hovered or focused — there is no\n// resting position to relax back to, the cursor simply appears/disappears.\n//\n// A11Y: the track itself is role=\"slider\" over distance (km), with\n// aria-valuetext \"km 14.2, 220 m elevation, 9% climb\". PageUp/PageDown do\n// not step a fixed distance — they jump to the next point where the grade's\n// classification bucket (flat / easy / moderate / steep / very steep, signed\n// by climb vs descent) changes, a real navigation unit for a route rather\n// than an arbitrary number of km. aria-describedby points at a static\n// plain-text shape summary (total climb, named climbs, the single steepest\n// point) so the drawing's information survives without vision, independent\n// of live scrubbing.\n// ---------------------------------------------------------------------------\n\nexport interface ElevationPoint {\n  /** distance along the route, kilometres, strictly increasing */\n  km: number;\n  /** elevation at that distance, metres */\n  m: number;\n}\n\nexport interface HachureFallProps {\n  /** elevation samples, ordered by km. Needs at least 2 points. */\n  data?: ElevationPoint[];\n  /** accessible name for the slider. Default \"Route elevation profile\". */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\n// -- fixed constants: the falsifiable, non-adjustable half of the contract --\nconst VW = 760; // internal SVG coordinate width\nconst PAD_X = 12; // internal px, left/right inset for the plotted curve\nconst TOP_PAD = 18; // internal px above the curve band\nconst CURVE_BAND_H = 88; // internal px the elevation silhouette auto-fits into\nconst HACHURE_ROOM = 66; // internal px reserved below the curve for strokes\nconst VH = TOP_PAD + CURVE_BAND_H + HACHURE_ROOM;\n\nconst STROKE_UNIT_PX = 14; // \"1 stroke-height\" — the printed legend unit\nconst UNIT_GRADE_PCT = 5; // ...corresponds to this many percent grade\nconst K = STROKE_UNIT_PX / UNIT_GRADE_PCT; // px of stroke per percent grade — FIXED\nconst MAX_LEN_PX = 58; // hard visual ceiling so absurd grades don't blow the layout\nconst MIN_LEN_PX = 0.6; // below this, draw nothing — the \"nearly blank\" flat case\nconst S0 = 9; // base tick spacing (internal px) at ~0 grade\n\nconst CURSOR_LIFT_PX = 2; // bristle lift near the scrub cursor\nconst CURSOR_WINDOW_PX = 42; // internal px falloff radius for the lift\n\nconst SPRING_K = 4200; // s^-2 — tuned so the critically damped response settles ~90ms\nconst SPRING_C = 2 * Math.sqrt(SPRING_K); // critical damping: c = 2*sqrt(k)\n\nconst CLASS_BOUNDS = [2, 4, 7, 10]; // abs% grade thresholds separating classes\n\nconst DEFAULT_DATA: ElevationPoint[] = [\n  { km: 0, m: 140 },\n  { km: 1, m: 142 },\n  { km: 2, m: 146 },\n  { km: 3, m: 148 },\n  { km: 4, m: 150 },\n  { km: 4.5, m: 172 },\n  { km: 5, m: 210 },\n  { km: 5.5, m: 258 },\n  { km: 6, m: 298 },\n  { km: 6.5, m: 322 },\n  { km: 7, m: 330 },\n  { km: 8, m: 332 },\n  { km: 9, m: 328 },\n  { km: 10, m: 330 },\n  { km: 11, m: 335 },\n  { km: 12, m: 338 },\n  { km: 13, m: 336 },\n  { km: 14, m: 340 },\n  { km: 15, m: 344 },\n  { km: 16, m: 342 },\n  { km: 17, m: 300 },\n  { km: 17.5, m: 260 },\n  { km: 18, m: 224 },\n  { km: 18.5, m: 196 },\n  { km: 19, m: 180 },\n  { km: 20, m: 178 },\n  { km: 21, m: 182 },\n  { km: 21.5, m: 210 },\n  { km: 22, m: 254 },\n  { km: 22.4, m: 296 },\n  { km: 22.8, m: 336 },\n  { km: 23.2, m: 372 },\n  { km: 23.6, m: 400 },\n  { km: 24, m: 412 },\n  { km: 25, m: 414 },\n  { km: 26, m: 410 },\n  { km: 27, m: 412 },\n  { km: 28, m: 408 },\n];\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, v));\n}\n\nfunction classIndexFor(gradePct: number): number {\n  const mag = Math.abs(gradePct);\n  let bucket = 0;\n  for (let i = 0; i < CLASS_BOUNDS.length; i++) {\n    if (mag >= CLASS_BOUNDS[i]) bucket = i + 1;\n  }\n  if (bucket === 0) return 0; // flat, sign doesn't matter\n  return gradePct > 0 ? bucket : -bucket;\n}\n\nfunction gradeWord(gradePct: number): string {\n  const r = Math.round(Math.abs(gradePct));\n  if (gradePct > 0.5) return `${r}% climb`;\n  if (gradePct < -0.5) return `${r}% descent`;\n  return \"flat\";\n}\n\ninterface Point {\n  x: number;\n  y: number;\n}\n\ninterface Tick {\n  frac: number; // 0..1 along the segment\n  bx: number;\n  by: number;\n  px: number; // unit perpendicular x\n  py: number; // unit perpendicular y\n  baseLen: number;\n}\n\ninterface Segment {\n  index: number;\n  kmStart: number;\n  kmEnd: number;\n  gradePct: number;\n  p0: Point;\n  p1: Point;\n  dx: number;\n  dy: number;\n  lenPx: number;\n  dBase: string;\n  ticks: Tick[];\n}\n\nfunction tickPathD(bx: number, by: number, px: number, py: number, len: number): string {\n  const ex = bx + px * len;\n  const ey = by + py * len;\n  return `M${bx.toFixed(2)},${by.toFixed(2)}L${ex.toFixed(2)},${ey.toFixed(2)}`;\n}\n\nfunction buildSegments(data: ElevationPoint[]): Segment[] {\n  if (data.length < 2) return [];\n  const elevs = data.map((d) => d.m);\n  const minElev = Math.min(...elevs);\n  const maxElev = Math.max(...elevs);\n  const range = Math.max(1, maxElev - minElev);\n  const totalKm = data[data.length - 1].km || 1;\n  const plotW = VW - PAD_X * 2;\n\n  const project = (p: ElevationPoint): Point => ({\n    x: PAD_X + (p.km / totalKm) * plotW,\n    y: TOP_PAD + CURVE_BAND_H * (1 - (p.m - minElev) / range),\n  });\n\n  const segments: Segment[] = [];\n  for (let i = 0; i < data.length - 1; i++) {\n    const a = data[i];\n    const b = data[i + 1];\n    const p0 = project(a);\n    const p1 = project(b);\n    const dx = p1.x - p0.x;\n    const dy = p1.y - p0.y;\n    const lenPx = Math.hypot(dx, dy);\n    const distM = (b.km - a.km) * 1000;\n    const gradePct = distM > 0 ? ((b.m - a.m) / distM) * 100 : 0;\n\n    if (lenPx < 0.01) {\n      segments.push({ index: i, kmStart: a.km, kmEnd: b.km, gradePct, p0, p1, dx, dy, lenPx: 0.01, dBase: \"\", ticks: [] });\n      continue;\n    }\n\n    const tx = dx / lenPx;\n    const ty = dy / lenPx;\n    // perpendicular, oriented so its y-component is non-negative — \"down\"\n    // in screen space, i.e. the fall direction, regardless of climb or\n    // descent sign\n    let px = -ty;\n    let py = tx;\n    if (py < 0 || (py === 0 && px < 0)) {\n      px = -px;\n      py = -py;\n    }\n\n    const strokeLen = clamp(K * Math.abs(gradePct), 0, MAX_LEN_PX);\n    const ticks: Tick[] = [];\n    let dParts = \"\";\n\n    if (strokeLen >= MIN_LEN_PX) {\n      const spacing = S0 / (1 + Math.abs(gradePct) / 100);\n      const nTicks = Math.max(1, Math.min(400, Math.floor(lenPx / spacing)));\n      for (let k = 0; k < nTicks; k++) {\n        const frac = (k + 0.5) / nTicks;\n        const bx = p0.x + dx * frac;\n        const by = p0.y + dy * frac;\n        ticks.push({ frac, bx, by, px, py, baseLen: strokeLen });\n        dParts += tickPathD(bx, by, px, py, strokeLen);\n      }\n    }\n\n    segments.push({\n      index: i,\n      kmStart: a.km,\n      kmEnd: b.km,\n      gradePct,\n      p0,\n      p1,\n      dx,\n      dy,\n      lenPx,\n      dBase: dParts,\n      ticks,\n    });\n  }\n  return segments;\n}\n\nfunction profilePath(data: ElevationPoint[], segments: Segment[]): string {\n  if (segments.length === 0) return \"\";\n  let d = `M${segments[0].p0.x.toFixed(2)},${segments[0].p0.y.toFixed(2)}`;\n  for (const s of segments) d += `L${s.p1.x.toFixed(2)},${s.p1.y.toFixed(2)}`;\n  return d;\n}\n\ninterface Climb {\n  startKm: number;\n  endKm: number;\n  avgGradePct: number;\n}\n\ninterface RouteSummary {\n  text: string;\n  boundaries: number[]; // km positions where the grade class changes\n}\n\nfunction summarizeRoute(data: ElevationPoint[], segments: Segment[]): RouteSummary {\n  if (segments.length === 0) {\n    return { text: \"No elevation data.\", boundaries: [] };\n  }\n\n  let totalClimbM = 0;\n  for (let i = 0; i < data.length - 1; i++) {\n    const rise = data[i + 1].m - data[i].m;\n    if (rise > 0) totalClimbM += rise;\n  }\n\n  const boundaries: number[] = [];\n  let prevClass = classIndexFor(segments[0].gradePct);\n  for (let i = 1; i < segments.length; i++) {\n    const cls = classIndexFor(segments[i].gradePct);\n    if (cls !== prevClass) boundaries.push(segments[i].kmStart);\n    prevClass = cls;\n  }\n\n  // group consecutive \"climbing\" segments (>3% grade) into named climbs,\n  // weighting the average grade by distance\n  const climbs: Climb[] = [];\n  let cur: { startKm: number; endKm: number; distM: number; gainM: number } | null = null;\n  for (const s of segments) {\n    if (s.gradePct > 3) {\n      const distM = (s.kmEnd - s.kmStart) * 1000;\n      const gainM = distM * (s.gradePct / 100);\n      if (cur) {\n        cur.endKm = s.kmEnd;\n        cur.distM += distM;\n        cur.gainM += gainM;\n      } else {\n        cur = { startKm: s.kmStart, endKm: s.kmEnd, distM, gainM };\n      }\n    } else if (cur) {\n      climbs.push({ startKm: cur.startKm, endKm: cur.endKm, avgGradePct: (cur.gainM / cur.distM) * 100 });\n      cur = null;\n    }\n  }\n  if (cur) climbs.push({ startKm: cur.startKm, endKm: cur.endKm, avgGradePct: (cur.gainM / cur.distM) * 100 });\n\n  let steepest = segments[0];\n  for (const s of segments) if (Math.abs(s.gradePct) > Math.abs(steepest.gradePct)) steepest = s;\n  const steepestKm = (steepest.kmStart + steepest.kmEnd) / 2;\n\n  const climbText = climbs.length\n    ? `${climbs.length} climb${climbs.length === 1 ? \"\" : \"s\"}: ${climbs\n        .map((c) => `km ${c.startKm.toFixed(0)}–${c.endKm.toFixed(0)} avg ${Math.round(c.avgGradePct)}%`)\n        .join(\", \")}`\n    : \"no sustained climbs\";\n\n  const text = `total climb ${Math.round(totalClimbM)} m; ${climbText}; steepest ${Math.round(\n    Math.abs(steepest.gradePct)\n  )}% at km ${steepestKm.toFixed(1)}`;\n\n  return { text, boundaries };\n}\n\nfunction findSegmentIndex(segments: Segment[], km: number): number {\n  if (segments.length === 0) return -1;\n  if (km <= segments[0].kmStart) return 0;\n  const last = segments[segments.length - 1];\n  if (km >= last.kmEnd) return segments.length - 1;\n  for (let i = 0; i < segments.length; i++) {\n    if (km >= segments[i].kmStart && km <= segments[i].kmEnd) return i;\n  }\n  return segments.length - 1;\n}\n\nfunction elevationAt(data: ElevationPoint[], km: number): number {\n  if (data.length === 0) return 0;\n  if (km <= data[0].km) return data[0].m;\n  const last = data[data.length - 1];\n  if (km >= last.km) return last.m;\n  for (let i = 0; i < data.length - 1; i++) {\n    const a = data[i];\n    const b = data[i + 1];\n    if (km >= a.km && km <= b.km) {\n      const frac = b.km === a.km ? 0 : (km - a.km) / (b.km - a.km);\n      return a.m + (b.m - a.m) * frac;\n    }\n  }\n  return last.m;\n}\n\nexport function HachureFall({\n  data = DEFAULT_DATA,\n  label = \"Route elevation profile\",\n  className = \"\",\n}: HachureFallProps) {\n  const uid = useId().replace(/:/g, \"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const svgRef = useRef<SVGSVGElement>(null);\n  const pathRefs = useRef<Array<SVGPathElement | null>>([]);\n  const cursorGroupRef = useRef<SVGGElement>(null);\n  const cursorDotRef = useRef<SVGCircleElement>(null);\n\n  const segments = useMemo(() => buildSegments(data), [data]);\n  const totalKm = data.length > 0 ? data[data.length - 1].km : 0;\n  const outline = useMemo(() => profilePath(data, segments), [data, segments]);\n  const summary = useMemo(() => summarizeRoute(data, segments), [data, segments]);\n\n  const [active, setActive] = useState(false);\n  const [announceKm, setAnnounceKm] = useState(0);\n\n  const reducedRef = useRef(false);\n  const targetKmRef = useRef(0);\n  const springKmRef = useRef(0);\n  const velRef = useRef(0);\n  const activeSegRef = useRef(-1);\n  const rafRef = useRef(0);\n  const lastTRef = useRef(0);\n  const lastAnnouncedRef = useRef(-1);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChange = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  // reset any lifted path back to its resting geometry/colour\n  const restoreSegment = useCallback((idx: number) => {\n    const el = pathRefs.current[idx];\n    const seg = segments[idx];\n    if (!el || !seg) return;\n    el.setAttribute(\"d\", seg.dBase);\n    el.setAttribute(\"stroke\", \"var(--ns-muted)\");\n  }, [segments]);\n\n  const applyFrame = useCallback(() => {\n    const km = clamp(springKmRef.current, 0, totalKm);\n    const segIdx = findSegmentIndex(segments, km);\n\n    if (segIdx !== activeSegRef.current) {\n      if (activeSegRef.current >= 0) restoreSegment(activeSegRef.current);\n      activeSegRef.current = segIdx;\n      const el = pathRefs.current[segIdx];\n      if (el) el.setAttribute(\"stroke\", \"var(--foreground)\");\n    }\n\n    const seg = segments[segIdx];\n    if (seg) {\n      const el = pathRefs.current[segIdx];\n      if (el && seg.ticks.length > 0) {\n        const segFrac = seg.lenPx > 0 ? clamp((km - seg.kmStart) / Math.max(1e-6, seg.kmEnd - seg.kmStart), 0, 1) : 0;\n        let d = \"\";\n        for (const t of seg.ticks) {\n          const distPx = seg.lenPx * Math.abs(t.frac - segFrac);\n          const liftFactor = reducedRef.current ? 0 : clamp(1 - distPx / CURSOR_WINDOW_PX, 0, 1);\n          const len = t.baseLen + CURSOR_LIFT_PX * liftFactor;\n          d += tickPathD(t.bx, t.by, t.px, t.py, len);\n        }\n        el.setAttribute(\"d\", d);\n      }\n\n      const p0 = seg.p0;\n      const frac = clamp((km - seg.kmStart) / Math.max(1e-6, seg.kmEnd - seg.kmStart), 0, 1);\n      const cx = p0.x + seg.dx * frac;\n      const cy = p0.y + seg.dy * frac;\n      if (cursorGroupRef.current) cursorGroupRef.current.setAttribute(\"transform\", `translate(${cx.toFixed(2)},0)`);\n      if (cursorDotRef.current) {\n        cursorDotRef.current.setAttribute(\"cx\", \"0\");\n        cursorDotRef.current.setAttribute(\"cy\", cy.toFixed(2));\n      }\n    }\n\n    const rounded = Math.round(km * 10) / 10;\n    if (rounded !== lastAnnouncedRef.current) {\n      lastAnnouncedRef.current = rounded;\n      setAnnounceKm(rounded);\n    }\n  }, [segments, totalKm, restoreSegment]);\n\n  const stopLoop = useCallback(() => {\n    if (rafRef.current) {\n      cancelAnimationFrame(rafRef.current);\n      rafRef.current = 0;\n    }\n  }, []);\n\n  const startLoop = useCallback(() => {\n    if (rafRef.current) return;\n    lastTRef.current = 0;\n    const tick = (now: number) => {\n      const dt = lastTRef.current === 0 ? 1 / 60 : Math.min(0.05, (now - lastTRef.current) / 1000);\n      lastTRef.current = now;\n\n      if (reducedRef.current) {\n        springKmRef.current = targetKmRef.current;\n        velRef.current = 0;\n      } else {\n        // semi-implicit Euler on this spring's stiffness needs omega*h well\n        // under 1 to stay stable (omega = sqrt(SPRING_K) ~= 65 rad/s, so a\n        // single 60fps step of ~16.7ms already overshoots that badly and\n        // diverges exponentially frame over frame). Substep at a fixed small\n        // h so the 90ms settle time from SPRING_K is preserved without\n        // blowing up the integrator.\n        const H = 0.004;\n        const steps = Math.max(1, Math.ceil(dt / H));\n        const h = dt / steps;\n        for (let i = 0; i < steps; i++) {\n          const err = springKmRef.current - targetKmRef.current;\n          const acc = -SPRING_K * err - SPRING_C * velRef.current;\n          velRef.current += acc * h;\n          springKmRef.current += velRef.current * h;\n        }\n        // hard guard: no retune of the constants above should ever be able\n        // to hand a NaN/out-of-range value to setAttribute downstream.\n        springKmRef.current = clamp(springKmRef.current, 0, totalKm);\n      }\n\n      applyFrame();\n      rafRef.current = requestAnimationFrame(tick);\n    };\n    rafRef.current = requestAnimationFrame(tick);\n  }, [applyFrame]);\n\n  useEffect(() => stopLoop, [stopLoop]);\n\n  const setTarget = useCallback(\n    (km: number, immediate = false) => {\n      const c = clamp(km, 0, totalKm);\n      targetKmRef.current = c;\n      if (immediate || reducedRef.current) {\n        springKmRef.current = c;\n        velRef.current = 0;\n      }\n      if (!active) setActive(true);\n      startLoop();\n    },\n    [active, startLoop, totalKm]\n  );\n\n  const kmFromClientX = useCallback(\n    (clientX: number) => {\n      const rect = svgRef.current?.getBoundingClientRect();\n      if (!rect || rect.width === 0) return targetKmRef.current;\n      const frac = clamp((clientX - rect.left) / rect.width, 0, 1);\n      return frac * totalKm;\n    },\n    [totalKm]\n  );\n\n  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    setTarget(kmFromClientX(e.clientX));\n  };\n\n  const onPointerLeave = () => {\n    if (document.activeElement === rootRef.current) return;\n    setActive(false);\n    stopLoop();\n    if (activeSegRef.current >= 0) {\n      restoreSegment(activeSegRef.current);\n      activeSegRef.current = -1;\n    }\n  };\n\n  const onFocus = () => {\n    setTarget(targetKmRef.current || 0, true);\n  };\n\n  const onBlur = () => {\n    setActive(false);\n    stopLoop();\n    if (activeSegRef.current >= 0) {\n      restoreSegment(activeSegRef.current);\n      activeSegRef.current = -1;\n    }\n  };\n\n  const stepSize = Math.max(0.1, totalKm / 200);\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    const current = clamp(springKmRef.current, 0, totalKm);\n    switch (e.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        e.preventDefault();\n        setTarget(current + stepSize);\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        e.preventDefault();\n        setTarget(current - stepSize);\n        break;\n      case \"PageUp\": {\n        e.preventDefault();\n        const next = summary.boundaries.find((b) => b > current + 1e-6);\n        setTarget(next ?? totalKm);\n        break;\n      }\n      case \"PageDown\": {\n        e.preventDefault();\n        const prev = [...summary.boundaries].reverse().find((b) => b < current - 1e-6);\n        setTarget(prev ?? 0);\n        break;\n      }\n      case \"Home\":\n        e.preventDefault();\n        setTarget(0);\n        break;\n      case \"End\":\n        e.preventDefault();\n        setTarget(totalKm);\n        break;\n      default:\n        break;\n    }\n  };\n\n  const elevNow = elevationAt(data, announceKm);\n  const segNow = segments[findSegmentIndex(segments, announceKm)];\n  const gradeNow = segNow ? segNow.gradePct : 0;\n  const valueText = `km ${announceKm.toFixed(1)}, ${Math.round(elevNow)} m elevation, ${gradeWord(gradeNow)}`;\n  const describeId = `hachure-fall-summary-${uid}`;\n\n  return (\n    <div className={`relative ${className}`}>\n      <div\n        ref={rootRef}\n        data-hachure-fall-track=\"\"\n        role=\"slider\"\n        tabIndex={0}\n        aria-orientation=\"horizontal\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={totalKm}\n        aria-valuenow={Math.round(announceKm * 10) / 10}\n        aria-valuetext={valueText}\n        aria-describedby={describeId}\n        className=\"relative w-full cursor-crosshair select-none rounded-[12px] border border-border bg-background px-2 pb-8 pt-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n        onPointerMove={onPointerMove}\n        onPointerLeave={onPointerLeave}\n        onFocus={onFocus}\n        onBlur={onBlur}\n        onKeyDown={onKeyDown}\n      >\n        <svg\n          ref={svgRef}\n          viewBox={`0 0 ${VW} ${VH}`}\n          className=\"block h-auto w-full\"\n          aria-hidden=\"true\"\n        >\n          {/* the profile line itself — thin, subordinate to the hachures */}\n          <path d={outline} fill=\"none\" stroke=\"var(--border)\" strokeWidth={1.25} />\n\n          {segments.map((seg, i) => (\n            <path\n              key={seg.index}\n              ref={(el) => {\n                pathRefs.current[i] = el;\n              }}\n              d={seg.dBase}\n              fill=\"none\"\n              stroke=\"var(--ns-muted)\"\n              strokeWidth={1}\n              strokeLinecap=\"round\"\n            />\n          ))}\n\n          <g ref={cursorGroupRef} style={{ opacity: active ? 1 : 0, transition: active ? undefined : \"opacity 120ms ease-out\" }}>\n            <line x1={0} y1={0} x2={0} y2={VH} stroke=\"var(--ns-accent)\" strokeWidth={1} strokeDasharray=\"2 3\" />\n            <circle ref={cursorDotRef} r={2.75} fill=\"var(--ns-accent)\" />\n          </g>\n        </svg>\n\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-1.5 left-2 font-mono text-[10px] tabular-nums text-ns-muted transition-opacity\"\n          style={{ opacity: active ? 1 : 0 }}\n        >\n          km {announceKm.toFixed(1)} · {Math.round(elevNow)} m · {gradeWord(gradeNow)}\n        </div>\n\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-1.5 right-2 font-mono text-[10px] text-ns-muted\"\n        >\n          1 stroke-height = 5% grade\n        </div>\n      </div>\n\n      <p id={describeId} className=\"sr-only\">\n        {summary.text}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/hachure-fall.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "chart",
      "elevation",
      "route",
      "svg",
      "slider",
      "keyboard",
      "accessibility",
      "cycling",
      "terrain"
    ],
    "instruction": "Build <HachureFall data? label? className?> — a route elevation profile where slope is drawn as perpendicular hachure strokes, not colour or a decoded y-axis. DATA: `data` is ElevationPoint[] ({km, m}, strictly increasing km); buildSegments() projects each consecutive pair onto a fixed internal SVG coordinate system (VW=760 wide) where the elevation curve auto-fits its own silhouette into a fixed CURVE_BAND_H band (an ordinary one-time chart auto-scale, ordinary and NOT exposed as a prop or control) purely so the route has a recognisable shape. GOVERNING SCALAR: for every segment, gradePct = (elevation delta in metres / distance delta in metres) * 100 — real-world grade, computed independently of whatever vertical scale the curve happens to be drawn at. Every hachure property derives from gradePct alone: stroke length = clamp(K * |gradePct|, 0, MAX_LEN_PX) where K = STROKE_UNIT_PX(14) / UNIT_GRADE_PCT(5) is a MODULE CONSTANT, never a prop, printed verbatim in the component's bottom-right corner as '1 stroke-height = 5% grade' in font-mono; tick spacing = S0(9) / (1 + |gradePct|/100), so a steep segment's strokes crowd together and a near-flat segment's clamp to below MIN_LEN_PX and draw literally nothing (the 'flat kilometre is nearly blank' requirement). There is deliberately NO vertical-exaggeration control anywhere in the props or UI: the curve's auto-fit height and the hachure length's K constant are two independently fixed mappings, so exposing an exaggeration slider would let the same visual stroke length silently mean a different grade on two different routes — exactly the dishonesty hachure marks were invented to prevent. GEOMETRY: each segment's full set of ticks is emitted as ONE <path> element whose `d` attribute concatenates one 'M x,y L x2,y2' subpath per tick (built by the tickPathD/buildSegments functions from the data, never a literal per-stroke DOM node), stroke=var(--ns-muted) at rest. The perpendicular direction for each segment is rotated from its local tangent and normalised so its y-component is always >= 0 — 'down' on screen, the fall direction, independent of whether that segment is a climb or a descent. SCRUB CURSOR: pointermove over the root (no click/drag required — a hover-follow slider) or arrow-key presses set a target km; the rendered cursor position, the 'active' segment's promotion from --ns-muted to --foreground stroke, and that segment's tick lift all track a critically damped spring (k=4200 s^-2, c=2*sqrt(k), tuned to settle in ~90ms) toward the target rather than following it 1:1 — an undamped follow flickers across segment boundaries on real (noisy) elevation data, while a slower/underdamped spring would feel detached from the pointer. Ticks within CURSOR_WINDOW_PX(42 internal px) of the cursor's position along the active segment lift by up to CURSOR_LIFT_PX(2) with linear falloff, recomputed every animation frame via direct `setAttribute('d', ...)` writes on cached SVGPathElement refs (never React state on the animation hot path — React state is only touched, and only when the rounded value actually changes, to drive the accessible value text). The spring only runs while hovered or focused; there is no rest position to relax back to, the cursor group's opacity just fades to 0 on blur/pointerleave. prefers-reduced-motion collapses the spring to an instant snap and disables the tick-lift entirely, while keeping the colour promotion (state still legible, just not animated). A11Y: the whole interactive surface is a single role=\"slider\" div (tabIndex 0, aria-orientation=\"horizontal\", aria-valuemin=0, aria-valuemax=total km, aria-valuenow rounded to 0.1km, aria-valuetext formatted exactly 'km 14.2, 220 m elevation, 9% climb' via gradeWord()). Arrow keys step by max(0.1, totalKm/200) km. PageUp/PageDown do NOT step a fixed distance: classIndexFor() buckets each segment's |grade| into flat/2%/4%/7%/10%+ classes signed by climb vs descent, summarizeRoute() collects every km position where the class changes into `boundaries`, and PageUp/PageDown jump to the next/previous boundary — a real route-navigation unit, not an arbitrary step. Home/End jump to the route's start/end. aria-describedby points at a visually-hidden (sr-only, not aria-hidden) paragraph holding summarizeRoute()'s static plain-text shape summary: total ascent, every named climb (segments run of >3% grade grouped, distance-weighted average grade) as 'km A–B avg N%', and the single steepest point route-wide as 'steepest N% at km K' — independent of scrubbing, so the drawing's information exists without vision or interaction. No extra aria-live region: the slider's own valuetext already announces scrub changes through native AT slider semantics. TOKENS: profile line var(--border); hachures var(--ns-muted) resting / var(--foreground) active; scrub cursor and its dot var(--ns-accent) (the one interaction-only use of accent in the file); legend and readout text font-mono var(--ns-muted). No color ramp anywhere — steepness is read from stroke length and density only, never hue, which is the whole point of reviving hachures over a heatmap."
  },
  "type": "registry:ui"
}