{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"price-trend","title":"Price trend","description":"Weekly price position against the market as a small line with a dashed parity line; hover or arrow through any week for its index and change.","registryDependencies":["https://ui.twelfth.ai/r/market-side.json","https://ui.twelfth.ai/r/one-open.json","https://ui.twelfth.ai/r/retail-format.json","popover"],"files":[{"path":"src/registry/catman/ui/price-trend.tsx","content":"\"use client\"\n\nimport { useState, type KeyboardEvent, type PointerEvent } from \"react\"\n\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\"\nimport { cn } from \"@/lib/utils\"\nimport { useOneOpen } from \"@/registry/catman/lib/one-open\"\nimport { formatMarketPosition } from \"@/registry/catman/lib/retail-format\"\nimport { MarketArrow, marketSideFromPosition, marketToneClass } from \"@/registry/catman/ui/market-side\"\n\n/**\n * Weeks of price position against the market, as a small line with a dashed\n * parity line. Hover, or focus and use the arrow keys, to read any week.\n *\n * The y domain is symmetric around zero so parity is always the middle and\n * \"above\" is always up. A week with no figure breaks the line rather than\n * being joined across: a gap is not a straight run.\n *\n * Part of the twelfth catman registry (`@twelfth/price-trend`).\n */\nexport type PriceTrendPoint = {\n  /** ISO date the week starts, e.g. \"2026-10-05\". */\n  week: string\n  /** Ours vs theirs, in percent; null when too few prices compared that week. */\n  positionPct: number | null\n}\n\nconst PAD = 2\nconst SMOOTHING = 0.12\nconst weekDate = new Intl.DateTimeFormat(\"en-AU\", { day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" })\nconst figure = (value: number) => value.toLocaleString(\"en-AU\", { maximumFractionDigits: 1 })\n/** A figure in the card, set apart from the words around it. */\nconst FIGURE_BADGE = \"inline-flex items-center justify-center rounded-md bg-muted px-1.5 py-0.5 font-mono tabular-nums text-foreground\"\n\nfunction weekLabel(week: string) {\n  const at = /^\\d{4}-\\d{2}-\\d{2}$/.test(week) ? new Date(`${week}T12:00:00Z`) : null\n  return at && Number.isFinite(at.getTime()) ? `Week of ${weekDate.format(at)}` : week\n}\n\nfunction linePath(points: Array<[number, number]>, smooth: boolean) {\n  if (points.length === 0) return \"\"\n  if (!smooth || points.length < 3) return points.map(([px, py], i) => `${i === 0 ? \"M\" : \"L\"}${px} ${py}`).join(\"\")\n\n  let path = `M${points[0][0]} ${points[0][1]}`\n  for (let i = 0; i < points.length - 1; i += 1) {\n    const previous = points[i - 1] ?? points[i]\n    const current = points[i]\n    const next = points[i + 1]\n    const following = points[i + 2] ?? next\n    const low = Math.min(current[1], next[1])\n    const high = Math.max(current[1], next[1])\n    const control1 = [\n      current[0] + (next[0] - previous[0]) * SMOOTHING,\n      Math.max(low, Math.min(high, current[1] + (next[1] - previous[1]) * SMOOTHING)),\n    ]\n    const control2 = [\n      next[0] - (following[0] - current[0]) * SMOOTHING,\n      Math.max(low, Math.min(high, next[1] - (following[1] - current[1]) * SMOOTHING)),\n    ]\n    path += `C${control1[0]} ${control1[1]} ${control2[0]} ${control2[1]} ${next[0]} ${next[1]}`\n  }\n  return path\n}\n\nexport function PriceTrend({\n  points,\n  label,\n  against = \"the market\",\n  width = 96,\n  height = 28,\n  inverse = false,\n  axes = true,\n  smooth = true,\n  className,\n}: {\n  points: PriceTrendPoint[]\n  /** What the line is, e.g. \"Position vs market\" or \"vs JB Hi-Fi\". */\n  label: string\n  /** Who the comparison is against, in the explainer. */\n  against?: string\n  width?: number\n  height?: number\n  /** Drawn on a dark tile: the line takes the tile's own text colour. */\n  inverse?: boolean\n  /** A faint left edge and baseline, so the line reads as a size and a direction. */\n  axes?: boolean\n  /** Gently rounds joins without overshooting the values. */\n  smooth?: boolean\n  className?: string\n}) {\n  const [open, setOpen] = useOneOpen()\n  const [selected, setSelected] = useState(points.length - 1)\n  const active = Math.max(0, Math.min(points.length - 1, selected))\n  const point = points[active]\n  const previous = points[active - 1]?.positionPct\n  const change = point?.positionPct != null && previous != null ? point.positionPct - previous : null\n  const side = marketSideFromPosition(point?.positionPct ?? null)\n  const known = points.flatMap((p) => (p.positionPct === null ? [] : [Math.abs(p.positionPct)]))\n  const extent = Math.max(2, ...known)\n  const x = (i: number) => (points.length < 2 ? width / 2 : PAD + (i * (width - 2 * PAD)) / (points.length - 1))\n  const y = (value: number) => height / 2 - (value / extent) * (height / 2 - PAD)\n  const fmt = (n: number) => Number(n.toFixed(1))\n\n  const runs: Array<Array<[number, number]>> = []\n  const lone: Array<[number, number]> = []\n  points.forEach((p, i) => {\n    if (p.positionPct === null) {\n      return\n    }\n    const px = fmt(x(i))\n    const py = fmt(y(p.positionPct))\n    if (i === 0 || points[i - 1]?.positionPct === null) runs.push([])\n    runs.at(-1)?.push([px, py])\n  })\n  runs.forEach((run) => { if (run.length === 1) lone.push(run[0]) })\n  const path = runs.map((run) => linePath(run, smooth)).join(\"\")\n\n  const first = points[0]?.positionPct ?? null\n  const last = points.at(-1)?.positionPct ?? null\n  const zero = fmt(y(0))\n  const chart = (\n    <svg\n      role=\"img\"\n      aria-label={`${label}: ${formatMarketPosition(first)} ${points.length} weeks ago, ${formatMarketPosition(last)} last week`}\n      width={width}\n      height={height}\n      viewBox={`0 0 ${width} ${height}`}\n      className={cn(\"block\", inverse ? \"text-current\" : \"text-foreground/80\", className)}\n    >\n      {axes ? <polyline points={`0.5,0 0.5,${height - 0.5} ${width},${height - 0.5}`} fill=\"none\" stroke=\"currentColor\" strokeOpacity={inverse ? 0.3 : 0.16} strokeWidth={1} /> : null}\n      <line x1={0} x2={width} y1={zero} y2={zero} stroke=\"currentColor\" strokeOpacity={inverse ? 0.4 : 0.28} strokeDasharray=\"2 2\" strokeWidth={1} />\n      {path && <path d={path} fill=\"none\" className=\"stroke-current\" strokeWidth={1.5} strokeLinejoin=\"round\" strokeLinecap=\"round\" />}\n      {lone.map(([cx, cy]) => (\n        <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={1.5} className=\"fill-current\" />\n      ))}\n      {open && point ? <>\n        <line data-trend-cursor x1={x(active)} x2={x(active)} y1={0} y2={height} className=\"stroke-muted-foreground\" strokeWidth={1} />\n        {point.positionPct !== null ? <circle cx={x(active)} cy={y(point.positionPct)} r={2.5} className=\"fill-popover stroke-current\" strokeWidth={1.5} /> : null}\n      </> : null}\n    </svg>\n  )\n\n  const inspect = (event: PointerEvent<HTMLButtonElement>) => {\n    const bounds = event.currentTarget.querySelector(\"svg\")?.getBoundingClientRect()\n    if (!bounds?.width || points.length === 0) return\n    const ratio = ((event.clientX - bounds.left) / bounds.width * width - PAD) / (width - PAD * 2)\n    setSelected(Math.max(0, Math.min(points.length - 1, Math.round(ratio * (points.length - 1)))))\n  }\n  const navigate = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (![\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].includes(event.key)) return\n    event.preventDefault()\n    setOpen(true)\n    setSelected(event.key === \"Home\" ? 0 : event.key === \"End\" ? points.length - 1 : Math.max(0, Math.min(points.length - 1, active + (event.key === \"ArrowLeft\" ? -1 : 1))))\n  }\n  if (points.length === 0) return chart\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger\n        openOnHover\n        delay={0}\n        closeDelay={60}\n        aria-label={`Inspect ${label} price history. Use left and right arrows to move between weeks.`}\n        onPointerMove={inspect}\n        onFocus={(event) => { if (event.currentTarget.matches(\":focus-visible\")) setOpen(true) }}\n        onKeyDown={navigate}\n        className=\"-my-1 inline-flex cursor-crosshair items-center rounded-sm p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >{chart}</PopoverTrigger>\n      <PopoverContent side=\"top\" align=\"center\" sideOffset={8} initialFocus={false} finalFocus={false} className=\"w-72 max-w-[calc(100vw-2rem)] p-3 text-xs\">\n        {/* One child: stock popovers stack their children with a gap. */}\n        <div>\n          <div className=\"truncate font-medium text-popover-foreground\">{label}</div>\n          <div aria-live=\"polite\" aria-atomic=\"true\">\n            <p className=\"mt-0.5 text-muted-foreground\">{point ? weekLabel(point.week) : \"No weekly history\"}</p>\n            {point?.positionPct != null ? <>\n              {/* The figure sits in a fixed-width badge and the words never wrap, so\n                  moving from 0.4% to 11.1% does not reflow the card. */}\n              <div className=\"mt-2 flex items-center gap-2\">\n                <span className={cn(\"inline-flex h-7 min-w-[5rem] shrink-0 items-center justify-center gap-0.5 rounded-md px-1.5 text-sm tabular-nums\", side === \"level\" ? \"bg-muted font-semibold\" : marketToneClass[side])}>\n                  <MarketArrow side={side} pointing=\"ours\" className=\"size-3.5\" />{figure(Math.abs(point.positionPct))}%\n                </span>\n                <span className=\"truncate text-[13px] font-medium whitespace-nowrap\">{point.positionPct === 0 ? \"at parity\" : `${point.positionPct > 0 ? \"above\" : \"below\"} comparison price`}</span>\n              </div>\n              <dl className=\"mt-2 space-y-1.5 border-t pt-2\">\n                <div className=\"flex items-center justify-between gap-3\">\n                  <dt className=\"text-muted-foreground\">Price index</dt><dd className={FIGURE_BADGE}>{figure(100 + point.positionPct)}</dd>\n                </div>\n                <div className=\"flex items-center justify-between gap-3\">\n                  <dt className=\"text-muted-foreground\">vs previous week</dt>\n                  {change !== null ? <dd className={FIGURE_BADGE}>{change > 0 ? \"+\" : change < 0 ? \"−\" : \"±\"}{figure(Math.abs(change))} pts</dd> : <dd className=\"py-0.5 text-muted-foreground\">No figure</dd>}\n                </div>\n              </dl>\n            </> : <p className=\"mt-2 text-muted-foreground\">Not enough comparable prices for this week.</p>}\n            <p className=\"mt-2 border-t pt-2 text-muted-foreground\">Our prices relative to {against}. The dashed line is parity (index 100).</p>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n","type":"registry:ui"}],"type":"registry:ui"}