21 lines
21 KiB
JSON
21 lines
21 KiB
JSON
{
|
||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||
"name": "insight-cards",
|
||
"type": "registry:component",
|
||
"title": "Insight Cards",
|
||
"description": "Paged agent insights with scrub-ready live charts.",
|
||
"dependencies": [
|
||
"liveline"
|
||
],
|
||
"registryDependencies": [
|
||
"https://www.beautifului.dev/r/foundation.json"
|
||
],
|
||
"files": [
|
||
{
|
||
"path": "components/primitives/InsightCards.tsx",
|
||
"content": "\"use client\";\n\nimport { Liveline, type LivelinePoint, type LivelineSeries } from \"liveline\";\nimport { useEffect, useMemo, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * INSIGHT CARDS\n * Embedded mini-visualizations in an \"Insights N ‹ ›\"\n * carousel. Autoplay yields as soon as a person uses it.\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\nconst formatPercent = (v: number) => `${v > 0 ? \"+\" : \"\"}${v.toFixed(2)}%`;\nconst formatMoney = (v: number) => `$${Math.round(v).toLocaleString(\"en-US\")}`;\n/* anchor the snapshot to *call* time (inside each card's mount-time memo) —\n * a module-load constant goes stale, and once the points age past the chart\n * window the canvas renders empty */\nfunction makePoints(values: number[], gap = 6): LivelinePoint[] {\n const end = Math.floor(Date.now() / 1000);\n return values.map((value, index) => ({\n time: end - (values.length - 1 - index) * gap,\n value,\n }));\n}\n\n/* Catmull-Rom resample — turn a sparse series into a dense, smoothly curved\n * one so both the line and the hover cursor glide instead of stepping between\n * a handful of points. */\nfunction smooth(values: number[], perSegment = 9): number[] {\n if (values.length < 3) return values.slice();\n const out: number[] = [];\n const n = values.length;\n for (let i = 0; i < n - 1; i += 1) {\n const p0 = values[Math.max(0, i - 1)];\n const p1 = values[i];\n const p2 = values[i + 1];\n const p3 = values[Math.min(n - 1, i + 2)];\n for (let s = 0; s < perSegment; s += 1) {\n const t = s / perSegment;\n const t2 = t * t;\n const t3 = t2 * t;\n out.push(\n 0.5 *\n (2 * p1 +\n (-p0 + p2) * t +\n (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +\n (-p0 + 3 * p1 - 3 * p2 + p3) * t3),\n );\n }\n }\n out.push(values[n - 1]);\n return out;\n}\n\n/* dense, smoothed points spanning exactly `spanSecs` — keeps the chart window\n * unchanged while multiplying the resolution. */\nfunction smoothPoints(values: number[], spanSecs: number): LivelinePoint[] {\n const dense = smooth(values);\n return makePoints(dense, spanSecs / (dense.length - 1));\n}\n\nfunction useDarkMode() {\n const [dark, setDark] = useState(false);\n\n useEffect(() => {\n const root = document.documentElement;\n const update = () => setDark(root.classList.contains(\"dark\"));\n update();\n const observer = new MutationObserver(update);\n observer.observe(root, { attributes: true, attributeFilter: [\"class\"] });\n return () => observer.disconnect();\n }, []);\n\n return dark;\n}\n\n/* inline @entity mention */\nfunction Entity({ name, tone }: { name: string; tone: string }) {\n return (\n <span className=\"inline-flex items-center gap-1 align-baseline font-medium text-ink\">\n <span className={`inline-block size-2.5 rounded-full ${tone}`} />\n @{name}\n </span>\n );\n}\n\nfunction Mono({ children, tone }: { children: React.ReactNode; tone: \"red\" | \"green\" }) {\n return (\n <code className={`font-mono text-[11.5px] ${tone === \"red\" ? \"text-red\" : \"text-green\"}`}>\n {children}\n </code>\n );\n}\n\nfunction chartIndexFromPointer(event: React.PointerEvent<HTMLDivElement>, pointCount: number) {\n const rect = event.currentTarget.getBoundingClientRect();\n const progress = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));\n return Math.round(progress * (pointCount - 1));\n}\n\nfunction ChartTooltip({ rows }: { rows: { label: string; value: string; color: string }[] }) {\n return (\n <div className=\"insight-chart-tooltip\">\n {rows.map((row) => (\n <span key={row.label} className=\"insight-chart-tooltip-item\">\n <span className=\"insight-chart-tooltip-dot\" style={{ background: row.color }} />\n {row.value}\n </span>\n ))}\n </div>\n );\n}\n\n/* content shape for the return-comparison card's two plotted series */\nexport type CompareSeries = {\n name: string;\n values: number[];\n sub: string;\n tone: \"red\" | \"green\";\n dot: string;\n color: string;\n tooltipColor: string;\n};\n\nconst COMPARE_SERIES: CompareSeries[] = [\n {\n name: \"Mint Chip\",\n values: [-2.9, -3.4, -3.05, -3.86, -3.52, -4.1, -3.82, -4.41],\n sub: \"-$2,377.66\",\n tone: \"red\",\n dot: \"bg-orange\",\n color: \"#f68f3c\",\n tooltipColor: \"var(--orange)\",\n },\n {\n name: \"Pistachio\",\n values: [0.22, 0.58, 0.42, 0.91, 0.76, 1.08, 0.96, 1.15],\n sub: \"+$617.22\",\n tone: \"green\",\n dot: \"bg-accent\",\n color: \"#3d9aff\",\n tooltipColor: \"var(--accent)\",\n },\n];\n\n/* 1 — return comparison: 2 series, legend + big deltas + line chart */\nfunction CompareCard({ series = COMPARE_SERIES }: { series?: CompareSeries[] }) {\n const dark = useDarkMode();\n const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n const points = useMemo(\n () => series.map((s) => smoothPoints(s.values, 42)),\n [series],\n );\n const pointCount = points[0]?.length ?? 0;\n\n const chartSeries: LivelineSeries[] = useMemo(\n () =>\n series.map((s, i) => ({\n id: s.name,\n label: \"\",\n data: points[i],\n value: points[i].at(-1)?.value ?? (s.values.at(-1) ?? 0),\n color: s.color,\n })),\n [series, points],\n );\n\n return (\n <div className=\"min-h-[278px] rounded-card bg-surface p-3 shadow-hairline\">\n <div className=\"flex items-center gap-4\">\n {series.map((s, i) => (\n <div key={s.name} className=\"flex-1\">\n <span className=\"flex items-center gap-1.5 text-[11.5px] text-ink-2\">\n <span className={`size-2 rounded-full ${s.dot}`} />\n {s.name}\n </span>\n <span className={`block text-[17px] font-semibold tracking-[-0.01em] tabular-nums ${s.tone === \"red\" ? \"text-red\" : \"text-green\"}`}>\n {formatPercent(points[i].at(-1)?.value ?? (s.values.at(-1) ?? 0))}\n </span>\n <Mono tone={s.tone}>{s.sub}</Mono>\n </div>\n ))}\n </div>\n <div className=\"mt-2 overflow-hidden rounded-control bg-inset shadow-hairline\">\n <div className=\"flex items-center justify-between border-b border-line px-2.5 py-1.5\">\n <span className=\"text-[11px] text-ink-3 tabular-nums\">\n Trend snapshot\n </span>\n <span className=\"rounded-full bg-field px-2 py-0.5 text-[10.5px] font-medium text-ink-2\">\n Snapshot\n </span>\n </div>\n <div\n className=\"insight-chart-stage relative h-[166px]\"\n onPointerDown={(event) => setHoverIndex(chartIndexFromPointer(event, pointCount))}\n onPointerMove={(event) => setHoverIndex(chartIndexFromPointer(event, pointCount))}\n onPointerLeave={() => setHoverIndex(null)}\n onPointerCancel={() => setHoverIndex(null)}\n onPointerUp={() => setHoverIndex(null)}\n >\n <Liveline\n data={[]}\n value={0}\n series={chartSeries}\n theme={dark ? \"dark\" : \"light\"}\n grid={false}\n pulse={false}\n window={42}\n paused\n scrub={false}\n cursor=\"default\"\n lineWidth={2.25}\n padding={{ top: 40, right: 0, bottom: 22, left: 0 }}\n formatValue={formatPercent}\n />\n {hoverIndex !== null && <>\n <span className=\"insight-chart-cursor\" style={{ left: `${(hoverIndex / (pointCount - 1)) * 100}%` }} />\n <span className=\"insight-chart-tooltip-anchor\" style={{ left: `${Math.min(Math.max((hoverIndex / (pointCount - 1)) * 100, 28), 72)}%` }}>\n <ChartTooltip rows={series.map((s, i) => ({ label: s.name, value: formatPercent(points[i][hoverIndex].value), color: s.tooltipColor }))} />\n </span>\n </>}\n </div>\n </div>\n </div>\n );\n}\n\n/* content shape for the anomaly card's two toggled metric series */\nexport type AnomalyData = {\n spend: number[];\n usage: number[];\n};\n\nconst ANOMALY_DATA: AnomalyData = {\n spend: [274, 289, 264, 307, 331, 1210, 1718, 2112],\n usage: [18, 19, 17, 21, 22, 58, 81, 96],\n};\n\n/* 2 — anomaly: bars with threshold + big spent value */\nfunction AnomalyCard({ data: anomaly = ANOMALY_DATA }: { data?: AnomalyData }) {\n const dark = useDarkMode();\n const [metric, setMetric] = useState<\"spend\" | \"usage\">(\"spend\");\n const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n const spend = useMemo(\n () => makePoints(anomaly.spend, 7),\n [anomaly],\n );\n const usage = useMemo(\n () => makePoints(anomaly.usage, 7),\n [anomaly],\n );\n\n const data = metric === \"spend\" ? spend : usage;\n const value = data.at(-1)?.value ?? (metric === \"spend\" ? 2112 : 96);\n const threshold = metric === \"spend\" ? \"$2,112\" : \"82 kWh\";\n const moneyLabel = formatMoney(spend.at(-1)?.value ?? 2112);\n\n return (\n <div className=\"min-h-[278px] rounded-card bg-surface p-3 shadow-hairline\">\n <div className=\"flex items-center justify-between\">\n <span className=\"flex items-center gap-1.5 text-[12px] font-medium text-ink\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--red)\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M12 19V5M5 12l7-7 7 7\" /></svg>\n High freezer spend\n </span>\n <span className=\"rounded-full bg-field px-2 py-0.5 text-[10.5px] font-medium text-ink-2\">\n Snapshot\n </span>\n </div>\n <div className=\"mt-2 overflow-hidden rounded-control bg-inset shadow-hairline\">\n <div className=\"flex items-center justify-between border-b border-line px-2.5 py-1.5\">\n <span className=\"text-[11px] text-ink-3 tabular-nums\">\n {hoverIndex !== null\n ? metric === \"spend\"\n ? formatMoney(data[hoverIndex].value)\n : `${Math.round(data[hoverIndex].value)} kWh`\n : `${threshold} threshold`}\n </span>\n <span className=\"flex rounded-full bg-field p-0.5\">\n {([\"spend\", \"usage\"] as const).map((item) => (\n <button\n key={item}\n type=\"button\"\n aria-pressed={metric === item}\n onClick={() => setMetric(item)}\n className={`rounded-full px-2 py-0.5 text-[10.5px] font-medium transition-[background-color,color,box-shadow,transform] duration-150 active:scale-[0.96] ${\n metric === item ? \"bg-surface text-ink shadow-btn\" : \"text-ink-3 hover:text-ink-2\"\n }`}\n >\n {item === \"spend\" ? \"Spend\" : \"Usage\"}\n </button>\n ))}\n </span>\n </div>\n <div\n className=\"insight-chart-stage relative h-[166px]\"\n onPointerDown={(event) => setHoverIndex(chartIndexFromPointer(event, data.length))}\n onPointerMove={(event) => setHoverIndex(chartIndexFromPointer(event, data.length))}\n onPointerLeave={() => setHoverIndex(null)}\n onPointerCancel={() => setHoverIndex(null)}\n onPointerUp={() => setHoverIndex(null)}\n >\n <Liveline\n data={data}\n value={value}\n theme={dark ? \"dark\" : \"light\"}\n color=\"#ee5c61\"\n grid\n scrub={false}\n fill={false}\n pulse={false}\n momentum={false}\n paused\n window={49}\n lineWidth={2.25}\n cursor=\"crosshair\"\n padding={{ top: 34, right: 0, bottom: 22, left: 0 }}\n formatValue={(v) => (metric === \"spend\" ? formatMoney(v) : `${Math.round(v)} kWh`)}\n />\n {hoverIndex !== null && <>\n <span className=\"insight-chart-cursor\" style={{ left: `${(hoverIndex / (data.length - 1)) * 100}%` }} />\n <span className=\"insight-chart-tooltip-anchor\" style={{ left: `${Math.min(Math.max((hoverIndex / (data.length - 1)) * 100, 28), 72)}%` }}>\n <ChartTooltip rows={[{ label: metric === \"spend\" ? \"Spend\" : \"Usage\", value: metric === \"spend\" ? formatMoney(data[hoverIndex].value) : `${Math.round(data[hoverIndex].value)} kWh`, color: \"var(--red)\" }]} />\n </span>\n </>}\n </div>\n </div>\n <div className=\"mt-1.5 flex items-baseline gap-2\">\n <span className=\"text-[17px] font-semibold tracking-[-0.01em] text-ink tabular-nums\">\n {moneyLabel} spent\n </span>\n <Mono tone=\"red\">+$1,834.66</Mono>\n <span className=\"text-[11px] text-ink-3\">vs 3 months</span>\n </div>\n </div>\n );\n}\n\n/* content shape for one allocation segment */\nexport type AllocationSegment = {\n name: string;\n label: string;\n pct: number;\n amount: string;\n cls: string;\n tone: string;\n};\n\nconst ALLOCATION_SEGMENTS: AllocationSegment[] = [\n { name: \"VAN\", label: \"Vanilla\", pct: 72.5, amount: \"$51,785\", cls: \"bg-orange\", tone: \"text-orange\" },\n { name: \"CHOC\", label: \"Chocolate\", pct: 22.8, amount: \"$16,278\", cls: \"bg-line-strong\", tone: \"text-ink-2\" },\n { name: \"MINT\", label: \"Mint\", pct: 4.7, amount: \"$3,357\", cls: \"bg-line\", tone: \"text-ink-3\" },\n];\n\n/* 3 — allocation: hero number + segmented bar + legend */\nfunction AllocationCard({ segments = ALLOCATION_SEGMENTS }: { segments?: AllocationSegment[] }) {\n const [selected, setSelected] = useState(segments[0].name);\n const active = segments.find((segment) => segment.name === selected) ?? segments[0];\n\n return (\n <div className=\"min-h-[278px] rounded-card bg-surface p-3 shadow-hairline\">\n <span className=\"flex items-center gap-1.5 text-[12px] font-medium text-ink\">\n <span className=\"flex size-3.5 items-center justify-center rounded-full bg-orange text-[8px] font-bold text-white\">\n V\n </span>\n Vanilla allocation\n </span>\n <span className=\"mt-1 block text-[20px] font-semibold tracking-[-0.01em] text-ink tabular-nums\">\n {active.amount}\n </span>\n <div\n className=\"mt-3 flex h-9 gap-0.5 overflow-hidden rounded-full bg-field p-0.5\"\n role=\"group\"\n aria-label=\"Allocation segments\"\n >\n {segments.map((s) => (\n <button\n key={s.name}\n type=\"button\"\n aria-pressed={selected === s.name}\n aria-label={`${s.label}: ${s.pct}%`}\n onClick={() => setSelected(s.name)}\n className={`relative h-full overflow-hidden rounded-full ${s.cls} transition-[opacity,transform,box-shadow] duration-300 active:scale-[0.98]`}\n style={{\n width: `${s.pct}%`,\n opacity: selected === s.name ? 1 : 0.58,\n boxShadow: selected === s.name ? \"inset 0 0 0 1px rgba(255,255,255,0.22)\" : undefined,\n transitionTimingFunction: EASE,\n }}\n >\n <span\n className=\"absolute inset-y-1 left-1 rounded-full bg-white/20 transition-[width,opacity] duration-500\"\n style={{\n width: selected === s.name ? \"calc(100% - 8px)\" : \"0%\",\n opacity: selected === s.name ? 1 : 0,\n transitionTimingFunction: EASE,\n }}\n />\n </button>\n ))}\n </div>\n <div className=\"mt-2 flex items-center gap-1.5\">\n {segments.map((s) => (\n <button\n key={s.name}\n type=\"button\"\n aria-pressed={selected === s.name}\n onClick={() => setSelected(s.name)}\n className={`flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] transition-[background-color,color,transform] duration-150 active:scale-[0.96] ${\n selected === s.name ? \"bg-field text-ink\" : \"text-ink-2 hover:bg-hover hover:text-ink\"\n }`}\n >\n <span className={`size-1.5 rounded-full ${s.cls}`} />\n {s.name} <span className=\"tabular-nums\">{s.pct}%</span>\n </button>\n ))}\n </div>\n <div className=\"mt-3 min-h-16 rounded-control bg-inset px-2.5 py-2 shadow-hairline\">\n <span className={`block text-[11.5px] font-medium ${active.tone}`}>{active.label}</span>\n <span className=\"mt-1 block text-[11px] leading-relaxed text-ink-3\">\n Contribution snapshot across current inventory value. Segment selection changes the inspected group without moving the card.\n </span>\n </div>\n </div>\n );\n}\n\n/* content shape for one insight page in the carousel */\nexport type InsightPage = {\n key: string;\n prose: React.ReactNode;\n Card: React.ComponentType;\n pill: string;\n};\n\nconst PAGES: InsightPage[] = [\n {\n key: \"compare\",\n prose: (\n <>\n The worst performer in your <Entity name=\"Creamery\" tone=\"bg-orange\" /> is\n Rocky Road — down <Mono tone=\"red\">-6%</Mono> or <Mono tone=\"red\">-$2,453.44</Mono>.\n </>\n ),\n Card: CompareCard,\n pill: \"Should I rebalance flavors?\",\n },\n {\n key: \"anomaly\",\n prose: (\n <>\n Unusually high freezer bill on <span className=\"font-medium text-ink\">Dec 13</span> —{\" \"}\n <Mono tone=\"red\">+$1,834.66</Mono> above your average.\n </>\n ),\n Card: AnomalyCard,\n pill: \"Get tips on cutting freezer costs\",\n },\n {\n key: \"allocation\",\n prose: (\n <>\n You’re heavily invested in <Entity name=\"Vanilla\" tone=\"bg-orange\" /> — it’s{\" \"}\n <span className=\"font-medium text-ink\">72.5%</span> of your case.\n </>\n ),\n Card: AllocationCard,\n pill: \"If we look at seasonals, what changes?\",\n },\n];\n\nexport type InsightCardsLabels = {\n /** carousel heading shown before the page count */\n title: string;\n};\n\nconst DEFAULT_INSIGHT_LABELS: InsightCardsLabels = {\n title: \"Insights\",\n};\n\nexport default function InsightCards({\n pages = PAGES,\n labels,\n}: {\n variant?: string;\n pages?: InsightPage[];\n labels?: Partial<InsightCardsLabels>;\n} = {}) {\n const l = { ...DEFAULT_INSIGHT_LABELS, ...labels };\n const [page, setPage] = useState(0);\n\n const move = (direction: -1 | 1) => {\n setPage((current) => (current + direction + pages.length) % pages.length);\n };\n\n const { prose, Card, pill } = pages[page];\n\n return (\n <div className=\"min-h-[408px] w-full max-w-86\">\n {/* pager header */}\n <div className=\"flex items-center justify-between\">\n <span className=\"flex items-baseline gap-1.5\">\n <span className=\"text-[13px] font-semibold text-ink\">{l.title}</span>\n <span className=\"text-[13px] text-ink-3 tabular-nums\">{pages.length}</span>\n </span>\n <span className=\"flex items-center gap-0.5\">\n {([\"M15 18l-6-6 6-6\", \"M9 6l6 6-6 6\"] as const).map((d, i) => (\n <button\n key={i}\n aria-label={i === 0 ? \"Previous insight\" : \"Next insight\"}\n onClick={() => move(i === 0 ? -1 : 1)}\n className=\"flex size-6 items-center justify-center rounded-[6px] text-ink-3\n transition-[background-color,color,transform] duration-100 hover:bg-hover\n hover:text-ink active:scale-[0.96]\"\n >\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d={d} />\n </svg>\n </button>\n ))}\n </span>\n </div>\n\n {/* page content — blurred crossfade */}\n <div\n className=\"transition-[opacity,filter] duration-250\"\n style={{ opacity: 1, filter: \"blur(0)\" }}\n >\n <p className=\"mt-1.5 text-[12.5px] leading-relaxed text-ink-2\">{prose}</p>\n <div className=\"mt-2\">\n <Card />\n </div>\n <button\n className=\"mt-2 rounded-full bg-surface px-3 py-1.5 text-left text-[12px] text-ink\n shadow-btn transition-colors duration-100 hover:bg-hover\"\n >\n {pill}\n </button>\n </div>\n </div>\n );\n}\n",
|
||
"type": "registry:component",
|
||
"target": "components/primitives/InsightCards.tsx"
|
||
}
|
||
]
|
||
} |