{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "trend-line", "title": "Trend Line", "description": "The full sensor chart: crossing curves, dashed gridlines with right-hand labels, point annotations, and a called-out reading with a drop line.", "registryDependencies": [ "http://localhost:5173/r/liquid-theme.json", "http://localhost:5173/r/liquid-path.json" ], "files": [ { "path": "src/components/ui/trend-line.tsx", "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { scale, smoothPath, type Point } from \"@/lib/liquid-path\"\n\nexport interface TrendAnnotation {\n index: number\n label: string\n /** 0 = primary series, 1 = comparison series. */\n series?: 0 | 1\n}\n\nexport interface TrendLineProps extends Omit, \"values\"> {\n values: number[]\n /**\n * A second series. In the reference it is the mirror of the first, so the\n * two curves cross into lens shapes — the chart shows spread, not two facts.\n */\n compare?: number[]\n /** `lens` draws both curves bare. `band` fills between them. `single` hides the comparison. */\n variant?: \"lens\" | \"band\" | \"single\"\n yTicks?: number[]\n xLabels?: string[]\n /** Index of the reading to call out, with a drop line to the axis. */\n marker?: number\n annotations?: TrendAnnotation[]\n /** Accent class for the marker and its drop line. */\n accentClassName?: string\n height?: number\n}\n\nconst W = 440\nconst PLOT_L = 8\nconst PLOT_R = 368\n/** Headroom for annotation labels, which sit above their point. */\nconst PLOT_T = 26\n/** Reserved below the plot for the marker's drop dot and the axis labels. */\nconst AXIS_BAND = 50\n\nexport function TrendLine({\n values,\n compare,\n variant = \"lens\",\n yTicks = [],\n xLabels = [],\n marker,\n annotations = [],\n accentClassName = \"text-volt\",\n height = 240,\n className,\n ...props\n}: TrendLineProps) {\n const domain = React.useMemo(() => {\n const all = [...values, ...(compare ?? []), ...yTicks]\n return { min: Math.min(...all), max: Math.max(...all) }\n }, [values, compare, yTicks])\n\n // Plot geometry follows `height`, so shrinking the chart shrinks the plot\n // rather than letting the curves run out of the box.\n const plotBottom = Math.max(PLOT_T + 40, height - AXIS_BAND)\n const axisY = height - 12\n const plotW = PLOT_R - PLOT_L\n const plotH = plotBottom - PLOT_T\n\n const project = React.useCallback(\n (vals: number[]): Point[] =>\n scale(vals, plotW, plotH, { min: domain.min, max: domain.max }).map((p) => ({\n x: p.x + PLOT_L,\n y: p.y + PLOT_T,\n })),\n [domain.min, domain.max, plotW, plotH],\n )\n\n const primary = project(values)\n const secondary = compare && variant !== \"single\" ? project(compare) : null\n const dPrimary = smoothPath(primary, 0.6)\n const dSecondary = secondary ? smoothPath(secondary, 0.6) : null\n\n const yFor = (v: number) =>\n PLOT_T + plotH - ((v - domain.min) / (domain.max - domain.min || 1)) * plotH\n\n /**\n * Out along the primary curve, then back along the reversed comparison.\n * The return leg's leading moveto has to be dropped, not just its \"M\" —\n * leaving the coordinates behind produces a malformed path that fills as a\n * rectangle instead of a band.\n */\n const bandPath = React.useMemo(() => {\n if (variant !== \"band\" || !secondary?.length) return null\n const last = secondary[secondary.length - 1]\n const back = smoothPath([...secondary].reverse(), 0.6).replace(/^M[-\\d.\\s]+/, \"\")\n return `${dPrimary} L${last.x.toFixed(2)} ${last.y.toFixed(2)} ${back} Z`\n }, [variant, secondary, dPrimary])\n\n const markerPoint = marker != null ? primary[marker] : null\n\n return (\n \n {/* gridlines — dashed so they stay behind the curves */}\n {yTicks.map((v) => (\n \n \n \n {v}\n \n \n ))}\n\n {bandPath ? : null}\n\n {dSecondary ? (\n \n ) : null}\n\n \n\n {annotations.map((a, i) => {\n const set = a.series === 1 && secondary ? secondary : primary\n const p = set[a.index]\n if (!p) return null\n return (\n \n \n \n {a.label}\n \n \n )\n })}\n\n {markerPoint ? (\n \n \n \n \n \n \n ) : null}\n\n {xLabels.map((label, i) => {\n const last = xLabels.length - 1\n return (\n \n {label}\n \n )\n })}\n \n )\n}\n\n/**\n * Convenience: the crossing-sine pair from the reference frame. The default\n * sample count is high on purpose — under about 20 samples per period the\n * smoother turns the lens crossings into corners.\n */\nexport function lensSeries(count = 61, amplitude = 30, centre = 120, periods = 2.5) {\n const a: number[] = []\n const b: number[] = []\n for (let i = 0; i < count; i++) {\n const phase = (i / (count - 1)) * Math.PI * 2 * periods\n a.push(centre + Math.sin(phase) * amplitude)\n b.push(centre - Math.sin(phase) * amplitude)\n }\n return { a, b }\n}\n", "type": "registry:ui" } ], "type": "registry:ui" }