diff --git a/.opencode/skills/data-viz/SKILL.md b/.opencode/skills/data-viz/SKILL.md new file mode 100644 index 0000000000..ad6a021691 --- /dev/null +++ b/.opencode/skills/data-viz/SKILL.md @@ -0,0 +1,135 @@ +--- +name: data-viz +description: > + Build modern, interactive data visualizations and dashboards using code-based + component libraries (shadcn/ui, Recharts, Tremor, Nivo, D3, Victory, visx). + Use this skill whenever the user asks to visualize data, build dashboards, + create analytics views, chart metrics, tell a data story, build a reporting + interface, create KPI cards, plot graphs, or explore a dataset — even if they + mention PowerBI, Tableau, Streamlit, Metabase, Looker, Grafana, or similar + tools. Also trigger when the user says "make a dashboard", "show me the data", + "chart this", "visualize trends", "build an analytics page", "data story", or + anything involving turning raw data into interactive visual interfaces. If the + task involves presenting data visually — this is the skill. Always prefer + building a real, interactive, code-based UI over exporting to or recommending + a BI platform. +--- + +# AI-First Data Visualization + +## Philosophy + +Build production-quality interactive data interfaces with modern component libraries — no vendor lock-in, embeddable anywhere. When no tool is specified, build code-first. When the user explicitly names a BI tool, use it — only suggest code-first if they ask for options or hit a technical blocker. + +## Technology Stack + +Full API patterns & code: `references/component-guide.md` + +### Framework Priority + +1. **React + Tailwind** — Default when JSX/TSX supported +2. **HTML + CSS + Vanilla JS** — Fallback (use D3 or Chart.js) +3. **Python (Plotly/Dash)** — Python-only environments only + +### Library Selection + +| Library | Best For | +|---------|----------| +| **shadcn/ui charts** | Default first choice — general dashboards, most chart types | +| **Recharts** | Line, bar, area, composed, radar — fine-grained control | +| **Tremor** | KPI cards, metric displays, full dashboard layouts | +| **Nivo** | Heatmaps, treemaps, choropleth, calendar, Sankey, funnel | +| **visx** | Bespoke custom viz — D3-level control with React | +| **D3.js** | Force-directed graphs, DAGs, maps — maximum flexibility | +| **Victory** | When animation quality matters most | + +**Supporting**: Tailwind CSS · Radix UI · Framer Motion · Lucide React · date-fns · Papaparse · lodash + +## Building a Visualization + +### Step 1: Understand the Data Story + +Before code, identify: **What question does the data answer?** Who is the audience (exec → KPIs only, analyst → drill-down, public → narrative)? **What's the ONE key insight?** Design around it. + +### Step 2: Choose Chart Type + +| Data Relationship | Chart Type | Library | +|---|---|---| +| Trend over time | Line, Area | shadcn/Recharts | +| Category comparison | Bar (horizontal if many) | shadcn/Recharts | +| Part of whole | Donut, Treemap | shadcn/Nivo | +| Distribution | Histogram, Box, Violin | Nivo/visx | +| Correlation | Scatter, Bubble | Recharts/visx | +| Geographic | Choropleth, Dot map | Nivo/D3 | +| Hierarchical | Treemap, Sunburst | Nivo | +| Flow / Process | Sankey, Funnel | Nivo/D3 | +| Single KPI | Metric card, Gauge, Sparkline | Tremor/shadcn | +| Multi-metric overview | Dashboard grid of cards | Tremor + shadcn | +| Ranking | Horizontal bar, Bar list | Tremor | +| Column/model lineage | Force-directed DAG | D3 | +| Pipeline dependencies | Hierarchical tree, DAG | D3/Nivo | +| Multi-dimensional quality | Radar/Spider | Recharts | +| Activity density over time | Calendar heatmap | Nivo | +| Incremental change breakdown | Waterfall | Recharts (custom) | + +### Step 3: Build the Interface + +Start from this layout — remove what the data doesn't need: + +``` +┌─────────────────────────────────────────┐ +│ Header: Title + Description + Date Range│ +├─────────────────────────────────────────┤ +│ KPI Row: 3-5 metric cards + sparklines │ +├─────────────────────────────────────────┤ +│ Primary Visualization (largest chart) │ +├──────────────────┬──────────────────────┤ +│ Secondary Chart │ Supporting Chart/Tbl │ +├──────────────────┴──────────────────────┤ +│ Detail Table (sortable, filterable) │ +└─────────────────────────────────────────┘ +``` + +A single insight might just be one chart with a headline and annotation. Scale complexity to audience. + +### Step 4: Design Principles + +- **Data-ink ratio**: Remove chartjunk — unnecessary gridlines, redundant labels, decorative borders +- **Color with purpose**: Encode meaning (red=bad, green=good, blue=neutral). Max 5-7 colors. Single-hue gradient for sequential data +- **Typography hierarchy**: Title → subtitle (muted) → axis labels (small) → data labels +- **Responsive**: `min-h-[VALUE]` on all charts. Grid stacks on mobile +- **Animation**: Entry transitions only, `duration-300` to `duration-500`. Never continuous +- **Accessibility**: `aria-label` on charts, WCAG AA contrast, don't rely on color alone + +### Step 5: Interactivity & Annotations + +**Priority**: Tooltips (every chart) → Filtering → Sorting → Drill-down → Cross-filtering → Export → Annotations + +**Annotations** turn charts into stories. Mark: inflection points, threshold crossings (amber), external events (indigo/red), anomalies (red), achievements (green). **Limit 3 per chart.** Implementation: `references/component-guide.md` → Annotation Patterns. + +### Step 6: Tell the Story + +- **Headline states insight**: "Revenue grew 23% QoQ, driven by enterprise" — not "Q3 Revenue Chart" +- **Annotate key moments** directly on chart +- **Contextual comparisons**: vs. prior period, vs. target, vs. benchmark +- **Progressive disclosure**: Overview first — detail on demand + +## Environment-Specific Guidance + +| Environment | Approach | +|---|---| +| **Claude Artifacts** | React (JSX), single file, default export. Available: `recharts`, `lodash`, `d3`, `lucide-react`, shadcn via `@/components/ui/*`, Tailwind | +| **Claude Code / Terminal** | Vite + React + Tailwind. Add shadcn/ui + Recharts. Structure: `src/components/charts/`, `src/components/cards/`, `src/data/` | +| **Python / Jupyter** | Plotly for charts, Plotly Dash for dashboards | +| **Cursor / Bolt / other IDEs** | Match existing framework. Prefer shadcn/ui if present | + +## Anti-Patterns + +- Screenshot/static charts — build interactive components +- Defaulting to BI tools unprompted — build code-first when no tool specified +- Default matplotlib — always customize in Python +- Rainbow palettes — use deliberate, meaningful colors +- 3D charts — almost never appropriate +- Pie charts > 5 slices — use horizontal bar +- Unlabeled dual y-axes — use two separate charts +- Truncated bar axes — always start at zero diff --git a/.opencode/skills/data-viz/references/component-guide.md b/.opencode/skills/data-viz/references/component-guide.md new file mode 100644 index 0000000000..8f792da07f --- /dev/null +++ b/.opencode/skills/data-viz/references/component-guide.md @@ -0,0 +1,394 @@ +# Component Library Reference + +Non-obvious patterns, gotchas, and custom implementations. Standard library usage (basic bar/line/area/pie/scatter) is well-documented — this covers what agents get wrong or can't infer. + +## Table of Contents + +1. [shadcn/ui Charts](#shadcnui-charts) — Config pattern & key rules +2. [Tremor Essentials](#tremor-essentials) — KPI cards, dashboard grid +3. [Nivo Gotchas](#nivo-gotchas) — Height wrapper, common props +4. [D3 + React Pattern](#d3--react-pattern) — Force-directed DAG +5. [Layout Patterns](#layout-patterns) — Dashboard grid, card component +6. [Color Systems](#color-systems) — Semantic, sequential, diverging, categorical +7. [Data Transformations](#data-transformations) — Recharts pivot, KPI aggregate, treemap, time bucketing +8. [Waterfall Chart](#waterfall-chart) — Custom Recharts pattern +9. [Radar / Spider Chart](#radar--spider-chart) — Key rules +10. [Calendar Heatmap](#calendar-heatmap) — Nivo setup +11. [Annotation Patterns](#annotation-patterns) — Goal lines, highlights, callouts, anomaly dots + +--- + +## shadcn/ui Charts + +Built on Recharts with themed, accessible wrappers. **Unique config pattern — don't skip this.** + +```tsx +import { type ChartConfig } from "@/components/ui/chart" + +const chartConfig = { + revenue: { label: "Revenue", color: "hsl(var(--chart-1))" }, + expenses: { label: "Expenses", color: "hsl(var(--chart-2))" }, +} satisfies ChartConfig +``` + +**Key rules:** +- Always `min-h-[VALUE]` on `ChartContainer` (required for responsiveness) +- Use `accessibilityLayer` prop on the main chart component +- Colors via CSS variables `var(--color-{key})`, never hardcoded +- Use `ChartTooltip` + `ChartTooltipContent`, not Recharts defaults +- Use `ChartLegend` + `ChartLegendContent` for interactive legends + +### Area Chart Gradient (common pattern) + +```tsx + + + + + + + +``` + +--- + +## Tremor Essentials + +### KPI Card Pattern + +```tsx +import { Card, BadgeDelta, SparkAreaChart } from "@tremor/react" + + +
+

Revenue

+ +12.3% +
+

$1.24M

+ +
+``` + +### Dashboard Grid + +```tsx +
+ {kpis.map(kpi => )} +
+``` + +### Tremor AreaChart / BarList + +```tsx + `$${(v/1000).toFixed(0)}k`} + className="h-72 mt-4" showAnimation /> + + +``` + +--- + +## Nivo Gotchas + +- **Always set `height` on the wrapper div**, not on the Nivo component: `
` +- Use `emptyColor="#f3f4f6"` for missing data cells +- For heatmaps: `colors={{ type: "sequential", scheme: "blues" }}` +- For treemaps: `identity="name"` + `value="value"` + `labelSkipSize={12}` +- For Sankey: `enableLinkGradient` + `linkBlendMode="multiply"` for polish +- For Choropleth: needs GeoJSON features, `projectionScale={150}`, `projectionTranslation={[0.5, 0.5]}` + +--- + +## D3 + React Pattern: Force-Directed DAG + +Use for lineage graphs, dependency trees, pipeline DAGs. **D3 computes positions, React renders SVG.** + +```tsx +import { useEffect, useRef } from "react" +import * as d3 from "d3" + +interface DagNode { id: string; label: string; type: "source" | "middle" | "output" } +interface DagLink { source: string; target: string } + +const NODE_COLORS: Record = { + source: { fill: "#dbeafe", stroke: "#3b82f6" }, + middle: { fill: "#f1f5f9", stroke: "#94a3b8" }, + output: { fill: "#dcfce7", stroke: "#22c55e" }, +} + +export function ForceDAG({ nodes, links }: { nodes: DagNode[]; links: DagLink[] }) { + const svgRef = useRef(null) + + useEffect(() => { + if (!svgRef.current) return + const width = svgRef.current.clientWidth || 800, height = 500 + const svg = d3.select(svgRef.current).attr("height", height) + svg.selectAll("*").remove() + + // Arrowhead marker + svg.append("defs").append("marker") + .attr("id", "dag-arrow").attr("viewBox", "0 -5 10 10") + .attr("refX", 22).attr("refY", 0) + .attr("markerWidth", 6).attr("markerHeight", 6).attr("orient", "auto") + .append("path").attr("d", "M0,-5L10,0L0,5").attr("fill", "#94a3b8") + + // CRITICAL: Copy arrays — D3 mutates them with x/y/vx/vy + const nodesCopy = nodes.map(n => ({ ...n })) + const linksCopy = links.map(l => ({ ...l })) + + const sim = d3.forceSimulation(nodesCopy as any) + .force("link", d3.forceLink(linksCopy).id((d: any) => d.id).distance(140)) + .force("charge", d3.forceManyBody().strength(-350)) + .force("center", d3.forceCenter(width / 2, height / 2)) + .force("collision", d3.forceCollide().radius(50)) + + const linkSel = svg.append("g").selectAll("line") + .data(linksCopy).join("line") + .attr("stroke", "#cbd5e1").attr("stroke-width", 1.5) + .attr("marker-end", "url(#dag-arrow)") + + const nodeSel = svg.append("g").selectAll("g") + .data(nodesCopy).join("g") + .call(d3.drag() + .on("start", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y }) + .on("drag", (e, d) => { d.fx = e.x; d.fy = e.y }) + .on("end", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null })) + + nodeSel.append("rect").attr("x", -54).attr("y", -18) + .attr("width", 108).attr("height", 36).attr("rx", 6) + .attr("fill", (d: any) => NODE_COLORS[d.type].fill) + .attr("stroke", (d: any) => NODE_COLORS[d.type].stroke).attr("stroke-width", 1.5) + + nodeSel.append("text").attr("text-anchor", "middle").attr("dy", "0.35em") + .attr("font-size", 11).attr("fill", "#374151") + .text((d: any) => d.label.length > 16 ? d.label.slice(0, 15) + "…" : d.label) + + sim.on("tick", () => { + linkSel.attr("x1", (d: any) => d.source.x).attr("y1", (d: any) => d.source.y) + .attr("x2", (d: any) => d.target.x).attr("y2", (d: any) => d.target.y) + nodeSel.attr("transform", (d: any) => `translate(${d.x},${d.y})`) + }) + return () => { sim.stop() } + }, [nodes, links]) + + return +} +``` + +**Rules:** Always copy nodes/links before D3. Use `clientWidth` for responsive width. Truncate labels, show full on hover. `alphaTarget(0)` on drag end lets sim cool naturally. + +--- + +## Layout Patterns + +### Dashboard Grid (Tailwind) + +```tsx +
+
+
+

Analytics

+

Your performance overview

+
+ +
+
+ {kpis.map(kpi => )} +
+
+ {/* Primary chart */} + {/* Secondary chart */} +
+ {/* DataTable */} +
+``` + +### shadcn-style Card + +```tsx +
+
+

{title}

+ +
+
+

{value}

+

0 ? "text-green-600" : "text-red-600")}> + {delta > 0 ? "+" : ""}{delta}% from last period +

+
+
+``` + +--- + +## Color Systems + +**Semantic (default):** +```css +--chart-1: 221.2 83.2% 53.3%; /* Blue */ +--chart-2: 142.1 76.2% 36.3%; /* Green */ +--chart-3: 24.6 95% 53.1%; /* Orange */ +--chart-4: 346.8 77.2% 49.8%; /* Red */ +--chart-5: 262.1 83.3% 57.8%; /* Purple */ +``` + +**Sequential** (heatmaps/gradients): Single hue light→dark. Tailwind `blue-100`→`blue-900` or Nivo schemes: `blues`, `greens`, `oranges`. + +**Diverging** (+/- values): Red ↔ White ↔ Green, or Red ↔ Grey ↔ Blue. Center on zero. + +**Categorical** (distinct groups): Max 7. Tailwind `500` shades: `blue`, `emerald`, `amber`, `rose`, `violet`, `cyan`, `orange`. + +--- + +## Data Transformations + +### Pivot for Recharts + +Recharts needs flat arrays with all series as keys per data point: + +```ts +// { date, category, value } rows → { date, cat_A: val, cat_B: val } +const pivoted = _.chain(rawData).groupBy("date") + .map((items, date) => ({ date, ..._.fromPairs(items.map(i => [i.category, i.value])) })) + .value() +``` + +### KPI Aggregation + +```ts +const kpis = { + total: _.sumBy(data, "revenue"), average: _.meanBy(data, "revenue"), + max: _.maxBy(data, "revenue"), count: data.length, + growth: ((current - previous) / previous * 100).toFixed(1), +} +``` + +### Flat → Hierarchical (Treemaps) + +```ts +const tree = { name: "root", children: _.chain(data).groupBy("category") + .map((items, name) => ({ name, children: items.map(i => ({ name: i.label, value: i.amount })) })).value() } +``` + +### Time Bucketing + +```ts +import { format, startOfWeek } from "date-fns" +const weekly = _.chain(data).groupBy(d => format(startOfWeek(new Date(d.date)), "yyyy-MM-dd")) + .map((items, week) => ({ week, total: _.sumBy(items, "value"), count: items.length })).value() +``` + +--- + +## Waterfall Chart + +Recharts has no native waterfall. Use stacked Bar with invisible spacer: + +```tsx +function toWaterfallSeries(items: { name: string; value: number }[]) { + let running = 0 + return items.map(item => { + const start = item.value >= 0 ? running : running + item.value + running += item.value + return { name: item.name, value: Math.abs(item.value), start, _raw: item.value } + }) +} + +// In ComposedChart: + + + {data.map((e, i) => = 0 ? "#22c55e" : "#ef4444"} />)} + +``` + +**Critical:** Spacer bar must have `isAnimationActive={false}` (animating it reveals the trick). Hide spacer from tooltip by returning `null` in formatter. For "total" bar: `start: 0`, `value: runningTotal`, distinct color (slate). + +--- + +## Radar / Spider Chart + +```tsx + + + + + + + + +``` + +**Rules:** `domain={[0, 100]}` for consistent comparison. Dashed benchmark gives context. Max 2 series. + +--- + +## Calendar Heatmap + +```tsx +import { ResponsiveCalendar } from "@nivo/calendar" + +
+ +
+``` + +**Rules:** Height on wrapper div, not component. Single-hue sequential palette. `emptyColor` near-white for sparse data. + +--- + +## Annotation Patterns + +Annotations turn charts into stories. **Limit 3 per chart.** + +**Color by type:** amber `#f59e0b` = target/goal, red `#ef4444` = incident/risk, indigo `#6366f1` = event/release, green `#22c55e` = achievement. + +### Goal/Threshold Line + +```tsx + +``` + +### Time Range Highlight + +```tsx + +``` + +### Floating Callout Label + +```tsx +const CalloutLabel = ({ viewBox, label, color = "#1e293b" }: { viewBox?: { x: number; y: number }; label: string; color?: string }) => { + if (!viewBox) return null + const { x, y } = viewBox, w = label.length * 7 + 16 + return ( + + {label} + + ) +} +// Usage: } /> +``` + +### Anomaly Dot Highlight + +```tsx + { + const { cx, cy, payload, key } = props + if (!payload?.isAnomaly) return + return ( + + + + ) +}} /> +``` + +**Rules:** Never overlap data. Use `position: "insideTopRight"/"insideTopLeft"` on labels. Pair annotations with tooltips — annotation names the event, tooltip shows the value.