Tide Chart
NewInstallation
npx shadcn@latest add https://uibrik.com/r/tide-chart.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/tide-chart/tide-chart.tsx"use client"; import { useEffect, useRef, useState, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Headline, Legend, Tooltip, TooltipRow, TooltipTitle, useChartCursor, useWidth, } from "../_charts/parts"; import { clamp, linePath, nearest, type Point } from "../_charts/scale"; import { accentVars, DRAW_IN, FOLLOW, GRID, SETTLE, seriesColor } from "../_charts/theme"; import { formatCompact, formatShare, formatValue, type ValueFormat } from "../_charts/format"; import { dayLabels, wave } from "../_charts/sample"; import { xLabelIndices } from "../_charts/axis"; /** One period: its label plus a number per series key. */ export type TideRow = { label: string; [key: string]: string | number }; export type TideSeries = { key: string; label: string; color?: string }; const SAMPLE_SERIES: TideSeries[] = [ { key: "search", label: "Search" }, { key: "direct", label: "Direct" }, { key: "social", label: "Social" }, { key: "email", label: "Email" }, ]; const BASES = [1800, 1200, 760, 420]; const SAMPLE_WAVES = SAMPLE_SERIES.map((_, j) => wave(30, { seed: 7 + j * 5, base: BASES[j], amp: 0.35, period: 9 + j * 3, phase: j * 1.3, noise: 0.06 }), ); const SAMPLE: TideRow[] = dayLabels(30).map((label, i) => ({ label, ...Object.fromEntries(SAMPLE_SERIES.map((s, j) => [s.key, SAMPLE_WAVES[j][i]])), })); // Room above the stream and below it for the dates. const TOP = 8; const X_AXIS = 24; // Thinnest a band can be and still carry its number. const LABEL_MIN = 16; /** A band between two edges, as one closed smooth shape. */ function ribbon(top: readonly Point[], bottom: readonly Point[]) { if (top.length < 2) return ""; const back = [...bottom].reverse(); return `${linePath(top)}L${linePath(back).slice(1)}Z`; } export type TideChartProps = { /** Names what is plotted. */ title?: string; /** One row per period, oldest first: a label and a value for each series key. */ data?: TideRow[]; /** The bands to draw, 2 to 6, bottom to top. */ series?: TideSeries[]; /** Intl.NumberFormat options for every number. */ format?: ValueFormat; /** "stream" centres the bands on a flowing axis, "stack" sits them on the floor. */ layout?: "stream" | "stack"; /** First band colour. Empty uses the chart palette. */ accent?: string; /** Plot height in px. */ height?: number; /** Called with the row under the cursor, or null when it leaves. */ onActiveChange?: (index: number | null) => void; className?: string; }; export function TideChart({ title = "Traffic by source", data = SAMPLE, series = SAMPLE_SERIES, format = {}, layout = "stream", accent = "", height = 260, onActiveChange, className, }: TideChartProps) { const reduce = useReducedMotion(); const [ref, width] = useWidth(); const cursor = useChartCursor(data.length); const { active } = cursor; // Click a band to solo it; click it again to bring the rest back. const [solo, setSolo] = useState<string | null>(null); const [legendFocus, setLegendFocus] = useState<string | null>(null); const [hoverKey, setHoverKey] = useState<string | null>(null); const all = series.map((s, i) => ({ ...s, color: s.color ?? seriesColor(i) })); const n = data.length; const fmt = (v: number) => formatValue(v, format); const raw = data.map((d) => all.map((s) => Math.max(0, Number(d[s.key] ?? 0)))); const rawTotals = raw.map((r) => r.reduce((a, b) => a + b, 0)); // A soloed band keeps its values and the rest drop to zero, so they fold flat into the stream. const shown = raw.map((r) => r.map((v, j) => (solo && all[j].key !== solo ? 0 : v))); const totals = shown.map((r) => r.reduce((a, b) => a + b, 0)); const bottomEdge = height - X_AXIS; const k = (bottomEdge - TOP) / Math.max(1, ...totals); const mid = (TOP + bottomEdge) / 2; const base = totals.map((t) => (layout === "stream" ? mid + (t * k) / 2 : bottomEdge)); const xs = data.map((_, i) => (n > 1 ? (width * i) / (n - 1) : 0)); const layers = all.map((_, j) => { const top: Point[] = []; const bottom: Point[] = []; for (let i = 0; i < n; i++) { let below = 0; for (let m = 0; m < j; m++) below += shown[i][m]; const b = base[i] - below * k; bottom.push([xs[i], b]); top.push([xs[i], b - shown[i][j] * k]); } return { top, bottom }; }); // Where every band sits before it has any thickness: the entrance starts here. const flat: Point[] = xs.map((x) => [x, mid]); const flatD = ribbon(flat, flat); const focus = legendFocus ?? hoverKey; const lit = focus !== null && (!solo || focus === solo) ? focus : null; const litIndex = lit ? all.findIndex((s) => s.key === lit) : -1; const onPointerMove = (e: PointerEvent<HTMLDivElement>) => { if (!n || !width) return; const rect = e.currentTarget.getBoundingClientRect(); const i = nearest(xs, e.clientX - rect.left); const y = e.clientY - rect.top; cursor.point(i); const j = layers.findIndex((l, jj) => shown[i][jj] > 0 && y >= l.top[i][1] && y <= l.bottom[i][1]); setHoverKey(j >= 0 ? all[j].key : null); }; const toggleSolo = (key: string) => setSolo((s) => (s === key ? null : key)); const callback = useRef(onActiveChange); useEffect(() => { callback.current = onActiveChange; }); useEffect(() => callback.current?.(active), [active]); const xLabels = width ? xLabelIndices(data.map((d) => d.label), 0, width) : []; const at = active !== null ? xs[active] : undefined; const headRow = active ?? n - 1; const stackTop = active !== null ? base[active] - totals[active] * k : 0; const tipY = active !== null ? (litIndex >= 0 ? layers[litIndex].top[active][1] : stackTop) : 0; const drawKey = `${n}-${all.map((s) => s.key).join()}-${layout}`; return ( <ChartCard title={title} value={<Headline value={rawTotals[headRow] ?? 0} format={format} />} meta={<span className="text-xs text-muted-foreground tabular-nums">{data[headRow]?.label}</span>} footer={ <Legend items={all} hidden={solo ? all.filter((s) => s.key !== solo).map((s) => s.key) : []} onToggle={toggleSolo} active={lit} onActiveChange={setLegendFocus} /> } className={cn("max-w-160", className)} style={accentVars(accent)} > <div ref={ref} role="group" aria-label={`${title} over ${n} periods. Arrow keys move between them.`} className={cn( "relative touch-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-foreground/20", hoverKey ? "cursor-pointer" : "cursor-crosshair", )} style={{ height }} onPointerMove={onPointerMove} onPointerDown={onPointerMove} onClick={() => hoverKey && toggleSolo(hoverKey)} {...cursor.bind} onPointerLeave={() => { cursor.bind.onPointerLeave(); setHoverKey(null); }} > {width > 0 && n > 1 && ( <svg width={width} height={height} className="block overflow-visible" aria-hidden> {layout === "stack" && <line x1={0} x2={width} y1={bottomEdge} y2={bottomEdge} stroke={GRID} shapeRendering="crispEdges" />} {all.map((s, j) => { const off = !!solo && solo !== s.key; const dim = lit !== null && lit !== s.key; const d = ribbon(layers[j].top, layers[j].bottom); return ( <motion.path key={`${s.key}-${drawKey}`} fill={s.color} className="stroke-background" strokeWidth={1.5} strokeLinejoin="round" initial={{ opacity: 0, d: flatD }} animate={{ opacity: off ? 0 : dim ? 0.25 : 1, d }} transition={{ d: reduce ? { duration: 0 } : { ...SETTLE, delay: 0.03 * j }, opacity: { duration: reduce ? 0 : 0.25, delay: reduce ? 0 : DRAW_IN * 0.1 }, }} /> ); })} {xLabels.map((i, j) => ( <text key={i} x={xs[i]} y={height - 4} textAnchor={j === 0 ? "start" : j === xLabels.length - 1 ? "end" : "middle"} className="fill-muted-foreground text-[11px] font-medium tabular-nums" > {data[i].label} </text> ))} {at !== undefined && ( <motion.g initial={false} animate={{ x: at }} transition={reduce ? { duration: 0 } : FOLLOW}> <line x1={0} x2={0} y1={TOP} y2={bottomEdge} className="stroke-foreground/25" strokeWidth={1} /> </motion.g> )} {/* The number of every band thick enough to hold one, at the cursor. */} {active !== null && all.map((s, j) => { const top = layers[j].top[active][1]; const bottom = layers[j].bottom[active][1]; if (bottom - top < LABEL_MIN || (lit !== null && lit !== s.key)) return null; return ( <motion.g key={s.key} initial={false} animate={{ x: clamp(xs[active], 22, width - 22), y: (top + bottom) / 2 }} transition={reduce ? { duration: 0 } : FOLLOW} > <text textAnchor="middle" dy="0.32em" className="pointer-events-none fill-white text-[11px] font-medium tabular-nums" style={{ paintOrder: "stroke", stroke: "rgb(0 0 0 / 0.12)", strokeWidth: 3 }} > {formatCompact(shown[active][j], format)} </text> </motion.g> ); })} </svg> )} <Tooltip x={at ?? 0} y={tipY - 4} bounds={width} visible={active !== null}> {active !== null && (litIndex >= 0 ? ( <> <TooltipTitle>{data[active].label}</TooltipTitle> <TooltipRow color={all[litIndex].color} value={fmt(raw[active][litIndex])} label={`${all[litIndex].label} · ${formatShare(rawTotals[active] ? raw[active][litIndex] / rawTotals[active] : 0)}`} /> </> ) : ( <> <span className="opacity-60">{data[active].label}</span> · {fmt(rawTotals[active])} </> ))} </Tooltip> </div> <Announce> {active !== null ? `${data[active].label}: ${all.map((s, j) => `${s.label} ${fmt(raw[active][j])}`).join(", ")}` : ""} </Announce> <DataTable caption={title} columns={["Period", ...all.map((s) => s.label)]} rows={data.map((d, i) => [d.label, ...all.map((_, j) => fmt(raw[i][j]))])} /> </ChartCard> ); }
Adds the files to
components/uibrik/tide-chart and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/tide-chart.Props
TideChart- title
- Type string
- "Traffic by source"
- Names what is plotted.
- layout
- Type "stream" | "stack"
- "stream"
- Bands centred on a flowing axis, or stacked on the floor.
- format
- Type Intl.NumberFormatOptions
- {}
- Formats every number.
- height
- Type number
- 260
- Plot height.
- accent
- Type string
- ""
- First band colour. Empty uses the chart palette.
- data
- Type ({ label: string } & Record<string, number>)[]
- –
- One row per period, oldest first, with a value per series key.
- series
- Type { key: string; label: string; color?: string }[]
- –
- The bands to draw, 2 to 6, bottom to top.
- onActiveChange
- Type (index: number | null) => void
- –
- Called with the row under the cursor.
- className
- Type string
- –
- Classes for the outer shell.