Files
inkreach-agent/docs/ui-ref/beautifului/insight-cards.json

21 lines
21 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"$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"
}
]
}