{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "charts-1",
  "title": "Charts 1: analytics overview",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An analytics overview: a range picker, four figures with their change from the period before, a stacked area chart of new and returning visitors, top pages and a sources donut, each with a table view and CSV download.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/chart-area.json",
    "https://ui.ballmac.com/r/chart-bar.json",
    "https://ui.ballmac.com/r/chart-pie.json",
    "https://ui.ballmac.com/r/kpi-row.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/charts-1/charts-1.tsx",
      "content": "// Ballmac UI: Charts 1. https://ui.ballmac.com/blocks/charts-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, ArrowUp, ChartNoAxesColumn } from \"lucide-react\"\n\nimport { ChartArea } from \"@/components/ballmac/chart-area\"\nimport { ChartBar } from \"@/components/ballmac/chart-bar\"\nimport { ChartPie } from \"@/components/ballmac/chart-pie\"\nimport { KpiItem, KpiRow } from \"@/components/ballmac/kpi-row\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Charts1Day = { date: string; newVisitors: number; returning: number; signups: number }\ntype Charts1Page = { page: string; views: number }\ntype Charts1Source = { name: string; label: string; value: number }\n\ntype Charts1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    description?: string\n    /** One row per day, oldest first, as ISO dates (2026-07-01). At least twice the longest range, so each range has a period to compare with. */\n    daily?: Charts1Day[]\n    /** Most viewed pages in the range. */\n    pages?: Charts1Page[]\n    /** Where visitors came from in the range. */\n    sources?: Charts1Source[]\n    /** The range shown at first: 7, 30 or 90 days. */\n    defaultRange?: \"7\" | \"30\" | \"90\"\n    /** Called when the range changes, so you can load that range's pages and sources. */\n    onRangeChange?: (range: \"7\" | \"30\" | \"90\") => void\n    locale?: string\n  }\n\n// Sample data, worked out from the day number so the server and the browser agree.\nconst start = Date.UTC(2026, 3, 1)\nconst sampleDaily: Charts1Day[] = Array.from({ length: 180 }, (_, i) => {\n  const date = new Date(start + i * 86_400_000)\n  const weekend = [0, 6].includes(date.getUTCDay())\n  const newVisitors = Math.round(300 + i * 2.6 + Math.sin(i / 3.1) * 42 - (weekend ? 70 : 0))\n  const returning = Math.round(190 + i * 1.7 + Math.cos(i / 4.3) * 31 + (weekend ? 18 : 0))\n  return { date: date.toISOString().slice(0, 10), newVisitors, returning, signups: Math.round((newVisitors + returning) * (0.034 + Math.sin(i / 9) * 0.004)) }\n})\nconst samplePages: Charts1Page[] = [\n  { page: \"/pricing\", views: 18420 },\n  { page: \"/docs/get-started\", views: 15310 },\n  { page: \"/blocks\", views: 12980 },\n  { page: \"/components/button\", views: 9640 },\n  { page: \"/themes\", views: 7220 },\n]\nconst sampleSources: Charts1Source[] = [\n  { name: \"search\", label: \"Search\", value: 4210 },\n  { name: \"direct\", label: \"Direct\", value: 2890 },\n  { name: \"social\", label: \"Social\", value: 1630 },\n  { name: \"email\", label: \"Email\", value: 980 },\n]\n\nconst sum = (rows: Charts1Day[], pick: (d: Charts1Day) => number) => rows.reduce((total, d) => total + pick(d), 0)\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6\">\n      <Skeleton className=\"h-24 rounded-xl\" />\n      <Skeleton className=\"aspect-[2/1] w-full rounded-xl\" />\n      <div className=\"grid gap-6 lg:grid-cols-2\">\n        <Skeleton className=\"h-72 rounded-xl\" />\n        <Skeleton className=\"h-72 rounded-xl\" />\n      </div>\n    </div>\n  )\n}\n\n/**\n * An analytics overview: a range picker, four figures with their change from the period before, an area chart of new and returning visitors, the\n * top pages and where visitors came from. Every figure is worked out from the daily rows, and each chart has a table view and a CSV download.\n */\nfunction Charts1({\n  title = \"Analytics\",\n  description = \"How many people visit, where they come from and what they read.\",\n  daily = sampleDaily,\n  pages = samplePages,\n  sources = sampleSources,\n  defaultRange = \"30\",\n  onRangeChange,\n  locale = \"en\",\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Charts1Props) {\n  const id = React.useId()\n  const [range, setRange] = React.useState<\"7\" | \"30\" | \"90\">(defaultRange)\n  const days = Number(range)\n  const rows = state === \"empty\" ? [] : daily\n  const current = rows.slice(-days)\n  const before = rows.slice(-days * 2, -days)\n  const n = React.useMemo(() => new Intl.NumberFormat(locale), [locale])\n  const pct = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 }), [locale])\n  const change = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1, signDisplay: \"exceptZero\" }), [locale])\n  const day = React.useMemo(() => new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\", timeZone: \"UTC\" }), [locale])\n\n  const figures = React.useMemo(() => {\n    const visitors = (d: Charts1Day) => d.newVisitors + d.returning\n    const figs = [\n      { label: \"Visitors\", now: sum(current, visitors), prev: sum(before, visitors), write: (v: number) => n.format(v) },\n      { label: \"Sign-ups\", now: sum(current, (d) => d.signups), prev: sum(before, (d) => d.signups), write: (v: number) => n.format(v) },\n      { label: \"Conversion\", now: sum(current, (d) => d.signups) / (sum(current, visitors) || 1), prev: sum(before, (d) => d.signups) / (sum(before, visitors) || 1), write: (v: number) => pct.format(v) },\n      { label: \"Returning\", now: sum(current, (d) => d.returning) / (sum(current, visitors) || 1), prev: sum(before, (d) => d.returning) / (sum(before, visitors) || 1), write: (v: number) => pct.format(v) },\n    ]\n    return figs.map((f) => ({ ...f, delta: before.length && f.prev ? (f.now - f.prev) / f.prev : null }))\n  }, [current, before, n, pct])\n\n  const content = !current.length ? null : (\n    <div className=\"grid gap-6\">\n      <KpiRow columns={4} className=\"w-full\">\n        {figures.map((f) => (\n          <KpiItem\n            key={f.label}\n            label={f.label}\n            value={f.write(f.now)}\n            detail={\n              f.delta === null ? (\n                \"No earlier period to compare\"\n              ) : (\n                <span className=\"inline-flex items-center gap-1\">\n                  {f.delta >= 0 ? <ArrowUp aria-hidden=\"true\" className=\"size-3.5 text-chart-2\" /> : <ArrowDown aria-hidden=\"true\" className=\"text-destructive size-3.5\" />}\n                  <span className=\"text-foreground font-medium tabular-nums\">{change.format(f.delta)}</span>\n                  <span className=\"sr-only\">{f.delta >= 0 ? \" up\" : \" down\"}</span> vs the previous {days} days\n                </span>\n              )\n            }\n          />\n        ))}\n      </KpiRow>\n\n      <ChartArea\n        title=\"Visitors\"\n        description={`New and returning, the last ${days} days`}\n        data={current}\n        xKey=\"date\"\n        categoryLabel=\"Day\"\n        categoryFormatter={(v) => day.format(new Date(`${String(v)}T00:00:00Z`))}\n        locale={locale}\n        stack=\"stacked\"\n        fillMode=\"gradient\"\n        aspect=\"panorama\"\n        series={[\n          { key: \"newVisitors\", label: \"New\" },\n          { key: \"returning\", label: \"Returning\", color: \"var(--chart-3)\" },\n        ]}\n      />\n\n      <div className=\"grid grid-cols-1 gap-6 lg:grid-cols-2 [&>*]:min-w-0\">\n        <ChartBar\n          title=\"Top pages\"\n          description=\"Views in the range\"\n          data={pages}\n          xKey=\"page\"\n          categoryLabel=\"Page\"\n          layout=\"bars\"\n          labels\n          valueAxis={false}\n          grid=\"none\"\n          sort=\"descending\"\n          locale={locale}\n          series={[{ key: \"views\", label: \"Views\" }]}\n          aspect=\"wide\"\n        />\n        <ChartPie title=\"Where visitors come from\" description=\"Share of visits\" data={sources} valueLabel=\"visits\" categoryLabel=\"Source\" locale={locale} aspect=\"video\" />\n      </div>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"charts-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8 flex flex-wrap items-end justify-between gap-4\">\n        <div>\n          <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n            {title}\n          </h1>\n          <p className=\"text-muted-foreground mt-2 text-pretty\">{description}</p>\n        </div>\n        {state === \"ready\" ? (\n          <SegmentedControl\n            aria-label=\"Range\"\n            value={range}\n            onValueChange={(v) => {\n              setRange(v as \"7\" | \"30\" | \"90\")\n              onRangeChange?.(v as \"7\" | \"30\" | \"90\")\n            }}\n          >\n            <SegmentedControlItem value=\"7\">7 days</SegmentedControlItem>\n            <SegmentedControlItem value=\"30\">30 days</SegmentedControlItem>\n            <SegmentedControlItem value=\"90\">90 days</SegmentedControlItem>\n          </SegmentedControl>\n        ) : null}\n      </header>\n      <StateView\n        state={state === \"empty\" || !current.length ? \"empty\" : state === \"loading\" || state === \"error\" ? state : \"ready\"}\n        onRetry={onRetry}\n        label=\"Loading analytics\"\n        loading={<Skel />}\n        empty={{ icon: <ChartNoAxesColumn />, title: emptyState?.title ?? \"No visits yet\", description: emptyState?.description ?? \"Once people visit, their numbers and charts appear here.\", action: emptyState?.action }}\n        error={{ title: errorState?.title ?? \"Analytics could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}\n      >\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Charts1, type Charts1Props, type Charts1Day, type Charts1Page, type Charts1Source }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/charts-1/charts-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "analytics",
      "charts",
      "dashboard",
      "traffic",
      "visitors",
      "kpi",
      "area",
      "donut",
      "metrics",
      "range"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "<Charts1 title description daily={[{ date, newVisitors, returning, signups }]} pages sources defaultRange='30' onRangeChange state />. daily is one row per day, oldest first, at least twice the longest range. Figures (visitors, sign-ups, conversion, returning share) and their change from the previous period are worked out from daily. Pages and sources are what you pass; reload them in onRangeChange.",
      "whenToUse": [
        "The overview page of a product or marketing site's analytics",
        "A starting point for any metric dashboard with a range picker"
      ],
      "whenNotToUse": [
        "A funnel (use funnel-1)",
        "Cohort retention (use retention-1)",
        "One chart only (use chart-area, chart-bar or chart-pie)"
      ],
      "composesWith": [
        "app-shell-1",
        "funnel-1",
        "retention-1",
        "chart-frame"
      ],
      "a11y": [
        {
          "keys": "Range",
          "action": "A labelled switch between 7, 30 and 90 days; the figures, change and chart follow"
        },
        {
          "keys": "Change",
          "action": "Written as 'up 12.4% from the previous 30 days', not only an arrow and a color"
        },
        {
          "keys": "Each chart",
          "action": "Has a Table view of the same numbers, a CSV download and a summary written from its data"
        }
      ],
      "customization": [
        "Pass daily rows from your API; the figures follow",
        "onRangeChange to load that range's pages and sources",
        "Swap any chart for another chart-* component"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "charts-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeletons shaped like the figures and charts, under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No visits yet",
        "example": "charts-1-empty",
        "trigger": "state=\"empty\" (or daily={[]})",
        "note": "Says the numbers appear once people visit; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "charts-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "charts-1-demo",
      "charts-1-loading",
      "charts-1-empty",
      "charts-1-error"
    ],
    "url": "https://ui.ballmac.com/components/charts-1"
  },
  "categories": [
    "blocks",
    "charts"
  ],
  "type": "registry:block"
}