makersclaw 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +56 -9
  2. package/package.json +1 -1
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +6 -6
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +10 -4
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +126 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +3 -3
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +24 -12
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chart/cartesian.jsx +1 -1
  67. package/registry/blocks/chart/core.jsx +33 -23
  68. package/registry/blocks/chart/pie.jsx +1 -1
  69. package/registry/blocks/chat/README.md +47 -14
  70. package/registry/blocks/chat/demo.jsx +2 -2
  71. package/registry/blocks/code-editor/README.md +4 -0
  72. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  73. package/registry/blocks/comment-box/README.md +20 -13
  74. package/registry/blocks/contact-section/README.md +7 -6
  75. package/registry/blocks/csv-import/README.md +34 -12
  76. package/registry/blocks/data-table/README.md +41 -8
  77. package/registry/blocks/diagram/diagram.jsx +1 -1
  78. package/registry/blocks/doc-editor/README.md +18 -6
  79. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  80. package/registry/blocks/doc-page/README.md +16 -6
  81. package/registry/blocks/file-upload/README.md +7 -1
  82. package/registry/blocks/flow-canvas/README.md +30 -12
  83. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  84. package/registry/blocks/footer/README.md +7 -4
  85. package/registry/blocks/gantt/README.md +18 -12
  86. package/registry/blocks/job-panel/README.md +1 -1
  87. package/registry/blocks/json-editor/README.md +16 -4
  88. package/registry/blocks/map/README.md +19 -3
  89. package/registry/blocks/map/map-view.jsx +1 -1
  90. package/registry/blocks/map/maplibre.js +1 -1
  91. package/registry/blocks/mind-map/README.md +20 -7
  92. package/registry/blocks/org-chart/README.md +14 -9
  93. package/registry/blocks/page-builder/README.md +14 -7
  94. package/registry/blocks/page-builder/demo.jsx +3 -3
  95. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  96. package/registry/blocks/page-builder/page-view.jsx +1 -1
  97. package/registry/blocks/page-builder/page.js +1 -1
  98. package/registry/blocks/pdf-viewer/README.md +26 -21
  99. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  100. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  101. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  102. package/registry/blocks/product-detail/README.md +13 -7
  103. package/registry/blocks/product-detail/demo.jsx +1 -1
  104. package/registry/blocks/product-grid/README.md +12 -10
  105. package/registry/blocks/record-form/README.md +26 -9
  106. package/registry/blocks/record-notes/README.md +42 -30
  107. package/registry/blocks/search-dialog/README.md +38 -7
  108. package/registry/blocks/settings-list/README.md +29 -17
  109. package/registry/blocks/setup-checklist/README.md +12 -14
  110. package/registry/blocks/share-dialog/README.md +1 -1
  111. package/registry/blocks/sidebar/README.md +23 -13
  112. package/registry/blocks/signature-pad/README.md +15 -4
  113. package/registry/blocks/sketch-canvas/README.md +25 -10
  114. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  115. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  116. package/registry/blocks/spreadsheet/README.md +17 -8
  117. package/registry/blocks/stat-cards/README.md +17 -8
  118. package/registry/blocks/task-list/README.md +14 -11
  119. package/registry/blocks/task-model/README.md +3 -3
  120. package/registry/blocks/transaction-list/README.md +15 -7
  121. package/registry/blocks/whiteboard/README.md +34 -22
  122. package/src/cli.mjs +33 -6
  123. package/src/install.mjs +285 -26
  124. package/src/registry.mjs +109 -1
  125. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  126. package/templates/next/browser/app/layout.jsx +94 -0
  127. package/templates/next/browser/app/page.jsx +60 -0
  128. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  129. package/templates/next/browser/lib/actions.js +16 -0
  130. package/templates/next/browser/lib/data.js +43 -0
  131. package/templates/next/files/README.md +1100 -89
  132. package/templates/next/files/app/frame.js +128 -4
  133. package/templates/next/files/app/shell.jsx +54 -37
  134. package/templates/next/files/check/run.mjs +1514 -45
  135. package/templates/next/files/components/ui/index.js +1 -0
  136. package/templates/next/files/components/ui/item.jsx +2 -2
  137. package/templates/next/files/components/ui/layout.jsx +307 -0
  138. package/templates/next/files/components/ui/notice.jsx +1 -1
  139. package/templates/next/files/components/ui/separator.jsx +1 -1
  140. package/templates/next/files/lib/actions.js +170 -51
  141. package/templates/next/files/lib/brain-server.js +96 -0
  142. package/templates/next/files/lib/brain.js +413 -0
  143. package/templates/next/files/lib/claw.js +15 -1
  144. package/templates/next/files/lib/data.js +119 -29
  145. package/templates/next/files/lib/db.js +97 -17
  146. package/templates/next/files/lib/door.js +680 -0
  147. package/templates/next/files/lib/hooks.js +71 -2
  148. package/templates/next/files/lib/platform.js +93 -4
  149. package/templates/next/files/next.config.mjs +36 -2
  150. package/templates/next/files/surfaces.js +3 -2
  151. package/templates/next/template.json +18 -3
  152. package/templates/next/files/schema.sql +0 -22
@@ -5,8 +5,8 @@
5
5
  "title": "Calendar",
6
6
  "description": "Tasks on a calendar, like Google Calendar, Cron or Notion Calendar: month, week, day and agenda views over task-model's work items. Month shows all-day tasks and spans as tinted bars across their days (stacked in lanes, \"2 more\" past three) and timed ones as a dot and a time; week and day show an all-day strip over an hour grid where timed tasks are blocks placed by their times, overlapping ones side by side, with a line at now. Drag an item to another day or hour (it snaps to the quarter hour and the day columns as it moves), drag its bottom or end to change how long it runs, click an empty day or hour (or drag across hours) to type a new task in place, drag tasks without a date in from an unscheduled tray (or drop one there to take its date off). Arrow keys move a focused block, Shift+arrows resize it, Delete removes with Undo; T, J/K, M/W/D/A and C for today, next/previous, the views and a new task. Search and filters from the shared ViewToolbar, a colour per project or status, a real phone layout (a dotted month with the tapped day listed, a three-day week, a sheet for the tray). An available-time layer (closed hours muted, open slots as boxes you pick, from `availability`, `slots` or an async `getSlots`, with pure `freeSlots` / `availabilityOn` helpers) is what a booking page builds on. One async onChange saves every change; failures roll back with a notice.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/task-model.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/task-model.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -68,7 +68,7 @@
68
68
  "path": "registry/blocks/calendar-view/README.md",
69
69
  "type": "registry:file",
70
70
  "target": "@components/makersclaw/blocks/calendar-view/README.md",
71
- "content": "# Calendar\n\nTasks on a calendar, the way Google Calendar, Cron and Notion Calendar\ndraw them. **Month** shows all-day tasks and multi-day spans as tinted bars\nacross their days (stacked, never on top of each other; past three a day\nsays \"2 more\") and timed tasks as a dot, a time and a title. **Week** and\n**Day** show an all-day strip over an hour grid: timed tasks are blocks\nplaced by their times, overlapping ones side by side, and a line marks\nnow. **Agenda** lists the days ahead with a round check to complete each\nitem.\n\nDrag an item to another day or hour — a block snaps to the quarter hour\nand to the day columns as it moves. Drag a block's bottom edge (or a\nbar's end) to change how long it runs. Click an empty day or hour, or drag\nacross a few hours, and type the new task in place. Tasks with no date\nwait in the **unscheduled tray** beside the calendar: drag one onto a day\nor an hour to schedule it, or drop a scheduled one there to take its date\noff. Search and filters come from task-model's toolbar, so the calendar\nfilters exactly like the list.\n\nThe **available-time layer** — closed hours muted, open slots as boxes\nyou can pick — is what a booking page is built on (`availability`,\n`slots` / `getSlots`, `onSlotClick`, and the pure `freeSlots`).\n\nEvery change saves through one async `onChange(change)` and shows at\nonce; when the save fails the calendar puts it back and says why.\n\n```jsx\nimport { CalendarView } from \"../components/blocks/calendar-view\"; // a Makersclaw app (\"./blocks/calendar-view\" from components/)\nimport { CalendarView } from \"@/components/makersclaw/blocks/calendar-view\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the tasks in the server surface (README §4). It is built on\n`task-model` (added with it): the task shape, the change shapes and the\nhelpers are documented there.\n\n## The data\n\nTasks in task-model's shape. Where a task lands is read from `start` and\n`due` (wall-calendar strings, no time zone):\n\n| The task has | It shows as |\n| --- | --- |\n| `due: \"2026-09-24\"` | an all-day item on the 24th |\n| `start: \"2026-09-22\", due: \"2026-09-24\"` | a bar across the 22nd–24th |\n| `start: \"…T09:30\", due: \"…T11:00\"` (same day) | a 9:30–11:00 block |\n| `start: \"…T09:30\"` only | a block running its `estimate` (hours), else `defaultDuration` |\n| `due: \"…T17:00\"` only | a deadline: a half-hour block at 5 PM, \"Due 5:00 PM\" |\n| neither | the unscheduled tray (open tasks only) |\n\nA drag saves only `start` and `due` (a move keeps the length and the\ntimes; dropping a timed block on the all-day strip saves `start: null`;\ndropping on the tray saves both as `null`). A new task from a click is\n`draftTask(title, ctx, { due })` or `{ start, due }`.\n\nA task with `locked: true` stays where it is: no drag, resize, arrow\nkeys, Delete or done tick for it, and it still opens (`onOpen`). For\nwhat the app shows but did not make — events mirrored from the person's\ncalendar — beside what it may move.\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing.\n- `statuses` · `people` · `projects` · `fields` — as task-list and\n task-model take them. A project's `hue` (1–5) colours its items.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app,\n loop over `changeWrites(change)` (task-model README, \"A change\").\n Without it the calendar only shows: no drag, no quick-add, no checks.\n- `canCreate` — `false` keeps every change to the tasks there are and\n takes away every way to add one: a click or a drag across an empty\n day or hour, C, the Add buttons (default `true`; with `onChange`).\n- `onOpen(task)` — clicking an item (or Enter on it) opens your `Sheet`.\n- `mode` · `defaultMode` (`\"month\"`) · `onModeChange(mode)` — month ·\n week · day · agenda. `modes` — which ones the switcher offers (default\n all four; one hides the switcher).\n- `date` · `defaultDate` (today) · `onDateChange(day)` — the day the\n view is around.\n- `view` · `defaultView` · `onViewChange(view)` — task-model's view\n (search, filters, hideDone); grouping and sort do not apply here.\n- `today` — \"YYYY-MM-DD\"; fix it in a demo. `now` — \"YYYY-MM-DDTHH:mm\"\n for the now line; default the browser's clock (after mount).\n- `weekStartsOn` (1, Monday) · `weekend` (`[0, 6]`, muted) · `holidays`\n · `locale` (the browser's) — dates, hours and headings follow it.\n- `colorBy` — `\"project\"` (default) · `\"status\"` (the status's tone).\n- `tray` (true) — the unscheduled tray: a column beside the calendar\n when it is 960px wide or more (the toolbar's \"Unscheduled\" button\n folds it), a sheet with a date picker per task below that.\n- `header` (true) — the title, Today, arrows and the view switcher.\n `toolbar` (true) — search, filter, \"Unscheduled\", \"Add task\";\n `toolbarExtra` — your buttons at its end. `addLabel` (\"Add task\").\n- `hourHeight` (48) — pixels per hour. `height` (\"40rem\") — the hour\n grid's scrolling height. `snap` (15) — minutes a drag snaps to.\n `defaultDuration` (60) — minutes of a new block. `agendaDays` (14).\n- `loading` — a skeleton month. `error` — a read error, as a Notice.\n- `label` (\"Calendar\") — the region's name.\n\n### The available-time layer (week and day)\n\n- `availability` — the open hours; everything else is drawn muted:\n `{ days: [1,2,3,4,5], from: \"09:00\", to: \"17:00\" }`, or per weekday\n `{ weekly: { 1: [[\"09:00\",\"12:00\"], [\"13:00\",\"17:00\"]], … } }`, either\n with `dates: { \"2026-12-24\": [[\"09:00\",\"12:00\"]], \"2026-12-25\": [] }`,\n or a function `(day) → [[\"09:00\",\"17:00\"]]`.\n- `slots` — open times to show: `[{ start: \"2026-09-24T10:00\", end:\n \"2026-09-24T10:30\", id?, …yours }]`.\n- `getSlots({ from, to })` — the same, fetched for the days on screen\n (may be async; called again when the range changes). Pass a stable\n function (`useCallback`); a new one fetches again.\n- `onSlotClick(slot)` — slots become buttons; `selectedSlot` (a slot's\n `id` or `start`) fills the picked one. `slotLabel` (\"Open\") is what a\n screen reader says before the time. `renderSlot(slot, { selected })`\n draws a slot's inside.\n\nA booking page is the calendar with `modes={[\"week\", \"day\"]}`,\n`toolbar={false}`, `tray={false}`, the host's meetings as `tasks`, and:\n\n```js\nconst getSlots = useCallback(({ from, to }) =>\n freeSlots({ from, to, availability: HOURS, busy: meetings, minutes: 30, buffer: 10, notice: 120, now }), [meetings, now]);\n```\n\n### Helpers (pure, from the same import)\n\n- `freeSlots({ from, to, availability, busy, minutes = 30, step, buffer, notice, now })`\n → `[{ id, day, start, end, from, to }]` — `busy` mixes tasks (their\n timed blocks; all-day items do not take time) and `{ start, end }`\n intervals; `buffer` minutes kept free around each; nothing before\n `now` + `notice` minutes. `availabilityOn(day, availability)` /\n `closedOn(day, availability)` → `[[from, to], …]` minutes.\n- `eventOf(task)` → `{ id, task, timed, start, end, from, to, deadline, startOnly }`\n or null; `eventsOf(tasks)` · `unscheduledOf(tasks)`.\n- `layoutTimed(events)` → Map id → `{ col, cols, span }` (side by side);\n `layoutLanes(events, days)` → `{ items: [{ event, lane, col, span, clippedStart, clippedEnd }], lanes }`.\n- The patches a gesture saves: `moveEventPatch(event, day, from)` ·\n `resizeEventPatch(event, to)` · `placeEventPatch(event, day, from, to)` ·\n `timedPatch(day, from, minutes)` · `dayPatch(task, day, { grabbed })` ·\n `allDayPatch(day)` · `extendPatch(event, endDay)` · `UNSCHEDULE`.\n- `rangeFor(mode, anchor, { weekStartsOn, count, agendaDays })` ·\n `stepAnchor` · `titleFor` · `rangeLabel` · `hourLabel` ·\n `minutesLabel` · `eventTimeLabel` · `snapMinutes` · `durationOf` ·\n `CALENDAR_MODES` · `MODE_LABELS` · `DAY_MINUTES`.\n\n### Keyboard\n\n- A focused block: ↑/↓ moves it 15 minutes, ←/→ a day, Shift+↑/↓\n changes its length (saved once the keys rest); Enter opens it;\n Delete removes it, with Undo in the toast.\n- A focused bar or month item: Space picks it up, the arrows move it\n between days, Space drops it, Escape puts it back; Shift+←/→ changes\n a bar's last day.\n- Anywhere in the calendar: T today · J / K next / previous · M W D A\n the views · C a new task.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { Sheet } from \"./ui\";\nimport { CalendarView } from \"./blocks/calendar-view\";\n\nexport default function Schedule({ tasks, people, projects, onChange }) {\n const [open, setOpen] = useState(null);\n return (\n <>\n <CalendarView tasks={tasks} people={people} projects={projects} onChange={onChange} onOpen={setOpen} defaultMode=\"week\" />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>…</Sheet>\n </>\n );\n}\n```\n\n## What it will not do\n\n- Recurring events, time zones, or events that are not tasks: a repeat\n is the page's to create; a time is wall time.\n- Timed blocks that cross midnight: a task over several days is an\n all-day bar.\n- Fetch or save: tasks come in as props, changes go out through\n `onChange`; slots come from `slots` or your `getSlots`.\n- Take a booking: `onSlotClick` hands you the slot; the form and the\n write are the page's (booking-page).\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw\nadd calendar-view` restores them.\n"
71
+ "content": "# Calendar\n\nTasks on a calendar, the way Google Calendar, Cron and Notion Calendar\ndraw them. **Month** shows all-day tasks and multi-day spans as tinted bars\nacross their days (stacked, never on top of each other; past three a day\nsays \"2 more\") and timed tasks as a dot, a time and a title. **Week** and\n**Day** show an all-day strip over an hour grid: timed tasks are blocks\nplaced by their times, overlapping ones side by side, and a line marks\nnow. **Agenda** lists the days ahead with a round check to complete each\nitem.\n\nDrag an item to another day or hour — a block snaps to the quarter hour\nand to the day columns as it moves. Drag a block's bottom edge (or a\nbar's end) to change how long it runs. Click an empty day or hour, or drag\nacross a few hours, and type the new task in place. Tasks with no date\nwait in the **unscheduled tray** beside the calendar: drag one onto a day\nor an hour to schedule it, or drop a scheduled one there to take its date\noff. Search and filters come from task-model's toolbar, so the calendar\nfilters exactly like the list.\n\nThe **available-time layer** — closed hours muted, open slots as boxes\nyou can pick — is what a booking page is built on (`availability`,\n`slots` / `getSlots`, `onSlotClick`, and the pure `freeSlots`).\n\nEvery change saves through one async `onChange(change)` and shows at\nonce; when the save fails the calendar puts it back and says why.\n\n```jsx\nimport { CalendarView } from \"../components/blocks/calendar-view\"; // a Makersclaw app (\"./blocks/calendar-view\" from components/)\nimport { CalendarView } from \"@/components/makersclaw/blocks/calendar-view\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the tasks in the server surface (README §4). It is built on\n`task-model` (added with it): the task shape, the change shapes and the\nhelpers are documented there.\n\n## The data\n\nTasks in task-model's shape. Where a task lands is read from `start` and\n`due` (wall-calendar strings, no time zone):\n\n| The task has | It shows as |\n| --- | --- |\n| `due: \"2026-09-24\"` | an all-day item on the 24th |\n| `start: \"2026-09-22\", due: \"2026-09-24\"` | a bar across the 22nd–24th |\n| `start: \"…T09:30\", due: \"…T11:00\"` (same day) | a 9:30–11:00 block |\n| `start: \"…T09:30\"` only | a block running its `estimate` (hours), else `defaultDuration` |\n| `due: \"…T17:00\"` only | a deadline: a half-hour block at 5 PM, \"Due 5:00 PM\" |\n| neither | the unscheduled tray (open tasks only) |\n\nA drag saves only `start` and `due` (a move keeps the length and the\ntimes; dropping a timed block on the all-day strip saves `start: null`;\ndropping on the tray saves both as `null`). A new task from a click is\n`draftTask(title, ctx, { due })` or `{ start, due }`.\n\nA task with `locked: true` stays where it is: no drag, resize, arrow\nkeys, Delete or done tick for it, and it still opens (`onOpen`). For\nwhat the app shows but did not make — events mirrored from the person's\ncalendar — beside what it may move.\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing.\n- `statuses` · `people` · `projects` · `fields` — as task-list and\n task-model take them. A project's `hue` (1–5) colours its items.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app,\n loop over `changeWrites(change)` (task-model README, \"A change\").\n Without it the calendar only shows: no drag, no quick-add, no checks.\n- `canCreate` — `false` keeps every change to the tasks there are and\n takes away every way to add one: a click or a drag across an empty\n day or hour, C, the Add buttons (default `true`; with `onChange`).\n- `onOpen(task)` — clicking an item (or Enter on it) opens your `Sheet`.\n- `mode` · `defaultMode` (`\"month\"`) · `onModeChange(mode)` — month ·\n week · day · agenda. `modes` — which ones the switcher offers (default\n all four; one hides the switcher).\n- `date` · `defaultDate` (today) · `onDateChange(day)` — the day the\n view is around.\n- `view` · `defaultView` · `onViewChange(view)` — task-model's view\n (search, filters, hideDone); grouping and sort do not apply here.\n- `today` — \"YYYY-MM-DD\"; fix it in a demo. `now` — \"YYYY-MM-DDTHH:mm\"\n for the now line; default the browser's clock (after mount).\n- `weekStartsOn` (1, Monday) · `weekend` (`[0, 6]`, muted) · `holidays`\n · `locale` (the browser's) — dates, hours and headings follow it.\n- `colorBy` — `\"project\"` (default) · `\"status\"` (the status's tone).\n- `tray` (true) — the unscheduled tray: a column beside the calendar\n when it is 960px wide or more (the toolbar's \"Unscheduled\" button\n folds it), a sheet with a date picker per task below that.\n- `header` (true) — the title, Today, arrows and the view switcher.\n `toolbar` (true) — search, filter, \"Unscheduled\", \"Add task\";\n `toolbarExtra` — your buttons at its end. `addLabel` (\"Add task\").\n- `hourHeight` (48) — pixels per hour. `height` (\"40rem\") — the hour\n grid's scrolling height. `snap` (15) — minutes a drag snaps to.\n `defaultDuration` (60) — minutes of a new block. `agendaDays` (14).\n- `loading` — a skeleton month. `error` — a read error, as a Notice.\n- `label` (\"Calendar\") — the region's name.\n\n### The available-time layer (week and day)\n\n- `availability` — the open hours; everything else is drawn muted:\n `{ days: [1,2,3,4,5], from: \"09:00\", to: \"17:00\" }`, or per weekday\n `{ weekly: { 1: [[\"09:00\",\"12:00\"], [\"13:00\",\"17:00\"]], … } }`, either\n with `dates: { \"2026-12-24\": [[\"09:00\",\"12:00\"]], \"2026-12-25\": [] }`,\n or a function `(day) → [[\"09:00\",\"17:00\"]]`.\n- `slots` — open times to show: `[{ start: \"2026-09-24T10:00\", end:\n \"2026-09-24T10:30\", id?, …yours }]`.\n- `getSlots({ from, to })` — the same, fetched for the days on screen\n (may be async; called again when the range changes). Pass a stable\n function (`useCallback`); a new one fetches again.\n- `onSlotClick(slot)` — slots become buttons; `selectedSlot` (a slot's\n `id` or `start`) fills the picked one. `slotLabel` (\"Open\") is what a\n screen reader says before the time. `renderSlot(slot, { selected })`\n draws a slot's inside.\n\nA booking page is the calendar with `modes={[\"week\", \"day\"]}`,\n`toolbar={false}`, `tray={false}`, the host's meetings as `tasks`, and:\n\n```js\nconst getSlots = useCallback(({ from, to }) =>\n freeSlots({ from, to, availability: HOURS, busy: meetings, minutes: 30, buffer: 10, notice: 120, now }), [meetings, now]);\n```\n\n### Helpers (pure, from the same import)\n\n- `freeSlots({ from, to, availability, busy, minutes = 30, step, buffer, notice, now })`\n → `[{ id, day, start, end, from, to }]` — `busy` mixes tasks (their\n timed blocks; all-day items do not take time) and `{ start, end }`\n intervals; `buffer` minutes kept free around each; nothing before\n `now` + `notice` minutes. `availabilityOn(day, availability)` /\n `closedOn(day, availability)` → `[[from, to], …]` minutes.\n- `eventOf(task)` → `{ id, task, timed, start, end, from, to, deadline, startOnly }`\n or null; `eventsOf(tasks)` · `unscheduledOf(tasks)`.\n- `layoutTimed(events)` → Map id → `{ col, cols, span }` (side by side);\n `layoutLanes(events, days)` → `{ items: [{ event, lane, col, span, clippedStart, clippedEnd }], lanes }`.\n- The patches a gesture saves: `moveEventPatch(event, day, from)` ·\n `resizeEventPatch(event, to)` · `placeEventPatch(event, day, from, to)` ·\n `timedPatch(day, from, minutes)` · `dayPatch(task, day, { grabbed })` ·\n `allDayPatch(day)` · `extendPatch(event, endDay)` · `UNSCHEDULE`.\n- `rangeFor(mode, anchor, { weekStartsOn, count, agendaDays })` ·\n `stepAnchor` · `titleFor` · `rangeLabel` · `hourLabel` ·\n `minutesLabel` · `eventTimeLabel` · `snapMinutes` · `durationOf` ·\n `CALENDAR_MODES` · `MODE_LABELS` · `DAY_MINUTES`.\n\n### Keyboard\n\n- A focused block: ↑/↓ moves it 15 minutes, ←/→ a day, Shift+↑/↓\n changes its length (saved once the keys rest); Enter opens it;\n Delete removes it, with Undo in the toast.\n- A focused bar or month item: Space picks it up, the arrows move it\n between days, Space drops it, Escape puts it back; Shift+←/→ changes\n a bar's last day.\n- Anywhere in the calendar: T today · J / K next / previous · M W D A\n the views · C a new task.\n\n## Example\n\n```jsx\n// surfaces/schedule.jsx — client: the tasks on a calendar\n\"use client\";\nimport { useState } from \"react\";\nimport { useRows } from \"../lib/data\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { Sheet } from \"../components/ui\";\nimport { CalendarView } from \"../components/blocks/calendar-view\";\nimport { changeWrites } from \"../components/blocks/task-model\";\n\nconst COLUMNS = [\"id\", \"title\", \"status\", \"assignees\", \"start\", \"due\", \"project\"];\n\n// One change of the calendar's, as the rows it writes (task-list's README has the same).\nasync function save(change) {\n for (const w of changeWrites(change, { columns: COLUMNS, keepNewIds: false })) {\n const r =\n w.op === \"insert\" ? await insertRow({ source: \"tasks\", values: w.values, detail: w.values.title })\n : w.op === \"update\" ? await updateRow({ source: \"tasks\", id: w.id, patch: w.patch })\n : await deleteRow({ source: \"tasks\", id: w.id });\n if (r?.ok === false) return r;\n }\n return { ok: true };\n}\n\nexport default function Schedule() {\n const tasks = useRows({ source: \"tasks\", orderBy: \"start asc\" });\n const people = useRows({ source: \"people\" });\n const projects = useRows({ source: \"projects\" });\n const [open, setOpen] = useState(null);\n return (\n <>\n <CalendarView tasks={tasks.rows} people={people.rows} projects={projects.rows} onChange={save} onOpen={setOpen} defaultMode=\"week\"\n loading={tasks.loading || people.loading || projects.loading} error={tasks.error} />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>…</Sheet>\n </>\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Recurring events, time zones, or events that are not tasks: a repeat\n is the page's to create; a time is wall time.\n- Timed blocks that cross midnight: a task over several days is an\n all-day bar.\n- Fetch or save: tasks come in as props, changes go out through\n `onChange`; slots come from `slots` or your `getSlots`.\n- Take a booking: `onSlotClick` hands you the slot; the form and the\n write are the page's (booking-page).\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw\nadd calendar-view` restores them.\n"
72
72
  }
73
73
  ],
74
74
  "categories": [
@@ -98,7 +98,7 @@
98
98
  "docs": "Installed to components/makersclaw/blocks/calendar-view. Import: import { CalendarView, CALENDAR_MODES, MODE_LABELS, DAY_MINUTES, UNSCHEDULE, snapMinutes, durationOf, eventOf, eventsOf, unscheduledOf, layoutTimed, layoutLanes, moveEventPatch, resizeEventPatch, placeEventPatch, timedPatch, dayPatch, allDayPatch, extendPatch, rangeFor, stepAnchor, rangeLabel, titleFor, hourLabel, minutesLabel, eventTimeLabel, availabilityOn, closedOn, freeSlots, normalizeSlots } from \"@/components/makersclaw/blocks/calendar-view\". Props and an example: components/makersclaw/blocks/calendar-view/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
99
99
  "meta": {
100
100
  "makersclaw": {
101
- "version": "0.6.0",
101
+ "version": "0.8.0",
102
102
  "useWhen": "A screen where dated work or events are seen by day and moved around: a team's deadlines, a content calendar, a launch plan, shifts, a personal schedule, meetings on tasks, or the slot picker of a booking page (modes week/day, slots from getSlots). Not for dependencies over a long timeline (gantt), for undated work (task-list, board), or for a single date field (the kit's DatePicker)."
103
103
  }
104
104
  }
package/r/cart.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Cart",
6
6
  "description": "A shopping cart: line items with picture, option, quantity and remove, a summary (subtotal, discount code, shipping, tax, total) and Checkout. step=\"checkout\" is the checkout page: contact and delivery details beside the order summary, ending at onPay, which hands off to Paddle. No card fields. Empty and loading states built in.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -31,7 +31,7 @@
31
31
  "path": "registry/blocks/cart/README.md",
32
32
  "type": "registry:file",
33
33
  "target": "@components/makersclaw/blocks/cart/README.md",
34
- "content": "# Cart\n\nThe basket and the checkout page. The cart lists line items (picture,\nname, option, quantity, remove) beside a summary: subtotal, a discount\ncode, shipping, tax, the total and Checkout. `step=\"checkout\"` is the next\npage: the buyer's email and delivery address beside the same summary,\nread-only. It ends at `onPay`. **There are no card fields**: the app hands\noff to Paddle there, which takes the payment.\n\nEvery change goes out through a callback that returns `{ ok }`; a failed\none is shown on the row (or the summary) that asked for it.\n\n```jsx\nimport { Cart } from \"../components/blocks/cart\"; // a Makersclaw app (\"./blocks/cart\" from components/)\nimport { Cart } from \"@/components/makersclaw/blocks/cart\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the cart in the server surface (README §4).\n\n## The data\n\nOne line item per row of the cart:\n\n```js\n{ id: \"li_1\", name: \"Merino crew\", image: \"https://…\", price: 89, quantity: 2, variant: \"Charcoal · M\" }\n```\n\n- `price` is one unit's price, a number in the currency's major unit\n (89.5 is $89.50). The row shows `price × quantity`.\n- `variant` is the chosen option in words, or empty.\n- `maxQuantity` (optional) caps the quantity picker; default 10.\n- Optional: `key` (a line's own key when the same product sits on two\n lines, one per size; default `id`), `href` (the name links to the\n product), `compareAt` (the old unit price, struck through beside the\n line total), `currency` (over the `currency` prop).\n\nThe same shape is what `product-detail` and `product-grid` hand to their\nadd-to-cart callbacks, so a cart is those rows.\n\n## Props\n\n- `items` — the line items. An empty list shows the empty state.\n- `step` — `\"cart\"` (default) or `\"checkout\"`.\n- `currency` — an ISO code, default `\"USD\"`. `locale` — for the money\n format, default the reader's.\n- `shipping` · `tax` — a number is a row that adds to the total (0 reads\n \"Free\" for shipping); a string is shown as written and adds nothing\n (`tax=\"Calculated at payment\"` — Paddle works tax out). Leave one out\n and its row is not drawn.\n- `discount` — the code in force: `{ code, amount, label? }`; `amount` is\n taken off the total.\n- `note` — a line at the foot of the summary, beside a truck (\"Free\n delivery on orders over $100\").\n- `loading` — skeleton rows. `error` — a line above everything (the cart\n did not load).\n- `title` — the heading; default \"Cart\" or \"Checkout\".\n\nThe cart step:\n\n- `onQuantityChange(item, quantity)` — save the new quantity; return\n `{ ok: true }` or `{ ok: false, error }`. Without it the quantity is\n shown, not picked.\n- `onRemove(item)` — remove the row, same return. Without it no ×.\n- `onApplyCode(code)` — a discount code field appears; return\n `{ ok: false, error: \"That code has expired.\" }` to say why not. On\n success pass the new `discount` back in.\n- `onRemoveCode()` — an × beside the discount.\n- `onCheckout()` — the Checkout button; move to the checkout step (route\n or state). May return `{ ok: false, error }`.\n- `checkoutLabel` — default \"Checkout\".\n- `onContinueShopping()` or `continueHref` — \"Continue shopping\", in the\n summary and in the empty state. `continueLabel` renames it.\n- `emptyTitle` · `emptyBody` — the empty state's words.\n\nThe checkout step:\n\n- `onPay(details)` — required for checkout. `details` is `{ email }`, plus\n `{ name, address, city, postal, country }` when the address is asked\n for. Save the order, open Paddle's checkout with it, and return\n `{ ok: true }`; `{ ok: false, error }` shows the reason above the button.\n- `requireAddress` — default `true`; `false` for things that are not\n shipped (downloads, tickets): only the email is asked for.\n- `terms` — a checkbox label that must be ticked before paying (\"I agree\n to the terms of sale\"); a string or a node with links. Omit for none.\n- `defaultDetails` — prefill: `{ email, name, address, city, postal, country }`.\n- `payLabel` — the button; default \"Continue to payment · $123.00\".\n- `payNote` — the line under it; default says the card is entered on the\n next screen. `null` for none.\n- `onBack()` — \"Back to cart\". `backLabel` renames it.\n\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// components/Basket.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { deleteRow, updateRow } from \"../lib/actions\";\nimport { Cart } from \"./blocks/cart\";\n\nexport default function Basket({ items, error }) {\n const [step, setStep] = useState(\"cart\");\n return (\n <Cart\n items={items}\n error={error}\n step={step}\n shipping={0}\n tax=\"Calculated at payment\"\n note=\"Free delivery on every order this month.\"\n onQuantityChange={(item, quantity) => updateRow({ source: \"public.cart_items\", id: item.id, patch: { quantity } })}\n onRemove={(item) => deleteRow({ source: \"public.cart_items\", id: item.id })}\n onCheckout={() => setStep(\"checkout\")}\n onBack={() => setStep(\"cart\")}\n continueHref=\"/shop\"\n terms=\"I agree to the terms of sale\"\n onPay={async (details) => {\n const res = await fetch(\"/api/checkout\", { method: \"POST\", body: JSON.stringify(details) });\n if (!res.ok) return { ok: false, error: \"We could not start the payment. Try again.\" };\n const { transactionId } = await res.json();\n window.Paddle?.Checkout.open({ transactionId });\n return { ok: true };\n }}\n />\n );\n}\n```\n\n## What it will not do\n\n- Take a card number, expiry or CVC. Payment is Paddle's overlay or page,\n opened from `onPay`; the block never sees a card.\n- Work out tax or shipping: pass the numbers (or the words) in.\n- Keep the cart: `items` is the app's; every change is a callback.\n- Validate an address against a country's format: it checks that each\n field is filled.\n\nA cart it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add cart` restores them.\n\n## Credits\n\nShape and behaviour from Creative Tim UI (https://github.com/creativetimofficial/ui), MIT; this is a rewrite on the Makersclaw kit.\n"
34
+ "content": "# Cart\n\nThe basket and the checkout page. The cart lists line items (picture,\nname, option, quantity, remove) beside a summary: subtotal, a discount\ncode, shipping, tax, the total and Checkout. `step=\"checkout\"` is the next\npage: the buyer's email and delivery address beside the same summary,\nread-only. It ends at `onPay`. **There are no card fields**: the app hands\noff to Paddle there, which takes the payment.\n\nEvery change goes out through a callback that returns `{ ok }`; a failed\none is shown on the row (or the summary) that asked for it.\n\n```jsx\nimport { Cart } from \"../components/blocks/cart\"; // a Makersclaw app (\"./blocks/cart\" from components/)\nimport { Cart } from \"@/components/makersclaw/blocks/cart\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the cart in the server surface (README §4).\n\n## The data\n\nOne line item per row of the cart:\n\n```js\n{ id: \"li_1\", name: \"Merino crew\", image: \"https://…\", price: 89, quantity: 2, variant: \"Charcoal · M\" }\n```\n\n- `price` is one unit's price, a number in the currency's major unit\n (89.5 is $89.50). The row shows `price × quantity`.\n- `variant` is the chosen option in words, or empty.\n- `maxQuantity` (optional) caps the quantity picker; default 10.\n- Optional: `key` (a line's own key when the same product sits on two\n lines, one per size; default `id`), `href` (the name links to the\n product), `compareAt` (the old unit price, struck through beside the\n line total), `currency` (over the `currency` prop).\n\nThe same shape is what `product-detail` and `product-grid` hand to their\nadd-to-cart callbacks, so a cart is those rows.\n\n## Props\n\n- `items` — the line items. An empty list shows the empty state.\n- `step` — `\"cart\"` (default) or `\"checkout\"`.\n- `currency` — an ISO code, default `\"USD\"`. `locale` — for the money\n format, default the reader's.\n- `shipping` · `tax` — a number is a row that adds to the total (0 reads\n \"Free\" for shipping); a string is shown as written and adds nothing\n (`tax=\"Calculated at payment\"` — Paddle works tax out). Leave one out\n and its row is not drawn.\n- `discount` — the code in force: `{ code, amount, label? }`; `amount` is\n taken off the total.\n- `note` — a line at the foot of the summary, beside a truck (\"Free\n delivery on orders over $100\").\n- `loading` — skeleton rows. `error` — a line above everything (the cart\n did not load).\n- `title` — the heading; default \"Cart\" or \"Checkout\".\n\nThe cart step:\n\n- `onQuantityChange(item, quantity)` — save the new quantity; return\n `{ ok: true }` or `{ ok: false, error }`. Without it the quantity is\n shown, not picked.\n- `onRemove(item)` — remove the row, same return. Without it no ×.\n- `onApplyCode(code)` — a discount code field appears; return\n `{ ok: false, error: \"That code has expired.\" }` to say why not. On\n success pass the new `discount` back in.\n- `onRemoveCode()` — an × beside the discount.\n- `onCheckout()` — the Checkout button; move to the checkout step (route\n or state). May return `{ ok: false, error }`.\n- `checkoutLabel` — default \"Checkout\".\n- `onContinueShopping()` or `continueHref` — \"Continue shopping\", in the\n summary and in the empty state. `continueLabel` renames it.\n- `emptyTitle` · `emptyBody` — the empty state's words.\n\nThe checkout step:\n\n- `onPay(details)` — required for checkout. `details` is `{ email }`, plus\n `{ name, address, city, postal, country }` when the address is asked\n for. Save the order, open Paddle's checkout with it, and return\n `{ ok: true }`; `{ ok: false, error }` shows the reason above the button.\n- `requireAddress` — default `true`; `false` for things that are not\n shipped (downloads, tickets): only the email is asked for.\n- `terms` — a checkbox label that must be ticked before paying (\"I agree\n to the terms of sale\"); a string or a node with links. Omit for none.\n- `defaultDetails` — prefill: `{ email, name, address, city, postal, country }`.\n- `payLabel` — the button; default \"Continue to payment · $123.00\".\n- `payNote` — the line under it; default says the card is entered on the\n next screen. `null` for none.\n- `onBack()` — \"Back to cart\". `backLabel` renames it.\n\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/basket.jsx — client: the cart, read and changed\n\"use client\";\nimport { useState } from \"react\";\nimport { useRows } from \"../lib/data\";\nimport { deleteRow, updateRow } from \"../lib/actions\";\nimport { Cart } from \"../components/blocks/cart\";\n\nexport default function Basket() {\n const { rows: items, error, loading } = useRows({ source: \"cart_items\", orderBy: \"created_at asc\" });\n const [step, setStep] = useState(\"cart\");\n return (\n <Cart\n items={items}\n loading={loading}\n error={error}\n step={step}\n shipping={0}\n tax=\"Calculated at payment\"\n note=\"Free delivery on every order this month.\"\n onQuantityChange={(item, quantity) => updateRow({ source: \"cart_items\", id: item.id, patch: { quantity } })}\n onRemove={(item) => deleteRow({ source: \"cart_items\", id: item.id })}\n onCheckout={() => setStep(\"checkout\")}\n onBack={() => setStep(\"cart\")}\n continueHref=\"/shop\"\n terms=\"I agree to the terms of sale\"\n onPay={async () => {\n // Paddle.js opens its checkout in the browser from price ids: no route of the app's own.\n if (!window.Paddle) return { ok: false, error: \"Payments have not loaded. Try again.\" };\n window.Paddle.Checkout.open({ items: items.map((i) => ({ priceId: i.price_id, quantity: i.quantity })) });\n return { ok: true };\n }}\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Take a card number, expiry or CVC. Payment is Paddle's overlay or page,\n opened from `onPay`; the block never sees a card.\n- Work out tax or shipping: pass the numbers (or the words) in.\n- Keep the cart: `items` is the app's; every change is a callback.\n- Validate an address against a country's format: it checks that each\n field is filled.\n\nA cart it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add cart` restores them.\n\n## Credits\n\nShape and behaviour from Creative Tim UI (https://github.com/creativetimofficial/ui), MIT; this is a rewrite on the Makersclaw kit.\n"
35
35
  }
36
36
  ],
37
37
  "categories": [
@@ -55,7 +55,7 @@
55
55
  "docs": "Installed to components/makersclaw/blocks/cart. Import: import { Cart } from \"@/components/makersclaw/blocks/cart\". Props and an example: components/makersclaw/blocks/cart/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
56
56
  "meta": {
57
57
  "makersclaw": {
58
- "version": "0.6.0",
58
+ "version": "0.8.0",
59
59
  "useWhen": "An app that sells things: the basket before paying, and the page that collects the buyer's email and address before the payment provider takes over. Not for a subscription plan picker (a pricing table) or for an order already placed (order-detail)."
60
60
  }
61
61
  }
package/r/chart.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Chart",
6
6
  "description": "Line, area (stacked), bar (grouped or stacked, columns or bars, value labels at the ends, a track behind), a muted or dashed series for a previous period to compare against, pie and donut charts, sparklines and heatmaps (a grid shaded by value, with a scale legend), drawn in SVG (the heatmap as an HTML table) with the workspace's chart colours: round ticks, gridlines, a tooltip and crosshair under the pointer or the arrow keys, a legend that hides a series, and a data table for screen readers.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -19,19 +19,19 @@
19
19
  "path": "registry/blocks/chart/core.jsx",
20
20
  "type": "registry:component",
21
21
  "target": "@components/makersclaw/blocks/chart/core.jsx",
22
- "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Empty, Toggle, cx } from \"../../ui\";\n\n// What every chart in this block shares: the series colours as classes,\n// nice ticks, the width of the box it sits in, the reveal, the legend, the\n// tooltip box and the hidden data table a screen reader reads. The charts\n// themselves are in cartesian.jsx, pie.jsx and sparkline.jsx.\n\n// ---- colours ---------------------------------------------------------------\n// Written out whole so Tailwind finds every class. chart-1…5 are the series\n// colours (app/globals.css); the rest are the theme's own tones, for the\n// one series that means a status (churn in the destructive tone).\nconst TONES = {\n \"chart-1\": { fill: \"fill-chart-1\", stroke: \"stroke-chart-1\", bg: \"bg-chart-1\", text: \"text-chart-1\" },\n \"chart-2\": { fill: \"fill-chart-2\", stroke: \"stroke-chart-2\", bg: \"bg-chart-2\", text: \"text-chart-2\" },\n \"chart-3\": { fill: \"fill-chart-3\", stroke: \"stroke-chart-3\", bg: \"bg-chart-3\", text: \"text-chart-3\" },\n \"chart-4\": { fill: \"fill-chart-4\", stroke: \"stroke-chart-4\", bg: \"bg-chart-4\", text: \"text-chart-4\" },\n \"chart-5\": { fill: \"fill-chart-5\", stroke: \"stroke-chart-5\", bg: \"bg-chart-5\", text: \"text-chart-5\" },\n primary: { fill: \"fill-primary\", stroke: \"stroke-primary\", bg: \"bg-primary\", text: \"text-primary\" },\n info: { fill: \"fill-info\", stroke: \"stroke-info\", bg: \"bg-info\", text: \"text-info\" },\n success: { fill: \"fill-success\", stroke: \"stroke-success\", bg: \"bg-success\", text: \"text-success\" },\n warning: { fill: \"fill-warning\", stroke: \"stroke-warning\", bg: \"bg-warning\", text: \"text-warning\" },\n danger: { fill: \"fill-destructive\", stroke: \"stroke-destructive\", bg: \"bg-destructive\", text: \"text-destructive\" },\n muted: { fill: \"fill-muted-foreground\", stroke: \"stroke-muted-foreground\", bg: \"bg-muted-foreground\", text: \"text-muted-foreground\" },\n};\n\n/** The tone a series (or a slice) wears: its own `tone` (chart-1…5, 1…5,\n * or a theme tone), else the next chart colour in order. */\nexport function toneOf(item, index) {\n const t = typeof item?.tone === \"number\" ? `chart-${item.tone}` : item?.tone;\n return TONES[t] ? t : `chart-${(index % 5) + 1}`;\n}\n\nexport function tone(t, kind) {\n return (TONES[t] ?? TONES[\"chart-1\"])[kind];\n}\n\n// ---- numbers ---------------------------------------------------------------\n\nconst COMPACT = new Intl.NumberFormat(undefined, { notation: \"compact\", maximumFractionDigits: 1 });\nconst PLAIN = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });\n\n/** The default value formatter: 1,250 · 48.2K · 1.3M. */\nexport function defaultFormat(v) {\n if (v == null || Number.isNaN(v)) return \"—\";\n return Math.abs(v) >= 10000 ? COMPACT.format(v) : PLAIN.format(v);\n}\n\n/** A value as a number, or null when there is none to draw: null,\n * undefined, an empty or blank string, or anything not a finite number.\n * null is a gap, never a zero — Number(null) and Number(\"\") are 0, and\n * until 0.5.1 a day with no data plotted as a real zero. */\nexport function num(v) {\n if (v == null || (typeof v === \"string\" && !v.trim())) return null;\n const n = typeof v === \"number\" ? v : Number(v);\n return Number.isFinite(n) ? n : null;\n}\n\n/** Points split into runs wherever one is missing (null): a line or an\n * area breaks at a gap. [p | null, …] → [[p, …], …] */\nexport function runs(points) {\n const out = [];\n let cur = [];\n for (const p of points) {\n if (p) cur.push(p);\n else if (cur.length) {\n out.push(cur);\n cur = [];\n }\n }\n if (cur.length) out.push(cur);\n return out;\n}\n\n/** Round numbers that cover [min, max] in about `count` steps. */\nexport function niceTicks(min, max, count = 5) {\n if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];\n if (min === max) {\n if (min === 0) return [0, 1];\n min = Math.min(0, min);\n max = Math.max(0, max);\n }\n const raw = (max - min) / Math.max(1, count);\n const mag = 10 ** Math.floor(Math.log10(raw));\n const err = raw / mag;\n const step = (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1) * mag;\n const lo = Math.floor(min / step) * step;\n const hi = Math.ceil(max / step) * step;\n const out = [];\n for (let v = lo; v <= hi + step / 2; v += step) out.push(Number(v.toPrecision(12)));\n return out;\n}\n\nexport function humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"\";\n}\n\n/** About how wide a label is at text-2xs, for margins and thinning. The\n * axis text is small and the estimate generous, so nothing is clipped. */\nexport function textWidth(s) {\n return String(s ?? \"\").length * 6.4 + 4;\n}\n\n/** A safe id fragment for url(#…) from React's useId. */\nexport function idFrom(uid) {\n return `c${String(uid).replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n}\n\n// ---- the box ---------------------------------------------------------------\n\n/** The width of the element, kept current by a ResizeObserver: a chart is\n * drawn for the width it has, never scaled. A callback ref, so a box that\n * comes back (after the empty state) is measured again. 0 until measured.\n *\n * const [boxRef, width] = useWidth(); <div ref={boxRef}>…</div> */\nexport function useWidth() {\n const [el, setEl] = useState(null);\n const [width, setWidth] = useState(0);\n useEffect(() => {\n if (!el) return undefined;\n setWidth(Math.floor(el.getBoundingClientRect().width));\n if (typeof ResizeObserver === \"undefined\") return undefined;\n const ro = new ResizeObserver(([entry]) => setWidth(Math.floor(entry.contentRect.width)));\n ro.observe(el);\n return () => ro.disconnect();\n }, [el]);\n return [setEl, width];\n}\n\n/** false for the first frame once `ready`, then true: marks draw from\n * nothing to their place. The transitions are motion-safe: classes, so\n * with reduced motion they simply appear. */\nexport function useReveal(ready) {\n const [shown, setShown] = useState(false);\n useEffect(() => {\n if (!ready || shown) return undefined;\n // A frame, so the start is painted; the timer, because a hidden tab\n // runs no frames and the chart must never stay empty.\n const done = () => setShown(true);\n const frame = requestAnimationFrame(() => requestAnimationFrame(done));\n const timer = setTimeout(done, 120);\n return () => {\n cancelAnimationFrame(frame);\n clearTimeout(timer);\n };\n }, [ready, shown]);\n return shown;\n}\n\n// ---- the parts every chart draws ----------------------------------------------\n\n// A square of the series' colour; a dashed series gets a short dashed line.\nfunction Swatch({ t, dim, dashed }) {\n if (dashed) {\n return (\n <svg aria-hidden=\"true\" width=\"12\" height=\"10\" viewBox=\"0 0 12 10\" className={cx(\"shrink-0\", dim && \"opacity-30\")}>\n <line x1=\"0\" y1=\"5\" x2=\"12\" y2=\"5\" strokeWidth=\"2\" strokeDasharray=\"3 2\" className={tone(t, \"stroke\")} />\n </svg>\n );\n }\n return <span aria-hidden=\"true\" className={cx(\"size-2.5 shrink-0 rounded-sm\", tone(t, \"bg\"), dim && \"opacity-30\")} />;\n}\n\n/**\n * The key to a chart's colours. With `onToggle` each entry is a toggle\n * (pressed = shown) that hides or shows its series; without it, a plain\n * list. `items` [{ key, label, tone?, value?, dashed? }] — `value` is a\n * muted note after the label (a slice's share); `dashed` draws the key as\n * a dashed line (a comparison series).\n */\nexport function ChartLegend({ items, hidden, onToggle, className, label = \"Legend\" }) {\n const list = (items ?? []).filter(Boolean);\n if (!list.length) return null;\n const off = hidden instanceof Set ? hidden : new Set(hidden ?? []);\n if (!onToggle) {\n return (\n <ul aria-label={label} className={cx(\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground\", className)}>\n {list.map((it, i) => (\n <li key={it.key} className=\"flex items-center gap-1.5\">\n <Swatch t={toneOf(it, i)} dashed={it.dashed} />\n <span className=\"text-foreground\">{it.label ?? humanize(it.key)}</span>\n {it.value != null ? <span data-nums>{it.value}</span> : null}\n </li>\n ))}\n </ul>\n );\n }\n return (\n <div role=\"group\" aria-label={label} className={cx(\"flex flex-wrap items-center justify-center gap-1\", className)}>\n {list.map((it, i) => {\n const shown = !off.has(it.key);\n return (\n <Toggle\n key={it.key}\n size=\"sm\"\n variant=\"quiet\"\n pressed={shown}\n onPressedChange={() => onToggle(it.key)}\n className=\"gap-1.5 text-xs\"\n >\n <Swatch t={toneOf(it, i)} dim={!shown} dashed={it.dashed} />\n <span className={shown ? \"text-foreground\" : \"line-through\"}>{it.label ?? humanize(it.key)}</span>\n {it.value != null ? <span data-nums className=\"text-muted-foreground\">{it.value}</span> : null}\n </Toggle>\n );\n })}\n </div>\n );\n}\n\n/** The floating box that says what is under the pointer (or the keyboard).\n * `at` { x, y, flip } in the chart's pixels; flip puts it on the left. */\nexport function TooltipBox({ at, title, rows, footer }) {\n if (!at) return null;\n return (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute left-0 top-0 z-10 min-w-36 rounded-md border border-border bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-lg\"\n style={{\n transform: `translate(${Math.round(at.x)}px, ${Math.round(at.y)}px) translate(${at.flip ? \"calc(-100% - 12px)\" : \"12px\"}, ${at.middle ? \"-50%\" : \"0\"})`,\n }}\n >\n {title != null ? <p className=\"mb-1.5 font-medium\">{title}</p> : null}\n <div className=\"grid gap-1\">\n {rows.map((r) => (\n <div key={r.key} className=\"flex items-center gap-2\">\n <Swatch t={r.tone} dashed={r.dashed} />\n <span className=\"flex-1 text-muted-foreground\">{r.label}</span>\n <span data-nums className=\"font-medium\">{r.value}</span>\n </div>\n ))}\n </div>\n {footer ? (\n <div className=\"mt-1.5 flex items-center justify-between gap-2 border-t border-border pt-1.5\">\n <span className=\"text-muted-foreground\">{footer.label}</span>\n <span data-nums className=\"font-medium\">{footer.value}</span>\n </div>\n ) : null}\n </div>\n );\n}\n\n/** The numbers behind the picture, for a screen reader: a real table,\n * visually hidden. `head` the column names, `body` rows of strings. */\nexport function DataTable({ caption, head, body }) {\n return (\n <table className=\"sr-only\">\n <caption>{caption}</caption>\n <thead>\n <tr>\n {head.map((h, i) => (\n <th key={i} scope=\"col\">{h}</th>\n ))}\n </tr>\n </thead>\n <tbody>\n {body.map((cells, r) => (\n <tr key={r}>\n {cells.map((c, i) =>\n i === 0 ? (\n <th key={i} scope=\"row\">{c}</th>\n ) : (\n <td key={i}>{c}</td>\n ),\n )}\n </tr>\n ))}\n </tbody>\n </table>\n );\n}\n\n/** What a chart shows with nothing to draw, at the height it would have. */\nexport function ChartEmpty({ height, title, body }) {\n return (\n <div style={{ minHeight: height }} className=\"grid\">\n <Empty icon=\"chart\" title={title} body={body} className=\"h-full content-center\" />\n </div>\n );\n}\n"
22
+ "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Empty, Toggle, cx } from \"../../ui\";\n\n// What every chart in this block shares: the series colours as classes,\n// nice ticks, the width of the box it sits in, the reveal, the legend, the\n// tooltip box and the hidden data table a screen reader reads. The charts\n// themselves are in cartesian.jsx, pie.jsx and sparkline.jsx.\n\n// ---- colours ---------------------------------------------------------------\n// Written out whole so Tailwind finds every class. chart-1…5 are the series\n// colours (app/globals.css); the rest are the theme's own tones, for the\n// one series that means a status (churn in the destructive tone).\nconst TONES = {\n \"chart-1\": { fill: \"fill-chart-1\", stroke: \"stroke-chart-1\", bg: \"bg-chart-1\", text: \"text-chart-1\" },\n \"chart-2\": { fill: \"fill-chart-2\", stroke: \"stroke-chart-2\", bg: \"bg-chart-2\", text: \"text-chart-2\" },\n \"chart-3\": { fill: \"fill-chart-3\", stroke: \"stroke-chart-3\", bg: \"bg-chart-3\", text: \"text-chart-3\" },\n \"chart-4\": { fill: \"fill-chart-4\", stroke: \"stroke-chart-4\", bg: \"bg-chart-4\", text: \"text-chart-4\" },\n \"chart-5\": { fill: \"fill-chart-5\", stroke: \"stroke-chart-5\", bg: \"bg-chart-5\", text: \"text-chart-5\" },\n primary: { fill: \"fill-primary\", stroke: \"stroke-primary\", bg: \"bg-primary\", text: \"text-primary\" },\n info: { fill: \"fill-info\", stroke: \"stroke-info\", bg: \"bg-info\", text: \"text-info\" },\n success: { fill: \"fill-success\", stroke: \"stroke-success\", bg: \"bg-success\", text: \"text-success\" },\n warning: { fill: \"fill-warning\", stroke: \"stroke-warning\", bg: \"bg-warning\", text: \"text-warning\" },\n danger: { fill: \"fill-destructive\", stroke: \"stroke-destructive\", bg: \"bg-destructive\", text: \"text-destructive\" },\n muted: { fill: \"fill-muted-foreground\", stroke: \"stroke-muted-foreground\", bg: \"bg-muted-foreground\", text: \"text-muted-foreground\" },\n};\n\n/** The tone a series (or a slice) wears: its own `tone` (chart-1…5, 1…5,\n * or a theme tone), else the next chart colour in order. */\nexport function toneOf(item, index) {\n const t = typeof item?.tone === \"number\" ? `chart-${item.tone}` : item?.tone;\n return TONES[t] ? t : `chart-${(index % 5) + 1}`;\n}\n\nexport function tone(t, kind) {\n return (TONES[t] ?? TONES[\"chart-1\"])[kind];\n}\n\n// ---- numbers ---------------------------------------------------------------\n\nconst COMPACT = new Intl.NumberFormat(undefined, { notation: \"compact\", maximumFractionDigits: 1 });\nconst PLAIN = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });\n\n/** The default value formatter: 1,250 · 48.2K · 1.3M. */\nexport function defaultFormat(v) {\n if (v == null || Number.isNaN(v)) return \"—\";\n return Math.abs(v) >= 10000 ? COMPACT.format(v) : PLAIN.format(v);\n}\n\n/** A value as a number, or null when there is none to draw: null,\n * undefined, an empty or blank string, or anything not a finite number.\n * null is a gap, never a zero — Number(null) and Number(\"\") are 0, and\n * until 0.5.1 a day with no data plotted as a real zero. */\nexport function num(v) {\n if (v == null || (typeof v === \"string\" && !v.trim())) return null;\n const n = typeof v === \"number\" ? v : Number(v);\n return Number.isFinite(n) ? n : null;\n}\n\n/** Points split into runs wherever one is missing (null): a line or an\n * area breaks at a gap. [p | null, …] → [[p, …], …] */\nexport function runs(points) {\n const out = [];\n let cur = [];\n for (const p of points) {\n if (p) cur.push(p);\n else if (cur.length) {\n out.push(cur);\n cur = [];\n }\n }\n if (cur.length) out.push(cur);\n return out;\n}\n\n/** Round numbers that cover [min, max] in about `count` steps. */\nexport function niceTicks(min, max, count = 5) {\n if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];\n if (min === max) {\n if (min === 0) return [0, 1];\n min = Math.min(0, min);\n max = Math.max(0, max);\n }\n const raw = (max - min) / Math.max(1, count);\n const mag = 10 ** Math.floor(Math.log10(raw));\n const err = raw / mag;\n const step = (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1) * mag;\n const lo = Math.floor(min / step) * step;\n const hi = Math.ceil(max / step) * step;\n const out = [];\n for (let v = lo; v <= hi + step / 2; v += step) out.push(Number(v.toPrecision(12)));\n return out;\n}\n\nexport function humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"\";\n}\n\n/** About how wide a label is at text-2xs, for margins and thinning. The\n * axis text is small and the estimate generous, so nothing is clipped. */\nexport function textWidth(s) {\n return String(s ?? \"\").length * 6.4 + 4;\n}\n\n/** A safe id fragment for url(#…) from React's useId. */\nexport function idFrom(uid) {\n return `c${String(uid).replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n}\n\n// ---- the box ---------------------------------------------------------------\n\n/** The width of the element, kept current by a ResizeObserver: a chart is\n * drawn for the width it has, never scaled. A callback ref, so a box that\n * comes back (after the empty state) is measured again. 0 until measured.\n *\n * const [boxRef, width] = useWidth(); <div ref={boxRef}>…</div> */\nexport function useWidth() {\n const [el, setEl] = useState(null);\n const [width, setWidth] = useState(0);\n useEffect(() => {\n if (!el) return undefined;\n setWidth(Math.floor(el.getBoundingClientRect().width));\n if (typeof ResizeObserver === \"undefined\") return undefined;\n const ro = new ResizeObserver(([entry]) => setWidth(Math.floor(entry.contentRect.width)));\n ro.observe(el);\n return () => ro.disconnect();\n }, [el]);\n return [setEl, width];\n}\n\n/** false for the first frame once `ready`, then true: marks draw from\n * nothing to their place. The transitions are motion-safe: classes, so\n * with reduced motion they simply appear. */\nexport function useReveal(ready) {\n const [shown, setShown] = useState(false);\n useEffect(() => {\n if (!ready || shown) return undefined;\n // A frame, so the start is painted; the timer, because a hidden tab\n // runs no frames and the chart must never stay empty.\n const done = () => setShown(true);\n const frame = requestAnimationFrame(() => requestAnimationFrame(done));\n const timer = setTimeout(done, 120);\n return () => {\n cancelAnimationFrame(frame);\n clearTimeout(timer);\n };\n }, [ready, shown]);\n return shown;\n}\n\n// ---- the parts every chart draws ----------------------------------------------\n\n// A square of the series' colour; a dashed series gets a short dashed line.\nfunction Swatch({ t, dim, dashed }) {\n if (dashed) {\n return (\n <svg aria-hidden=\"true\" width=\"12\" height=\"10\" viewBox=\"0 0 12 10\" className={cx(\"shrink-0\", dim && \"opacity-30\")}>\n <line x1=\"0\" y1=\"5\" x2=\"12\" y2=\"5\" strokeWidth=\"2\" strokeDasharray=\"3 2\" className={tone(t, \"stroke\")} />\n </svg>\n );\n }\n return <span aria-hidden=\"true\" className={cx(\"size-2.5 shrink-0 rounded-sm\", tone(t, \"bg\"), dim && \"opacity-30\")} />;\n}\n\n/**\n * The key to a chart's colours. With `onToggle` each entry is a toggle\n * (pressed = shown) that hides or shows its series; without it, a plain\n * list. `items` [{ key, label, tone?, value?, dashed? }] — `value` is a\n * muted note after the label (a slice's share); `dashed` draws the key as\n * a dashed line (a comparison series).\n */\nexport function ChartLegend({ items, hidden, onToggle, className, label = \"Legend\" }) {\n const list = (items ?? []).filter(Boolean);\n if (!list.length) return null;\n const off = hidden instanceof Set ? hidden : new Set(hidden ?? []);\n if (!onToggle) {\n return (\n <ul aria-label={label} className={cx(\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground\", className)}>\n {list.map((it, i) => (\n <li key={it.key} className=\"flex items-center gap-1.5\">\n <Swatch t={toneOf(it, i)} dashed={it.dashed} />\n <span className=\"text-foreground\">{it.label ?? humanize(it.key)}</span>\n {it.value != null ? <span data-nums>{it.value}</span> : null}\n </li>\n ))}\n </ul>\n );\n }\n return (\n <div role=\"group\" aria-label={label} className={cx(\"flex flex-wrap items-center justify-center gap-1\", className)}>\n {list.map((it, i) => {\n const shown = !off.has(it.key);\n return (\n <Toggle\n key={it.key}\n size=\"sm\"\n variant=\"quiet\"\n pressed={shown}\n onPressedChange={() => onToggle(it.key)}\n className=\"gap-1.5 text-xs\"\n >\n <Swatch t={toneOf(it, i)} dim={!shown} dashed={it.dashed} />\n <span className={shown ? \"text-foreground\" : \"line-through\"}>{it.label ?? humanize(it.key)}</span>\n {it.value != null ? <span data-nums className=\"text-muted-foreground\">{it.value}</span> : null}\n </Toggle>\n );\n })}\n </div>\n );\n}\n\n/** The floating box that says what is under the pointer (or the keyboard).\n * `at` { x, y, flip } in the chart's pixels; flip puts it on the left. */\nexport function TooltipBox({ at, title, rows, footer }) {\n if (!at) return null;\n return (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute left-0 top-0 z-10 min-w-36 rounded-md border border-border bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-lg\"\n style={{\n transform: `translate(${Math.round(at.x)}px, ${Math.round(at.y)}px) translate(${at.flip ? \"calc(-100% - 12px)\" : \"12px\"}, ${at.middle ? \"-50%\" : \"0\"})`,\n }}\n >\n {title != null ? <p className=\"mb-1.5 font-medium\">{title}</p> : null}\n <div className=\"grid gap-1\">\n {rows.map((r) => (\n <div key={r.key} className=\"flex items-center gap-2\">\n <Swatch t={r.tone} dashed={r.dashed} />\n <span className=\"flex-1 text-muted-foreground\">{r.label}</span>\n <span data-nums className=\"font-medium\">{r.value}</span>\n </div>\n ))}\n </div>\n {footer ? (\n <div className=\"mt-1.5 flex items-center justify-between gap-2 border-t border-border pt-1.5\">\n <span className=\"text-muted-foreground\">{footer.label}</span>\n <span data-nums className=\"font-medium\">{footer.value}</span>\n </div>\n ) : null}\n </div>\n );\n}\n\n/** The numbers behind the picture, for a screen reader: a real table,\n * visually hidden. `head` the column names, `body` rows of strings.\n *\n * The hiding is a div's, never the table's: a table takes `width: 1px`\n * as a least width and grows to its rows, and a browser need not clip a\n * table's overflow, so `sr-only` on the table itself left a box as wide\n * as its longest row, which pushed a phone's page past the window (the\n * store's probe at 375px, 2026-10-08). The div is 1px, clips what it\n * holds and is absolute inside the chart's own box (each chart's root\n * is `relative`); the table in it is a plain table, read as one. */\nexport function DataTable({ caption, head, body }) {\n return (\n <div className=\"sr-only\">\n <table>\n <caption>{caption}</caption>\n <thead>\n <tr>\n {head.map((h, i) => (\n <th key={i} scope=\"col\">{h}</th>\n ))}\n </tr>\n </thead>\n <tbody>\n {body.map((cells, r) => (\n <tr key={r}>\n {cells.map((c, i) =>\n i === 0 ? (\n <th key={i} scope=\"row\">{c}</th>\n ) : (\n <td key={i}>{c}</td>\n ),\n )}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n );\n}\n\n/** What a chart shows with nothing to draw, at the height it would have. */\nexport function ChartEmpty({ height, title, body }) {\n return (\n <div style={{ minHeight: height }} className=\"grid\">\n <Empty icon=\"chart\" title={title} body={body} className=\"h-full content-center\" />\n </div>\n );\n}\n"
23
23
  },
24
24
  {
25
25
  "path": "registry/blocks/chart/cartesian.jsx",
26
26
  "type": "registry:component",
27
27
  "target": "@components/makersclaw/blocks/chart/cartesian.jsx",
28
- "content": "\"use client\";\n\nimport { useId, useMemo, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport {\n ChartEmpty,\n ChartLegend,\n DataTable,\n TooltipBox,\n defaultFormat,\n humanize,\n idFrom,\n niceTicks,\n num,\n runs,\n textWidth,\n tone,\n toneOf,\n useReveal,\n useWidth,\n} from \"./core\";\n\n// LineChart, AreaChart and BarChart — one drawing, three marks. Rows in,\n// one x key, the series to draw; the block owns the rest: the scale and its\n// round ticks, gridlines, labels thinned to fit, a width that follows the\n// container, the tooltip and crosshair under a pointer or the arrow keys,\n// the legend that hides a series, the reveal (none with reduced motion),\n// and the words and table a screen reader gets instead of the picture.\n\n// ---- paths -------------------------------------------------------------------\n\nconst f = (n) => Math.round(n * 100) / 100;\n\nfunction linear(pts) {\n return pts.map(([x, y], i) => `${i ? \"L\" : \"M\"}${f(x)},${f(y)}`).join(\"\");\n}\n\nfunction step(pts) {\n let d = `M${f(pts[0][0])},${f(pts[0][1])}`;\n for (let i = 1; i < pts.length; i++) {\n const mid = (pts[i - 1][0] + pts[i][0]) / 2;\n d += `H${f(mid)}V${f(pts[i][1])}H${f(pts[i][0])}`;\n }\n return d;\n}\n\n// Monotone cubic (Fritsch–Carlson): smooth, and never overshoots a point,\n// so a curve does not invent a dip below zero or a peak that was not there.\nfunction monotone(pts) {\n const n = pts.length;\n if (n < 3) return linear(pts);\n const dx = [];\n const m = [];\n for (let i = 0; i < n - 1; i++) {\n dx[i] = pts[i + 1][0] - pts[i][0];\n m[i] = dx[i] === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / dx[i];\n }\n const t = new Array(n);\n t[0] = m[0];\n t[n - 1] = m[n - 2];\n for (let i = 1; i < n - 1; i++) {\n t[i] = m[i - 1] * m[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / m[i - 1] + (dx[i] + 2 * dx[i - 1]) / m[i]);\n }\n let d = `M${f(pts[0][0])},${f(pts[0][1])}`;\n for (let i = 0; i < n - 1; i++) {\n const h = dx[i] / 3;\n d += `C${f(pts[i][0] + h)},${f(pts[i][1] + t[i] * h)} ${f(pts[i + 1][0] - h)},${f(pts[i + 1][1] - t[i + 1] * h)} ${f(pts[i + 1][0])},${f(pts[i + 1][1])}`;\n }\n return d;\n}\n\nfunction curvePath(pts, curve) {\n if (!pts.length) return \"\";\n if (curve === \"step\") return step(pts);\n if (curve === \"linear\") return linear(pts);\n return monotone(pts);\n}\n\n// A bar with its outer end rounded (the end away from zero).\nfunction barPath(x, y, w, h, r, side) {\n const q = Math.max(0, Math.min(r, w / 2, h / 2));\n if (!q || !side) return `M${f(x)},${f(y)}h${f(w)}v${f(h)}h${f(-w)}Z`;\n const X = f(x);\n const Y = f(y);\n if (side === \"top\") return `M${X},${f(y + h)}V${f(y + q)}Q${X},${Y} ${f(x + q)},${Y}H${f(x + w - q)}Q${f(x + w)},${Y} ${f(x + w)},${f(y + q)}V${f(y + h)}Z`;\n if (side === \"bottom\") return `M${X},${Y}V${f(y + h - q)}Q${X},${f(y + h)} ${f(x + q)},${f(y + h)}H${f(x + w - q)}Q${f(x + w)},${f(y + h)} ${f(x + w)},${f(y + h - q)}V${Y}Z`;\n if (side === \"right\") return `M${X},${Y}H${f(x + w - q)}Q${f(x + w)},${Y} ${f(x + w)},${f(y + q)}V${f(y + h - q)}Q${f(x + w)},${f(y + h)} ${f(x + w - q)},${f(y + h)}H${X}Z`;\n return `M${f(x + w)},${Y}H${f(x + q)}Q${X},${Y} ${X},${f(y + q)}V${f(y + h - q)}Q${X},${f(y + h)} ${f(x + q)},${f(y + h)}H${f(x + w)}Z`;\n}\n\n// ---- the chart ----------------------------------------------------------------\n\nfunction Cartesian({\n kind,\n data,\n x,\n series,\n format = defaultFormat,\n formatAxis,\n formatX = (v) => (v == null ? \"\" : String(v)),\n label = \"Chart\",\n description,\n xLabel,\n height = 240,\n stacked = false,\n orientation = \"vertical\",\n valueLabels = false,\n track = false,\n curve = \"monotone\",\n dots = false,\n grid = true,\n xAxis = true,\n yAxis = true,\n legend,\n tooltip = true,\n onSelect,\n emptyLabel = \"No data yet\",\n emptyBody,\n className,\n}) {\n const uid = idFrom(useId());\n const fmt = (v) => (v == null ? \"—\" : format(v));\n const [boxRef, width] = useWidth();\n const [hidden, setHidden] = useState(() => new Set());\n const [active, setActive] = useState(null);\n const [keyed, setKeyed] = useState(false);\n\n const rows = Array.isArray(data) ? data : [];\n // A `muted` series with no tone of its own is the grey: a comparison\n // (last period) that sits behind the series it is compared with.\n const all = useMemo(\n () => (series ?? []).map((s, i) => ({ ...s, label: s.label ?? humanize(s.key), tone: s.muted && s.tone == null ? \"muted\" : toneOf(s, i) })),\n [series],\n );\n const live = all.filter((s) => !hidden.has(s.key));\n // Lines and areas draw muted series first, so they sit underneath.\n const order = live.map((_, j) => j).sort((a, b) => Number(Boolean(live[b].muted)) - Number(Boolean(live[a].muted)));\n const horizontal = kind === \"bar\" && orientation === \"horizontal\";\n const stack = stacked && kind !== \"line\";\n const axisFormat = formatAxis ?? format;\n const showLegend = legend ?? all.length > 1;\n const shown = useReveal(width > 0);\n\n // Each series' [from, to] per row: stacked marks start where the one\n // below ended (positives up, negatives down); the rest start at zero.\n const spans = useMemo(() => {\n const up = rows.map(() => 0);\n const down = rows.map(() => 0);\n return live.map((s) =>\n rows.map((r, i) => {\n const v = num(r?.[s.key]);\n if (v == null) return null;\n if (!stack) return [0, v];\n if (v >= 0) {\n const from = up[i];\n up[i] += v;\n return [from, up[i]];\n }\n const from = down[i];\n down[i] += v;\n return [from, down[i]];\n }),\n );\n }, [rows, live, stack]); // eslint-disable-line react-hooks/exhaustive-deps\n\n let lo = 0;\n let hi = 0;\n for (const s of spans) for (const p of s) if (p) (lo = Math.min(lo, p[0], p[1])), (hi = Math.max(hi, p[0], p[1]));\n const valueLen = horizontal ? Math.max(0, width - 16) : height - 32;\n const ticks = niceTicks(lo, hi, Math.max(2, Math.min(6, Math.floor(valueLen / (horizontal ? 80 : 48)))));\n const vMin = ticks[0];\n const vMax = ticks[ticks.length - 1];\n\n const xNames = rows.map((r) => formatX(r?.[x], r));\n const tickNames = ticks.map((t) => axisFormat(t));\n\n // BarChart's value labels: one at the outer end of every bar, or of every\n // stack (its total). [{ i, j, v, text }]; j is the series (-1: a stack).\n const barLabels = [];\n if (kind === \"bar\" && valueLabels && live.length) {\n const say = (v, row, key) => (typeof valueLabels === \"function\" ? valueLabels(v, row, key) : fmt(v));\n rows.forEach((row, i) => {\n if (stack) {\n const vals = live.map((s) => num(row?.[s.key])).filter((v) => v != null);\n if (!vals.length) return;\n const v = vals.reduce((a, b) => a + b, 0);\n barLabels.push({ i, j: -1, v, text: say(v, row, null) });\n } else {\n live.forEach((s, j) => {\n const v = num(row?.[s.key]);\n if (v != null) barLabels.push({ i, j, v, text: say(v, row, s.key) });\n });\n }\n });\n }\n const labelRoom = barLabels.length ? Math.max(...barLabels.map((l) => textWidth(l.text))) + 6 : 0;\n\n // Margins from what the labels need.\n const catW = horizontal && yAxis ? Math.min(Math.max(...xNames.map(textWidth), 24) + 8, width * 0.4) : 0;\n const valW = !horizontal && yAxis ? Math.max(...tickNames.map(textWidth)) + 8 : 0;\n const m = {\n top: barLabels.length && !horizontal ? 22 : 8,\n right: horizontal ? Math.max(8, textWidth(tickNames[tickNames.length - 1]) / 2, labelRoom) : 8,\n bottom: xAxis ? 24 : 4,\n left: horizontal ? catW : valW,\n };\n // Points sit on the plot's edges (bars sit inside their band), so the\n // first and last x labels, centred on them, need room outside it.\n if (kind !== \"bar\" && xAxis && rows.length) {\n m.left = Math.max(m.left, textWidth(xNames[0]) / 2);\n m.right = Math.max(m.right, textWidth(xNames[xNames.length - 1]) / 2);\n }\n const plotW = Math.max(0, width - m.left - m.right);\n const plotH = Math.max(0, height - m.top - m.bottom);\n const n = rows.length;\n\n // Value → pixel along the value axis; index → pixel along the category axis.\n const vPos = (v) => (horizontal ? m.left + ((v - vMin) / (vMax - vMin || 1)) * plotW : m.top + plotH - ((v - vMin) / (vMax - vMin || 1)) * plotH);\n const band = (horizontal ? plotH : plotW) / Math.max(1, n);\n const cPos =\n kind === \"bar\"\n ? (i) => (horizontal ? m.top : m.left) + band * (i + 0.5)\n : (i) => m.left + (n === 1 ? plotW / 2 : (i * plotW) / (n - 1));\n const zero = vPos(Math.max(vMin, Math.min(0, vMax)));\n\n // Category labels thinned so they never collide.\n const labelEvery = horizontal\n ? Math.max(1, Math.ceil(16 / Math.max(1, band)))\n : Math.max(1, Math.ceil((Math.max(...xNames.map(textWidth), 0) + 12) / Math.max(1, kind === \"bar\" ? band : plotW / Math.max(1, n - 1))));\n\n function indexAt(e) {\n const rect = e.currentTarget.getBoundingClientRect();\n const p = horizontal ? e.clientY - rect.top : e.clientX - rect.left;\n if (kind === \"bar\") return Math.max(0, Math.min(n - 1, Math.floor((p - (horizontal ? m.top : m.left)) / band)));\n if (n === 1) return 0;\n return Math.max(0, Math.min(n - 1, Math.round(((p - m.left) / plotW) * (n - 1))));\n }\n\n function onKeyDown(e) {\n if (!n) return;\n const back = horizontal ? \"ArrowUp\" : \"ArrowLeft\";\n const fwd = horizontal ? \"ArrowDown\" : \"ArrowRight\";\n let next = null;\n if (e.key === fwd || e.key === (horizontal ? \"ArrowRight\" : \"ArrowDown\")) next = active == null ? 0 : Math.min(n - 1, active + 1);\n else if (e.key === back || e.key === (horizontal ? \"ArrowLeft\" : \"ArrowUp\")) next = active == null ? n - 1 : Math.max(0, active - 1);\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = n - 1;\n else if (e.key === \"Escape\") {\n setActive(null);\n return;\n } else if ((e.key === \"Enter\" || e.key === \" \") && onSelect && active != null) {\n e.preventDefault();\n onSelect(rows[active], active);\n return;\n }\n if (next == null) return;\n e.preventDefault();\n setKeyed(true);\n setActive(next);\n }\n\n // ---- words -------------------------------------------------------------------\n\n const summary = useMemo(() => {\n if (!n || !live.length) return \"\";\n const parts = live.map((s) => {\n const vals = rows.map((r) => num(r?.[s.key]));\n const idx = vals.reduce((best, v, i) => (v != null && (best < 0 || v > vals[best]) ? i : best), -1);\n if (idx < 0) return `${s.label}: no data`;\n const first = vals.find((v) => v != null);\n const last = [...vals].reverse().find((v) => v != null);\n return `${s.label}: ${kind === \"bar\" ? \"\" : `from ${fmt(first)} to ${fmt(last)}, `}highest ${fmt(vals[idx])} at ${xNames[idx]}`;\n });\n return `${n} ${n === 1 ? \"point\" : \"points\"}, ${xNames[0]} to ${xNames[n - 1]}. ${parts.join(\"; \")}.`;\n }, [rows, live, n]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const tipRows =\n active == null\n ? []\n : live.map((s) => ({ key: s.key, label: s.label, tone: s.tone, dashed: s.dashed, value: fmt(num(rows[active]?.[s.key])) }));\n // The stack's total: what there is, or none (—) when every value is missing.\n const total =\n active == null\n ? null\n : live.reduce((sum, s) => {\n const v = num(rows[active]?.[s.key]);\n return v == null ? sum : (sum ?? 0) + v;\n }, null);\n const announce = active == null ? \"\" : `${xNames[active]}: ${tipRows.map((r) => `${r.label} ${r.value}`).join(\", \")}`;\n\n const legendNode = showLegend ? (\n <ChartLegend\n items={all}\n hidden={hidden}\n onToggle={(key) =>\n setHidden((h) => {\n const next = new Set(h);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n })\n }\n label={`${label}: series`}\n />\n ) : null;\n\n if (!n) {\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <ChartEmpty height={height} title={emptyLabel} body={emptyBody} />\n </div>\n );\n }\n\n // ---- marks -------------------------------------------------------------------\n\n const tipAt =\n active == null || !tooltip\n ? null\n : horizontal\n ? { x: Math.min(width - 12, Math.max(...live.map((_, k) => vPos(spans[k][active]?.[1] ?? 0)), zero)), y: cPos(active), flip: false, middle: true }\n : { x: cPos(active), y: m.top, flip: cPos(active) > width / 2 };\n if (tipAt && horizontal) tipAt.flip = tipAt.x > width * 0.6;\n\n const revealStyle = (axis) => ({\n transform: shown ? \"none\" : axis === \"x\" ? \"scaleX(0)\" : \"scaleY(0)\",\n transformOrigin: axis === \"x\" ? `${zero}px 0` : `0 ${zero}px`,\n });\n\n let marks = null;\n if (kind === \"bar\") {\n const k = live.length;\n const groupW = Math.min(band * (stack ? 0.62 : 0.8), stack ? 56 : 28 * Math.max(1, k) + 4 * (k - 1));\n const gap = k > 1 && !stack ? Math.min(4, groupW * 0.06) : 0;\n const barW = stack ? groupW : Math.max(1, (groupW - gap * (k - 1)) / Math.max(1, k));\n const r = Math.min(4, barW / 3);\n // Which series ends each stack (its outer end is the rounded one).\n const outer = rows.map((_, i) => {\n let top = -1;\n let bottom = -1;\n live.forEach((s, j) => {\n const p = spans[j][i];\n if (!p || p[1] === p[0]) return;\n if (p[1] > p[0]) top = j;\n else bottom = j;\n });\n return { top, bottom };\n });\n // Where the stacked total ends: the far end of the stack on its side.\n const stackEnd = (i, v) => {\n let end = 0;\n live.forEach((_, j) => {\n const p = spans[j][i];\n if (p) end = v >= 0 ? Math.max(end, p[1]) : Math.min(end, p[1]);\n });\n return end;\n };\n const tracks = track ? (\n <g aria-hidden=\"true\" className=\"fill-accent\">\n {rows.map((_, i) => {\n const c = cPos(i) - groupW / 2;\n return horizontal ? (\n <rect key={i} x={m.left} y={c} width={plotW} height={groupW} rx={r} />\n ) : (\n <rect key={i} x={c} y={m.top} width={groupW} height={plotH} rx={r} />\n );\n })}\n </g>\n ) : null;\n const labels = barLabels.length ? (\n <g\n aria-hidden=\"true\"\n className=\"fill-foreground text-2xs font-medium motion-safe:transition-opacity motion-safe:duration-500\"\n style={{ opacity: shown ? 1 : 0 }}\n data-nums\n >\n {barLabels.map(({ i, j, v, text }) => {\n const end = vPos(j < 0 ? stackEnd(i, v) : v);\n const c = cPos(i) + (j < 0 ? 0 : -groupW / 2 + j * (barW + gap) + barW / 2);\n const up = v >= 0;\n return horizontal ? (\n <text key={`${i}-${j}`} x={end + (up ? 6 : -6)} y={c} dy=\"0.32em\" textAnchor={up ? \"start\" : \"end\"}>\n {text}\n </text>\n ) : (\n <text key={`${i}-${j}`} x={c} y={end + (up ? -6 : 12)} textAnchor=\"middle\">\n {text}\n </text>\n );\n })}\n </g>\n ) : null;\n marks = (\n <>\n {tracks}\n <g style={revealStyle(horizontal ? \"x\" : \"y\")} className=\"motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out\">\n {live.map((s, j) => (\n <g key={s.key} className={tone(s.tone, \"fill\")}>\n {rows.map((_, i) => {\n const p = spans[j][i];\n if (!p || p[0] === p[1]) return null;\n const a = vPos(p[0]);\n const b = vPos(p[1]);\n const offset = stack ? -groupW / 2 : -groupW / 2 + j * (barW + gap);\n const c = cPos(i) + offset;\n const positive = p[1] > p[0];\n const isOuter = !stack || (positive ? outer[i].top === j : outer[i].bottom === j);\n const side = !isOuter ? null : horizontal ? (positive ? \"right\" : \"left\") : positive ? \"top\" : \"bottom\";\n const d = horizontal\n ? barPath(Math.min(a, b), c, Math.abs(b - a), barW, r, side)\n : barPath(c, Math.min(a, b), barW, Math.abs(b - a), r, side);\n return <path key={i} d={d} className={cx(active != null && active !== i && \"opacity-60\", \"motion-safe:transition-opacity\")} />;\n })}\n </g>\n ))}\n </g>\n {labels}\n </>\n );\n } else {\n const clip = `${uid}-clip`;\n const fade = `${uid}-fade`;\n marks = (\n <>\n <defs>\n <clipPath id={clip}>\n <rect\n x={m.left - 4}\n y={0}\n width={plotW + 8}\n height={height}\n style={{ transform: shown ? \"none\" : \"scaleX(0)\", transformOrigin: `${m.left}px 0` }}\n className=\"motion-safe:transition-transform motion-safe:duration-700 motion-safe:ease-out\"\n />\n </clipPath>\n {/* An alpha mask, not a colour: the area fades toward zero. */}\n <linearGradient id={`${fade}-g`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopOpacity=\"1\" />\n <stop offset=\"1\" stopOpacity=\"0.15\" />\n </linearGradient>\n <mask id={fade} style={{ maskType: \"alpha\" }} maskUnits=\"userSpaceOnUse\" x={0} y={0} width={width} height={height}>\n <rect x={0} y={m.top} width={width} height={plotH} fill={`url(#${fade}-g)`} />\n </mask>\n </defs>\n <g clipPath={`url(#${clip})`}>\n {kind === \"area\"\n ? order.map((j) => {\n const s = live[j];\n // A dashed series is a line only: a comparison has no fill.\n if (s.dashed && !stack) return null;\n // One band per run of values: an area breaks where a value\n // is missing, as its line does — never down to zero.\n const d = runs(spans[j].map((p, i) => (p ? { x: cPos(i), top: vPos(p[1]), base: vPos(p[0]) } : null)))\n .filter((run) => run.length > 1)\n .map((run) => {\n const back = curvePath(run.map((q) => [q.x, q.base]).reverse(), curve).replace(/^M/, \"L\");\n return `${curvePath(run.map((q) => [q.x, q.top]), curve)}${back}Z`;\n })\n .join(\"\");\n if (!d) return null;\n return (\n <path\n key={s.key}\n d={d}\n className={tone(s.tone, \"fill\")}\n fillOpacity={stack ? 0.4 : s.muted ? 0.2 : 0.45}\n mask={stack ? undefined : `url(#${fade})`}\n />\n );\n })\n : null}\n {order.map((j) => {\n const s = live[j];\n // A line (and an area's edge) breaks where a value is missing;\n // a value alone between two gaps is a dot, so it still shows.\n const segs = runs(spans[j].map((p, i) => (p ? [cPos(i), vPos(p[1])] : null)));\n return (\n <g\n key={s.key}\n className={tone(s.tone, \"stroke\")}\n fill=\"none\"\n strokeWidth={s.muted ? 1.5 : 2}\n strokeDasharray={s.dashed ? \"5 4\" : undefined}\n strokeLinejoin=\"round\"\n strokeLinecap={s.dashed ? \"butt\" : \"round\"}\n >\n {segs.map((seg, k) =>\n seg.length > 1 ? (\n <path key={k} d={curvePath(seg, curve)} />\n ) : dots ? null : (\n <circle key={k} cx={seg[0][0]} cy={seg[0][1]} r={3} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={1.5} strokeDasharray=\"none\" />\n ),\n )}\n {dots\n ? spans[j].map((p, i) =>\n p ? <circle key={i} cx={cPos(i)} cy={vPos(p[1])} r={3} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={1.5} strokeDasharray=\"none\" /> : null,\n )\n : null}\n </g>\n );\n })}\n </g>\n </>\n );\n }\n\n const cursor =\n active == null\n ? null\n : kind === \"bar\"\n ? horizontal\n ? <rect x={m.left} y={m.top + band * active} width={plotW} height={band} className=\"fill-muted\" />\n : <rect x={m.left + band * active} y={m.top} width={band} height={plotH} className=\"fill-muted\" />\n : <line x1={cPos(active)} x2={cPos(active)} y1={m.top} y2={m.top + plotH} className=\"stroke-muted-foreground\" strokeDasharray=\"3 3\" strokeWidth={1} />;\n\n const activeDots =\n active == null || kind === \"bar\"\n ? null\n : live.map((s, j) => {\n const p = spans[j][active];\n if (!p) return null;\n return <circle key={s.key} cx={cPos(active)} cy={vPos(p[1])} r={4.5} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={2} />;\n });\n\n return (\n <div className={cx(\"grid min-w-0 gap-3\", className)}>\n <div\n ref={boxRef}\n tabIndex={0}\n role=\"group\"\n aria-label={`${label}. Arrow keys read each value.`}\n onKeyDown={onKeyDown}\n onFocus={(e) => {\n if (e.currentTarget.matches(\":focus-visible\") && active == null) {\n setKeyed(true);\n setActive(0);\n }\n }}\n onBlur={() => {\n setKeyed(false);\n setActive(null);\n }}\n className=\"relative min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n style={{ height }}\n >\n {width > 0 ? (\n <svg\n role=\"img\"\n aria-label={`${label}. ${description ?? summary}`}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n className={cx(\"block touch-pan-y select-none overflow-visible\", onSelect && \"cursor-pointer\")}\n onPointerMove={(e) => {\n setKeyed(false);\n setActive(indexAt(e));\n }}\n onPointerDown={(e) => setActive(indexAt(e))}\n onPointerLeave={() => !keyed && setActive(null)}\n onClick={(e) => onSelect?.(rows[indexAt(e)], indexAt(e))}\n >\n {/* raw: an SVG drawing — no kit part draws a chart. */}\n {cursor}\n {grid\n ? ticks.map((t) => {\n const p = f(vPos(t));\n return horizontal ? (\n <line key={t} x1={p} x2={p} y1={m.top} y2={m.top + plotH} className=\"stroke-border\" strokeDasharray={t === 0 ? undefined : \"3 3\"} />\n ) : (\n <line key={t} x1={m.left} x2={m.left + plotW} y1={p} y2={p} className=\"stroke-border\" strokeDasharray={t === 0 ? undefined : \"3 3\"} />\n );\n })\n : null}\n {marks}\n {activeDots}\n <g aria-hidden=\"true\" className=\"fill-muted-foreground text-2xs\" data-nums>\n {/* The value axis. */}\n {(horizontal ? xAxis : yAxis)\n ? ticks.map((t, i) =>\n horizontal ? (\n <text key={t} x={vPos(t)} y={height - 6} textAnchor=\"middle\">\n {tickNames[i]}\n </text>\n ) : (\n <text key={t} x={m.left - 8} y={vPos(t)} dy=\"0.32em\" textAnchor=\"end\">\n {tickNames[i]}\n </text>\n ),\n )\n : null}\n {/* The category axis. */}\n {(horizontal ? yAxis : xAxis)\n ? xNames.map((name, i) => {\n if (i % labelEvery !== 0) return null;\n if (horizontal) {\n return (\n <text key={i} x={m.left - 8} y={cPos(i)} dy=\"0.32em\" textAnchor=\"end\">\n {name}\n </text>\n );\n }\n return (\n <text key={i} x={cPos(i)} y={height - 6} textAnchor=\"middle\">\n {name}\n </text>\n );\n })\n : null}\n </g>\n </svg>\n ) : null}\n {tipAt ? (\n <TooltipBox\n at={tipAt}\n title={xNames[active]}\n rows={tipRows}\n footer={stack && live.length > 1 ? { label: \"Total\", value: fmt(total) } : null}\n />\n ) : null}\n <div className=\"sr-only\" aria-live=\"polite\">\n {keyed ? announce : \"\"}\n </div>\n </div>\n {live.length === 0 ? <p className=\"text-center text-xs text-muted-foreground\">Every series is hidden. Press one below to show it.</p> : null}\n {legendNode}\n <DataTable\n caption={label}\n head={[xLabel ?? humanize(x), ...all.map((s) => s.label)]}\n body={rows.map((r, i) => [xNames[i], ...all.map((s) => fmt(num(r?.[s.key])))])}\n />\n </div>\n );\n}\n\n/** Values over an ordered x (time): lines, optionally curved or stepped. */\nexport function LineChart(props) {\n return <Cartesian {...props} kind=\"line\" />;\n}\n\n/** Lines filled to zero; `stacked` piles the series into a total. */\nexport function AreaChart(props) {\n return <Cartesian {...props} kind=\"area\" />;\n}\n\n/** Bars per category: grouped side by side, or `stacked`; columns\n * (`orientation=\"vertical\"`, the default) or bars (`\"horizontal\"`). */\nexport function BarChart(props) {\n return <Cartesian {...props} kind=\"bar\" />;\n}\n"
28
+ "content": "\"use client\";\n\nimport { useId, useMemo, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport {\n ChartEmpty,\n ChartLegend,\n DataTable,\n TooltipBox,\n defaultFormat,\n humanize,\n idFrom,\n niceTicks,\n num,\n runs,\n textWidth,\n tone,\n toneOf,\n useReveal,\n useWidth,\n} from \"./core\";\n\n// LineChart, AreaChart and BarChart — one drawing, three marks. Rows in,\n// one x key, the series to draw; the block owns the rest: the scale and its\n// round ticks, gridlines, labels thinned to fit, a width that follows the\n// container, the tooltip and crosshair under a pointer or the arrow keys,\n// the legend that hides a series, the reveal (none with reduced motion),\n// and the words and table a screen reader gets instead of the picture.\n\n// ---- paths -------------------------------------------------------------------\n\nconst f = (n) => Math.round(n * 100) / 100;\n\nfunction linear(pts) {\n return pts.map(([x, y], i) => `${i ? \"L\" : \"M\"}${f(x)},${f(y)}`).join(\"\");\n}\n\nfunction step(pts) {\n let d = `M${f(pts[0][0])},${f(pts[0][1])}`;\n for (let i = 1; i < pts.length; i++) {\n const mid = (pts[i - 1][0] + pts[i][0]) / 2;\n d += `H${f(mid)}V${f(pts[i][1])}H${f(pts[i][0])}`;\n }\n return d;\n}\n\n// Monotone cubic (Fritsch–Carlson): smooth, and never overshoots a point,\n// so a curve does not invent a dip below zero or a peak that was not there.\nfunction monotone(pts) {\n const n = pts.length;\n if (n < 3) return linear(pts);\n const dx = [];\n const m = [];\n for (let i = 0; i < n - 1; i++) {\n dx[i] = pts[i + 1][0] - pts[i][0];\n m[i] = dx[i] === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / dx[i];\n }\n const t = new Array(n);\n t[0] = m[0];\n t[n - 1] = m[n - 2];\n for (let i = 1; i < n - 1; i++) {\n t[i] = m[i - 1] * m[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / m[i - 1] + (dx[i] + 2 * dx[i - 1]) / m[i]);\n }\n let d = `M${f(pts[0][0])},${f(pts[0][1])}`;\n for (let i = 0; i < n - 1; i++) {\n const h = dx[i] / 3;\n d += `C${f(pts[i][0] + h)},${f(pts[i][1] + t[i] * h)} ${f(pts[i + 1][0] - h)},${f(pts[i + 1][1] - t[i + 1] * h)} ${f(pts[i + 1][0])},${f(pts[i + 1][1])}`;\n }\n return d;\n}\n\nfunction curvePath(pts, curve) {\n if (!pts.length) return \"\";\n if (curve === \"step\") return step(pts);\n if (curve === \"linear\") return linear(pts);\n return monotone(pts);\n}\n\n// A bar with its outer end rounded (the end away from zero).\nfunction barPath(x, y, w, h, r, side) {\n const q = Math.max(0, Math.min(r, w / 2, h / 2));\n if (!q || !side) return `M${f(x)},${f(y)}h${f(w)}v${f(h)}h${f(-w)}Z`;\n const X = f(x);\n const Y = f(y);\n if (side === \"top\") return `M${X},${f(y + h)}V${f(y + q)}Q${X},${Y} ${f(x + q)},${Y}H${f(x + w - q)}Q${f(x + w)},${Y} ${f(x + w)},${f(y + q)}V${f(y + h)}Z`;\n if (side === \"bottom\") return `M${X},${Y}V${f(y + h - q)}Q${X},${f(y + h)} ${f(x + q)},${f(y + h)}H${f(x + w - q)}Q${f(x + w)},${f(y + h)} ${f(x + w)},${f(y + h - q)}V${Y}Z`;\n if (side === \"right\") return `M${X},${Y}H${f(x + w - q)}Q${f(x + w)},${Y} ${f(x + w)},${f(y + q)}V${f(y + h - q)}Q${f(x + w)},${f(y + h)} ${f(x + w - q)},${f(y + h)}H${X}Z`;\n return `M${f(x + w)},${Y}H${f(x + q)}Q${X},${Y} ${X},${f(y + q)}V${f(y + h - q)}Q${X},${f(y + h)} ${f(x + q)},${f(y + h)}H${f(x + w)}Z`;\n}\n\n// ---- the chart ----------------------------------------------------------------\n\nfunction Cartesian({\n kind,\n data,\n x,\n series,\n format = defaultFormat,\n formatAxis,\n formatX = (v) => (v == null ? \"\" : String(v)),\n label = \"Chart\",\n description,\n xLabel,\n height = 240,\n stacked = false,\n orientation = \"vertical\",\n valueLabels = false,\n track = false,\n curve = \"monotone\",\n dots = false,\n grid = true,\n xAxis = true,\n yAxis = true,\n legend,\n tooltip = true,\n onSelect,\n emptyLabel = \"No data yet\",\n emptyBody,\n className,\n}) {\n const uid = idFrom(useId());\n const fmt = (v) => (v == null ? \"—\" : format(v));\n const [boxRef, width] = useWidth();\n const [hidden, setHidden] = useState(() => new Set());\n const [active, setActive] = useState(null);\n const [keyed, setKeyed] = useState(false);\n\n const rows = Array.isArray(data) ? data : [];\n // A `muted` series with no tone of its own is the grey: a comparison\n // (last period) that sits behind the series it is compared with.\n const all = useMemo(\n () => (series ?? []).map((s, i) => ({ ...s, label: s.label ?? humanize(s.key), tone: s.muted && s.tone == null ? \"muted\" : toneOf(s, i) })),\n [series],\n );\n const live = all.filter((s) => !hidden.has(s.key));\n // Lines and areas draw muted series first, so they sit underneath.\n const order = live.map((_, j) => j).sort((a, b) => Number(Boolean(live[b].muted)) - Number(Boolean(live[a].muted)));\n const horizontal = kind === \"bar\" && orientation === \"horizontal\";\n const stack = stacked && kind !== \"line\";\n const axisFormat = formatAxis ?? format;\n const showLegend = legend ?? all.length > 1;\n const shown = useReveal(width > 0);\n\n // Each series' [from, to] per row: stacked marks start where the one\n // below ended (positives up, negatives down); the rest start at zero.\n const spans = useMemo(() => {\n const up = rows.map(() => 0);\n const down = rows.map(() => 0);\n return live.map((s) =>\n rows.map((r, i) => {\n const v = num(r?.[s.key]);\n if (v == null) return null;\n if (!stack) return [0, v];\n if (v >= 0) {\n const from = up[i];\n up[i] += v;\n return [from, up[i]];\n }\n const from = down[i];\n down[i] += v;\n return [from, down[i]];\n }),\n );\n }, [rows, live, stack]); // eslint-disable-line react-hooks/exhaustive-deps\n\n let lo = 0;\n let hi = 0;\n for (const s of spans) for (const p of s) if (p) (lo = Math.min(lo, p[0], p[1])), (hi = Math.max(hi, p[0], p[1]));\n const valueLen = horizontal ? Math.max(0, width - 16) : height - 32;\n const ticks = niceTicks(lo, hi, Math.max(2, Math.min(6, Math.floor(valueLen / (horizontal ? 80 : 48)))));\n const vMin = ticks[0];\n const vMax = ticks[ticks.length - 1];\n\n const xNames = rows.map((r) => formatX(r?.[x], r));\n const tickNames = ticks.map((t) => axisFormat(t));\n\n // BarChart's value labels: one at the outer end of every bar, or of every\n // stack (its total). [{ i, j, v, text }]; j is the series (-1: a stack).\n const barLabels = [];\n if (kind === \"bar\" && valueLabels && live.length) {\n const say = (v, row, key) => (typeof valueLabels === \"function\" ? valueLabels(v, row, key) : fmt(v));\n rows.forEach((row, i) => {\n if (stack) {\n const vals = live.map((s) => num(row?.[s.key])).filter((v) => v != null);\n if (!vals.length) return;\n const v = vals.reduce((a, b) => a + b, 0);\n barLabels.push({ i, j: -1, v, text: say(v, row, null) });\n } else {\n live.forEach((s, j) => {\n const v = num(row?.[s.key]);\n if (v != null) barLabels.push({ i, j, v, text: say(v, row, s.key) });\n });\n }\n });\n }\n const labelRoom = barLabels.length ? Math.max(...barLabels.map((l) => textWidth(l.text))) + 6 : 0;\n\n // Margins from what the labels need.\n const catW = horizontal && yAxis ? Math.min(Math.max(...xNames.map(textWidth), 24) + 8, width * 0.4) : 0;\n const valW = !horizontal && yAxis ? Math.max(...tickNames.map(textWidth)) + 8 : 0;\n const m = {\n top: barLabels.length && !horizontal ? 22 : 8,\n right: horizontal ? Math.max(8, textWidth(tickNames[tickNames.length - 1]) / 2, labelRoom) : 8,\n bottom: xAxis ? 24 : 4,\n left: horizontal ? catW : valW,\n };\n // Points sit on the plot's edges (bars sit inside their band), so the\n // first and last x labels, centred on them, need room outside it.\n if (kind !== \"bar\" && xAxis && rows.length) {\n m.left = Math.max(m.left, textWidth(xNames[0]) / 2);\n m.right = Math.max(m.right, textWidth(xNames[xNames.length - 1]) / 2);\n }\n const plotW = Math.max(0, width - m.left - m.right);\n const plotH = Math.max(0, height - m.top - m.bottom);\n const n = rows.length;\n\n // Value → pixel along the value axis; index → pixel along the category axis.\n const vPos = (v) => (horizontal ? m.left + ((v - vMin) / (vMax - vMin || 1)) * plotW : m.top + plotH - ((v - vMin) / (vMax - vMin || 1)) * plotH);\n const band = (horizontal ? plotH : plotW) / Math.max(1, n);\n const cPos =\n kind === \"bar\"\n ? (i) => (horizontal ? m.top : m.left) + band * (i + 0.5)\n : (i) => m.left + (n === 1 ? plotW / 2 : (i * plotW) / (n - 1));\n const zero = vPos(Math.max(vMin, Math.min(0, vMax)));\n\n // Category labels thinned so they never collide.\n const labelEvery = horizontal\n ? Math.max(1, Math.ceil(16 / Math.max(1, band)))\n : Math.max(1, Math.ceil((Math.max(...xNames.map(textWidth), 0) + 12) / Math.max(1, kind === \"bar\" ? band : plotW / Math.max(1, n - 1))));\n\n function indexAt(e) {\n const rect = e.currentTarget.getBoundingClientRect();\n const p = horizontal ? e.clientY - rect.top : e.clientX - rect.left;\n if (kind === \"bar\") return Math.max(0, Math.min(n - 1, Math.floor((p - (horizontal ? m.top : m.left)) / band)));\n if (n === 1) return 0;\n return Math.max(0, Math.min(n - 1, Math.round(((p - m.left) / plotW) * (n - 1))));\n }\n\n function onKeyDown(e) {\n if (!n) return;\n const back = horizontal ? \"ArrowUp\" : \"ArrowLeft\";\n const fwd = horizontal ? \"ArrowDown\" : \"ArrowRight\";\n let next = null;\n if (e.key === fwd || e.key === (horizontal ? \"ArrowRight\" : \"ArrowDown\")) next = active == null ? 0 : Math.min(n - 1, active + 1);\n else if (e.key === back || e.key === (horizontal ? \"ArrowLeft\" : \"ArrowUp\")) next = active == null ? n - 1 : Math.max(0, active - 1);\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = n - 1;\n else if (e.key === \"Escape\") {\n setActive(null);\n return;\n } else if ((e.key === \"Enter\" || e.key === \" \") && onSelect && active != null) {\n e.preventDefault();\n onSelect(rows[active], active);\n return;\n }\n if (next == null) return;\n e.preventDefault();\n setKeyed(true);\n setActive(next);\n }\n\n // ---- words -------------------------------------------------------------------\n\n const summary = useMemo(() => {\n if (!n || !live.length) return \"\";\n const parts = live.map((s) => {\n const vals = rows.map((r) => num(r?.[s.key]));\n const idx = vals.reduce((best, v, i) => (v != null && (best < 0 || v > vals[best]) ? i : best), -1);\n if (idx < 0) return `${s.label}: no data`;\n const first = vals.find((v) => v != null);\n const last = [...vals].reverse().find((v) => v != null);\n return `${s.label}: ${kind === \"bar\" ? \"\" : `from ${fmt(first)} to ${fmt(last)}, `}highest ${fmt(vals[idx])} at ${xNames[idx]}`;\n });\n return `${n} ${n === 1 ? \"point\" : \"points\"}, ${xNames[0]} to ${xNames[n - 1]}. ${parts.join(\"; \")}.`;\n }, [rows, live, n]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const tipRows =\n active == null\n ? []\n : live.map((s) => ({ key: s.key, label: s.label, tone: s.tone, dashed: s.dashed, value: fmt(num(rows[active]?.[s.key])) }));\n // The stack's total: what there is, or none (—) when every value is missing.\n const total =\n active == null\n ? null\n : live.reduce((sum, s) => {\n const v = num(rows[active]?.[s.key]);\n return v == null ? sum : (sum ?? 0) + v;\n }, null);\n const announce = active == null ? \"\" : `${xNames[active]}: ${tipRows.map((r) => `${r.label} ${r.value}`).join(\", \")}`;\n\n const legendNode = showLegend ? (\n <ChartLegend\n items={all}\n hidden={hidden}\n onToggle={(key) =>\n setHidden((h) => {\n const next = new Set(h);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n })\n }\n label={`${label}: series`}\n />\n ) : null;\n\n if (!n) {\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <ChartEmpty height={height} title={emptyLabel} body={emptyBody} />\n </div>\n );\n }\n\n // ---- marks -------------------------------------------------------------------\n\n const tipAt =\n active == null || !tooltip\n ? null\n : horizontal\n ? { x: Math.min(width - 12, Math.max(...live.map((_, k) => vPos(spans[k][active]?.[1] ?? 0)), zero)), y: cPos(active), flip: false, middle: true }\n : { x: cPos(active), y: m.top, flip: cPos(active) > width / 2 };\n if (tipAt && horizontal) tipAt.flip = tipAt.x > width * 0.6;\n\n const revealStyle = (axis) => ({\n transform: shown ? \"none\" : axis === \"x\" ? \"scaleX(0)\" : \"scaleY(0)\",\n transformOrigin: axis === \"x\" ? `${zero}px 0` : `0 ${zero}px`,\n });\n\n let marks = null;\n if (kind === \"bar\") {\n const k = live.length;\n const groupW = Math.min(band * (stack ? 0.62 : 0.8), stack ? 56 : 28 * Math.max(1, k) + 4 * (k - 1));\n const gap = k > 1 && !stack ? Math.min(4, groupW * 0.06) : 0;\n const barW = stack ? groupW : Math.max(1, (groupW - gap * (k - 1)) / Math.max(1, k));\n const r = Math.min(4, barW / 3);\n // Which series ends each stack (its outer end is the rounded one).\n const outer = rows.map((_, i) => {\n let top = -1;\n let bottom = -1;\n live.forEach((s, j) => {\n const p = spans[j][i];\n if (!p || p[1] === p[0]) return;\n if (p[1] > p[0]) top = j;\n else bottom = j;\n });\n return { top, bottom };\n });\n // Where the stacked total ends: the far end of the stack on its side.\n const stackEnd = (i, v) => {\n let end = 0;\n live.forEach((_, j) => {\n const p = spans[j][i];\n if (p) end = v >= 0 ? Math.max(end, p[1]) : Math.min(end, p[1]);\n });\n return end;\n };\n const tracks = track ? (\n <g aria-hidden=\"true\" className=\"fill-accent\">\n {rows.map((_, i) => {\n const c = cPos(i) - groupW / 2;\n return horizontal ? (\n <rect key={i} x={m.left} y={c} width={plotW} height={groupW} rx={r} />\n ) : (\n <rect key={i} x={c} y={m.top} width={groupW} height={plotH} rx={r} />\n );\n })}\n </g>\n ) : null;\n const labels = barLabels.length ? (\n <g\n aria-hidden=\"true\"\n className=\"fill-foreground text-2xs font-medium motion-safe:transition-opacity motion-safe:duration-500\"\n style={{ opacity: shown ? 1 : 0 }}\n data-nums\n >\n {barLabels.map(({ i, j, v, text }) => {\n const end = vPos(j < 0 ? stackEnd(i, v) : v);\n const c = cPos(i) + (j < 0 ? 0 : -groupW / 2 + j * (barW + gap) + barW / 2);\n const up = v >= 0;\n return horizontal ? (\n <text key={`${i}-${j}`} x={end + (up ? 6 : -6)} y={c} dy=\"0.32em\" textAnchor={up ? \"start\" : \"end\"}>\n {text}\n </text>\n ) : (\n <text key={`${i}-${j}`} x={c} y={end + (up ? -6 : 12)} textAnchor=\"middle\">\n {text}\n </text>\n );\n })}\n </g>\n ) : null;\n marks = (\n <>\n {tracks}\n <g style={revealStyle(horizontal ? \"x\" : \"y\")} className=\"motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out\">\n {live.map((s, j) => (\n <g key={s.key} className={tone(s.tone, \"fill\")}>\n {rows.map((_, i) => {\n const p = spans[j][i];\n if (!p || p[0] === p[1]) return null;\n const a = vPos(p[0]);\n const b = vPos(p[1]);\n const offset = stack ? -groupW / 2 : -groupW / 2 + j * (barW + gap);\n const c = cPos(i) + offset;\n const positive = p[1] > p[0];\n const isOuter = !stack || (positive ? outer[i].top === j : outer[i].bottom === j);\n const side = !isOuter ? null : horizontal ? (positive ? \"right\" : \"left\") : positive ? \"top\" : \"bottom\";\n const d = horizontal\n ? barPath(Math.min(a, b), c, Math.abs(b - a), barW, r, side)\n : barPath(c, Math.min(a, b), barW, Math.abs(b - a), r, side);\n return <path key={i} d={d} className={cx(active != null && active !== i && \"opacity-60\", \"motion-safe:transition-opacity\")} />;\n })}\n </g>\n ))}\n </g>\n {labels}\n </>\n );\n } else {\n const clip = `${uid}-clip`;\n const fade = `${uid}-fade`;\n marks = (\n <>\n <defs>\n <clipPath id={clip}>\n <rect\n x={m.left - 4}\n y={0}\n width={plotW + 8}\n height={height}\n style={{ transform: shown ? \"none\" : \"scaleX(0)\", transformOrigin: `${m.left}px 0` }}\n className=\"motion-safe:transition-transform motion-safe:duration-700 motion-safe:ease-out\"\n />\n </clipPath>\n {/* An alpha mask, not a colour: the area fades toward zero. */}\n <linearGradient id={`${fade}-g`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopOpacity=\"1\" />\n <stop offset=\"1\" stopOpacity=\"0.15\" />\n </linearGradient>\n <mask id={fade} style={{ maskType: \"alpha\" }} maskUnits=\"userSpaceOnUse\" x={0} y={0} width={width} height={height}>\n <rect x={0} y={m.top} width={width} height={plotH} fill={`url(#${fade}-g)`} />\n </mask>\n </defs>\n <g clipPath={`url(#${clip})`}>\n {kind === \"area\"\n ? order.map((j) => {\n const s = live[j];\n // A dashed series is a line only: a comparison has no fill.\n if (s.dashed && !stack) return null;\n // One band per run of values: an area breaks where a value\n // is missing, as its line does — never down to zero.\n const d = runs(spans[j].map((p, i) => (p ? { x: cPos(i), top: vPos(p[1]), base: vPos(p[0]) } : null)))\n .filter((run) => run.length > 1)\n .map((run) => {\n const back = curvePath(run.map((q) => [q.x, q.base]).reverse(), curve).replace(/^M/, \"L\");\n return `${curvePath(run.map((q) => [q.x, q.top]), curve)}${back}Z`;\n })\n .join(\"\");\n if (!d) return null;\n return (\n <path\n key={s.key}\n d={d}\n className={tone(s.tone, \"fill\")}\n fillOpacity={stack ? 0.4 : s.muted ? 0.2 : 0.45}\n mask={stack ? undefined : `url(#${fade})`}\n />\n );\n })\n : null}\n {order.map((j) => {\n const s = live[j];\n // A line (and an area's edge) breaks where a value is missing;\n // a value alone between two gaps is a dot, so it still shows.\n const segs = runs(spans[j].map((p, i) => (p ? [cPos(i), vPos(p[1])] : null)));\n return (\n <g\n key={s.key}\n className={tone(s.tone, \"stroke\")}\n fill=\"none\"\n strokeWidth={s.muted ? 1.5 : 2}\n strokeDasharray={s.dashed ? \"5 4\" : undefined}\n strokeLinejoin=\"round\"\n strokeLinecap={s.dashed ? \"butt\" : \"round\"}\n >\n {segs.map((seg, k) =>\n seg.length > 1 ? (\n <path key={k} d={curvePath(seg, curve)} />\n ) : dots ? null : (\n <circle key={k} cx={seg[0][0]} cy={seg[0][1]} r={3} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={1.5} strokeDasharray=\"none\" />\n ),\n )}\n {dots\n ? spans[j].map((p, i) =>\n p ? <circle key={i} cx={cPos(i)} cy={vPos(p[1])} r={3} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={1.5} strokeDasharray=\"none\" /> : null,\n )\n : null}\n </g>\n );\n })}\n </g>\n </>\n );\n }\n\n const cursor =\n active == null\n ? null\n : kind === \"bar\"\n ? horizontal\n ? <rect x={m.left} y={m.top + band * active} width={plotW} height={band} className=\"fill-muted\" />\n : <rect x={m.left + band * active} y={m.top} width={band} height={plotH} className=\"fill-muted\" />\n : <line x1={cPos(active)} x2={cPos(active)} y1={m.top} y2={m.top + plotH} className=\"stroke-muted-foreground\" strokeDasharray=\"3 3\" strokeWidth={1} />;\n\n const activeDots =\n active == null || kind === \"bar\"\n ? null\n : live.map((s, j) => {\n const p = spans[j][active];\n if (!p) return null;\n return <circle key={s.key} cx={cPos(active)} cy={vPos(p[1])} r={4.5} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={2} />;\n });\n\n return (\n <div className={cx(\"relative grid min-w-0 gap-3\", className)}>\n <div\n ref={boxRef}\n tabIndex={0}\n role=\"group\"\n aria-label={`${label}. Arrow keys read each value.`}\n onKeyDown={onKeyDown}\n onFocus={(e) => {\n if (e.currentTarget.matches(\":focus-visible\") && active == null) {\n setKeyed(true);\n setActive(0);\n }\n }}\n onBlur={() => {\n setKeyed(false);\n setActive(null);\n }}\n className=\"relative min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n style={{ height }}\n >\n {width > 0 ? (\n <svg\n role=\"img\"\n aria-label={`${label}. ${description ?? summary}`}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n className={cx(\"block touch-pan-y select-none overflow-visible\", onSelect && \"cursor-pointer\")}\n onPointerMove={(e) => {\n setKeyed(false);\n setActive(indexAt(e));\n }}\n onPointerDown={(e) => setActive(indexAt(e))}\n onPointerLeave={() => !keyed && setActive(null)}\n onClick={(e) => onSelect?.(rows[indexAt(e)], indexAt(e))}\n >\n {/* raw: an SVG drawing — no kit part draws a chart. */}\n {cursor}\n {grid\n ? ticks.map((t) => {\n const p = f(vPos(t));\n return horizontal ? (\n <line key={t} x1={p} x2={p} y1={m.top} y2={m.top + plotH} className=\"stroke-border\" strokeDasharray={t === 0 ? undefined : \"3 3\"} />\n ) : (\n <line key={t} x1={m.left} x2={m.left + plotW} y1={p} y2={p} className=\"stroke-border\" strokeDasharray={t === 0 ? undefined : \"3 3\"} />\n );\n })\n : null}\n {marks}\n {activeDots}\n <g aria-hidden=\"true\" className=\"fill-muted-foreground text-2xs\" data-nums>\n {/* The value axis. */}\n {(horizontal ? xAxis : yAxis)\n ? ticks.map((t, i) =>\n horizontal ? (\n <text key={t} x={vPos(t)} y={height - 6} textAnchor=\"middle\">\n {tickNames[i]}\n </text>\n ) : (\n <text key={t} x={m.left - 8} y={vPos(t)} dy=\"0.32em\" textAnchor=\"end\">\n {tickNames[i]}\n </text>\n ),\n )\n : null}\n {/* The category axis. */}\n {(horizontal ? yAxis : xAxis)\n ? xNames.map((name, i) => {\n if (i % labelEvery !== 0) return null;\n if (horizontal) {\n return (\n <text key={i} x={m.left - 8} y={cPos(i)} dy=\"0.32em\" textAnchor=\"end\">\n {name}\n </text>\n );\n }\n return (\n <text key={i} x={cPos(i)} y={height - 6} textAnchor=\"middle\">\n {name}\n </text>\n );\n })\n : null}\n </g>\n </svg>\n ) : null}\n {tipAt ? (\n <TooltipBox\n at={tipAt}\n title={xNames[active]}\n rows={tipRows}\n footer={stack && live.length > 1 ? { label: \"Total\", value: fmt(total) } : null}\n />\n ) : null}\n <div className=\"sr-only\" aria-live=\"polite\">\n {keyed ? announce : \"\"}\n </div>\n </div>\n {live.length === 0 ? <p className=\"text-center text-xs text-muted-foreground\">Every series is hidden. Press one below to show it.</p> : null}\n {legendNode}\n <DataTable\n caption={label}\n head={[xLabel ?? humanize(x), ...all.map((s) => s.label)]}\n body={rows.map((r, i) => [xNames[i], ...all.map((s) => fmt(num(r?.[s.key])))])}\n />\n </div>\n );\n}\n\n/** Values over an ordered x (time): lines, optionally curved or stepped. */\nexport function LineChart(props) {\n return <Cartesian {...props} kind=\"line\" />;\n}\n\n/** Lines filled to zero; `stacked` piles the series into a total. */\nexport function AreaChart(props) {\n return <Cartesian {...props} kind=\"area\" />;\n}\n\n/** Bars per category: grouped side by side, or `stacked`; columns\n * (`orientation=\"vertical\"`, the default) or bars (`\"horizontal\"`). */\nexport function BarChart(props) {\n return <Cartesian {...props} kind=\"bar\" />;\n}\n"
29
29
  },
30
30
  {
31
31
  "path": "registry/blocks/chart/pie.jsx",
32
32
  "type": "registry:component",
33
33
  "target": "@components/makersclaw/blocks/chart/pie.jsx",
34
- "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport { ChartEmpty, ChartLegend, DataTable, TooltipBox, defaultFormat, humanize, num, tone, toneOf, useReveal, useWidth } from \"./core\";\n\n// PieChart — parts of one whole, one slice per row; `donut` opens the\n// middle and writes the total (or your words) there. Pointer, touch or the\n// arrow keys pick a slice: it lifts, and the tooltip gives its value and\n// share. The legend hides a slice and the rest re-share the whole.\n\nconst TAU = Math.PI * 2;\n\nfunction point(cx0, cy0, r, a) {\n return [cx0 + r * Math.cos(a), cy0 + r * Math.sin(a)];\n}\n\nconst f = (n) => Math.round(n * 100) / 100;\n\nfunction slicePath(c, r, inner, a0, a1) {\n // A whole circle cannot be one arc: draw it as two halves.\n if (a1 - a0 >= TAU - 1e-6) {\n const mid = a0 + Math.PI;\n return slicePath(c, r, inner, a0, mid) + slicePath(c, r, inner, mid, a1);\n }\n const large = a1 - a0 > Math.PI ? 1 : 0;\n const [x0, y0] = point(c.x, c.y, r, a0);\n const [x1, y1] = point(c.x, c.y, r, a1);\n if (!inner) return `M${f(c.x)},${f(c.y)}L${f(x0)},${f(y0)}A${f(r)},${f(r)} 0 ${large} 1 ${f(x1)},${f(y1)}Z`;\n const [x2, y2] = point(c.x, c.y, inner, a1);\n const [x3, y3] = point(c.x, c.y, inner, a0);\n return `M${f(x0)},${f(y0)}A${f(r)},${f(r)} 0 ${large} 1 ${f(x1)},${f(y1)}L${f(x2)},${f(y2)}A${f(inner)},${f(inner)} 0 ${large} 0 ${f(x3)},${f(y3)}Z`;\n}\n\nexport function PieChart({\n data,\n value = \"value\",\n name = \"name\",\n format = defaultFormat,\n formatName = (v) => (v == null ? \"\" : String(v)),\n label = \"Chart\",\n description,\n height = 240,\n donut = false,\n centerLabel,\n totalLabel = \"Total\",\n legend = true,\n legendValues = \"percent\",\n tooltip = true,\n onSelect,\n emptyLabel = \"No data yet\",\n emptyBody,\n className,\n}) {\n const [boxRef, width] = useWidth();\n const [hidden, setHidden] = useState(() => new Set());\n const [active, setActive] = useState(null);\n const [keyed, setKeyed] = useState(false);\n const shown = useReveal(width > 0);\n\n const rows = Array.isArray(data) ? data : [];\n const slices = useMemo(\n () =>\n rows.map((r, i) => {\n const v = num(r?.[value]);\n return {\n row: r,\n key: String(r?.[name] ?? i),\n label: formatName(r?.[name], r) || humanize(r?.[name]),\n tone: toneOf(r, i),\n // A missing value is no slice and reads —, never 0 or 0%.\n value: v == null ? null : Math.max(0, v),\n };\n }),\n [rows, name, value], // eslint-disable-line react-hooks/exhaustive-deps\n );\n const live = slices.filter((s) => !hidden.has(s.key) && s.value > 0);\n const total = live.reduce((sum, s) => sum + s.value, 0);\n const pct = (v) => (v == null ? \"—\" : total ? `${Math.round((v / total) * 1000) / 10}%` : \"0%\");\n const fmt = (v) => (v == null ? \"—\" : format(v));\n\n const size = Math.max(0, Math.min(width, height));\n const c = { x: width / 2, y: height / 2 };\n const r = Math.max(0, size / 2 - 6);\n const inner = donut ? r * 0.62 : 0;\n\n let a = -Math.PI / 2;\n const arcs = live.map((s) => {\n const span = total ? (s.value / total) * TAU : 0;\n // A gap between slices by angle, not a stroke: no guess at the colour\n // of whatever the chart sits on.\n const pad = live.length > 1 ? Math.min(0.012, span / 4) : 0;\n const out = { ...s, a0: a, a1: a + span, pad };\n a += span;\n return out;\n });\n\n function onKeyDown(e) {\n if (!arcs.length) return;\n let next = null;\n if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = active == null ? 0 : (active + 1) % arcs.length;\n else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = active == null ? arcs.length - 1 : (active - 1 + arcs.length) % arcs.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = arcs.length - 1;\n else if (e.key === \"Escape\") return setActive(null);\n else if ((e.key === \"Enter\" || e.key === \" \") && onSelect && active != null) {\n e.preventDefault();\n onSelect(arcs[active].row, active);\n return;\n }\n if (next == null) return;\n e.preventDefault();\n setKeyed(true);\n setActive(next);\n }\n\n const summary = arcs.length\n ? `${arcs.length} parts of ${format(total)}: ${arcs\n .slice()\n .sort((p, q) => q.value - p.value)\n .map((s) => `${s.label} ${format(s.value)} (${pct(s.value)})`)\n .join(\", \")}.`\n : \"\";\n\n const legendNode = legend ? (\n <ChartLegend\n items={slices.map((s) => ({\n key: s.key,\n label: s.label,\n tone: s.tone,\n value: hidden.has(s.key) ? null : legendValues === \"value\" ? fmt(s.value) : legendValues === \"percent\" ? pct(s.value) : null,\n }))}\n hidden={hidden}\n onToggle={(key) => {\n setActive(null);\n setHidden((h) => {\n const next = new Set(h);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n });\n }}\n label={`${label}: parts`}\n />\n ) : null;\n\n if (!rows.length) {\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <ChartEmpty height={height} title={emptyLabel} body={emptyBody} />\n </div>\n );\n }\n\n const hit = active != null ? arcs[active] : null;\n let tipAt = null;\n // A donut says it in its middle; a pie needs the box.\n if (hit && tooltip && !donut) {\n const mid = (hit.a0 + hit.a1) / 2;\n const [tx, ty] = point(c.x, c.y, (r + inner) / 2 + (donut ? 0 : r * 0.15), mid);\n tipAt = { x: tx, y: ty, flip: tx > c.x, middle: true };\n }\n const centre =\n centerLabel !== undefined\n ? typeof centerLabel === \"object\" && centerLabel !== null && !(\"$$typeof\" in centerLabel)\n ? centerLabel\n : { value: centerLabel }\n : { value: format(total), label: totalLabel };\n\n return (\n <div className={cx(\"grid min-w-0 gap-3\", className)}>\n <div\n ref={boxRef}\n tabIndex={0}\n role=\"group\"\n aria-label={`${label}. Arrow keys read each part.`}\n onKeyDown={onKeyDown}\n onFocus={(e) => {\n if (e.currentTarget.matches(\":focus-visible\") && active == null && arcs.length) {\n setKeyed(true);\n setActive(0);\n }\n }}\n onBlur={() => {\n setKeyed(false);\n setActive(null);\n }}\n className=\"relative min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n style={{ height }}\n >\n {width > 0 ? (\n <svg\n role=\"img\"\n aria-label={`${label}. ${description ?? summary}`}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n className={cx(\"block touch-pan-y select-none\", onSelect && \"cursor-pointer\")}\n onPointerLeave={() => !keyed && setActive(null)}\n >\n {/* raw: an SVG drawing — no kit part draws a chart. */}\n {arcs.length === 0 ? (\n <circle cx={c.x} cy={c.y} r={r} className=\"fill-muted\" />\n ) : null}\n <g\n style={{ transform: shown ? \"none\" : \"rotate(-60deg) scale(0.9)\", transformOrigin: `${c.x}px ${c.y}px`, opacity: shown ? 1 : 0 }}\n className=\"motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out\"\n >\n {arcs.map((s, i) => (\n <path\n key={s.key}\n d={slicePath(c, active === i ? r + 4 : r, inner, s.a0 + s.pad, s.a1 - s.pad)}\n className={cx(tone(s.tone, \"fill\"), active != null && active !== i && \"opacity-60\", \"motion-safe:transition-opacity\")}\n onPointerEnter={() => {\n setKeyed(false);\n setActive(i);\n }}\n onPointerDown={() => setActive(i)}\n onClick={() => onSelect?.(s.row, i)}\n />\n ))}\n </g>\n {donut && centre ? (\n <g aria-hidden=\"true\" textAnchor=\"middle\">\n <text x={c.x} y={c.y} dy={centre.label ? \"-0.1em\" : \"0.35em\"} className=\"fill-foreground text-2xl font-semibold\" data-nums>\n {hit ? format(hit.value) : centre.value}\n </text>\n {centre.label || hit ? (\n <text x={c.x} y={c.y} dy=\"1.6em\" className=\"fill-muted-foreground text-xs\">\n {hit ? `${hit.label} · ${pct(hit.value)}` : centre.label}\n </text>\n ) : null}\n </g>\n ) : null}\n </svg>\n ) : null}\n {tipAt ? <TooltipBox at={tipAt} title={hit.label} rows={[{ key: hit.key, label: pct(hit.value), tone: hit.tone, value: format(hit.value) }]} /> : null}\n <div className=\"sr-only\" aria-live=\"polite\">\n {keyed && hit ? `${hit.label}: ${format(hit.value)}, ${pct(hit.value)}` : \"\"}\n </div>\n </div>\n {legendNode}\n <DataTable\n caption={label}\n head={[\"\", \"Value\", \"Share\"]}\n body={slices.map((s) => [s.label, fmt(s.value), hidden.has(s.key) ? \"hidden\" : pct(s.value)])}\n />\n </div>\n );\n}\n"
34
+ "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport { ChartEmpty, ChartLegend, DataTable, TooltipBox, defaultFormat, humanize, num, tone, toneOf, useReveal, useWidth } from \"./core\";\n\n// PieChart — parts of one whole, one slice per row; `donut` opens the\n// middle and writes the total (or your words) there. Pointer, touch or the\n// arrow keys pick a slice: it lifts, and the tooltip gives its value and\n// share. The legend hides a slice and the rest re-share the whole.\n\nconst TAU = Math.PI * 2;\n\nfunction point(cx0, cy0, r, a) {\n return [cx0 + r * Math.cos(a), cy0 + r * Math.sin(a)];\n}\n\nconst f = (n) => Math.round(n * 100) / 100;\n\nfunction slicePath(c, r, inner, a0, a1) {\n // A whole circle cannot be one arc: draw it as two halves.\n if (a1 - a0 >= TAU - 1e-6) {\n const mid = a0 + Math.PI;\n return slicePath(c, r, inner, a0, mid) + slicePath(c, r, inner, mid, a1);\n }\n const large = a1 - a0 > Math.PI ? 1 : 0;\n const [x0, y0] = point(c.x, c.y, r, a0);\n const [x1, y1] = point(c.x, c.y, r, a1);\n if (!inner) return `M${f(c.x)},${f(c.y)}L${f(x0)},${f(y0)}A${f(r)},${f(r)} 0 ${large} 1 ${f(x1)},${f(y1)}Z`;\n const [x2, y2] = point(c.x, c.y, inner, a1);\n const [x3, y3] = point(c.x, c.y, inner, a0);\n return `M${f(x0)},${f(y0)}A${f(r)},${f(r)} 0 ${large} 1 ${f(x1)},${f(y1)}L${f(x2)},${f(y2)}A${f(inner)},${f(inner)} 0 ${large} 0 ${f(x3)},${f(y3)}Z`;\n}\n\nexport function PieChart({\n data,\n value = \"value\",\n name = \"name\",\n format = defaultFormat,\n formatName = (v) => (v == null ? \"\" : String(v)),\n label = \"Chart\",\n description,\n height = 240,\n donut = false,\n centerLabel,\n totalLabel = \"Total\",\n legend = true,\n legendValues = \"percent\",\n tooltip = true,\n onSelect,\n emptyLabel = \"No data yet\",\n emptyBody,\n className,\n}) {\n const [boxRef, width] = useWidth();\n const [hidden, setHidden] = useState(() => new Set());\n const [active, setActive] = useState(null);\n const [keyed, setKeyed] = useState(false);\n const shown = useReveal(width > 0);\n\n const rows = Array.isArray(data) ? data : [];\n const slices = useMemo(\n () =>\n rows.map((r, i) => {\n const v = num(r?.[value]);\n return {\n row: r,\n key: String(r?.[name] ?? i),\n label: formatName(r?.[name], r) || humanize(r?.[name]),\n tone: toneOf(r, i),\n // A missing value is no slice and reads —, never 0 or 0%.\n value: v == null ? null : Math.max(0, v),\n };\n }),\n [rows, name, value], // eslint-disable-line react-hooks/exhaustive-deps\n );\n const live = slices.filter((s) => !hidden.has(s.key) && s.value > 0);\n const total = live.reduce((sum, s) => sum + s.value, 0);\n const pct = (v) => (v == null ? \"—\" : total ? `${Math.round((v / total) * 1000) / 10}%` : \"0%\");\n const fmt = (v) => (v == null ? \"—\" : format(v));\n\n const size = Math.max(0, Math.min(width, height));\n const c = { x: width / 2, y: height / 2 };\n const r = Math.max(0, size / 2 - 6);\n const inner = donut ? r * 0.62 : 0;\n\n let a = -Math.PI / 2;\n const arcs = live.map((s) => {\n const span = total ? (s.value / total) * TAU : 0;\n // A gap between slices by angle, not a stroke: no guess at the colour\n // of whatever the chart sits on.\n const pad = live.length > 1 ? Math.min(0.012, span / 4) : 0;\n const out = { ...s, a0: a, a1: a + span, pad };\n a += span;\n return out;\n });\n\n function onKeyDown(e) {\n if (!arcs.length) return;\n let next = null;\n if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = active == null ? 0 : (active + 1) % arcs.length;\n else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = active == null ? arcs.length - 1 : (active - 1 + arcs.length) % arcs.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = arcs.length - 1;\n else if (e.key === \"Escape\") return setActive(null);\n else if ((e.key === \"Enter\" || e.key === \" \") && onSelect && active != null) {\n e.preventDefault();\n onSelect(arcs[active].row, active);\n return;\n }\n if (next == null) return;\n e.preventDefault();\n setKeyed(true);\n setActive(next);\n }\n\n const summary = arcs.length\n ? `${arcs.length} parts of ${format(total)}: ${arcs\n .slice()\n .sort((p, q) => q.value - p.value)\n .map((s) => `${s.label} ${format(s.value)} (${pct(s.value)})`)\n .join(\", \")}.`\n : \"\";\n\n const legendNode = legend ? (\n <ChartLegend\n items={slices.map((s) => ({\n key: s.key,\n label: s.label,\n tone: s.tone,\n value: hidden.has(s.key) ? null : legendValues === \"value\" ? fmt(s.value) : legendValues === \"percent\" ? pct(s.value) : null,\n }))}\n hidden={hidden}\n onToggle={(key) => {\n setActive(null);\n setHidden((h) => {\n const next = new Set(h);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n });\n }}\n label={`${label}: parts`}\n />\n ) : null;\n\n if (!rows.length) {\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <ChartEmpty height={height} title={emptyLabel} body={emptyBody} />\n </div>\n );\n }\n\n const hit = active != null ? arcs[active] : null;\n let tipAt = null;\n // A donut says it in its middle; a pie needs the box.\n if (hit && tooltip && !donut) {\n const mid = (hit.a0 + hit.a1) / 2;\n const [tx, ty] = point(c.x, c.y, (r + inner) / 2 + (donut ? 0 : r * 0.15), mid);\n tipAt = { x: tx, y: ty, flip: tx > c.x, middle: true };\n }\n const centre =\n centerLabel !== undefined\n ? typeof centerLabel === \"object\" && centerLabel !== null && !(\"$$typeof\" in centerLabel)\n ? centerLabel\n : { value: centerLabel }\n : { value: format(total), label: totalLabel };\n\n return (\n <div className={cx(\"relative grid min-w-0 gap-3\", className)}>\n <div\n ref={boxRef}\n tabIndex={0}\n role=\"group\"\n aria-label={`${label}. Arrow keys read each part.`}\n onKeyDown={onKeyDown}\n onFocus={(e) => {\n if (e.currentTarget.matches(\":focus-visible\") && active == null && arcs.length) {\n setKeyed(true);\n setActive(0);\n }\n }}\n onBlur={() => {\n setKeyed(false);\n setActive(null);\n }}\n className=\"relative min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n style={{ height }}\n >\n {width > 0 ? (\n <svg\n role=\"img\"\n aria-label={`${label}. ${description ?? summary}`}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n className={cx(\"block touch-pan-y select-none\", onSelect && \"cursor-pointer\")}\n onPointerLeave={() => !keyed && setActive(null)}\n >\n {/* raw: an SVG drawing — no kit part draws a chart. */}\n {arcs.length === 0 ? (\n <circle cx={c.x} cy={c.y} r={r} className=\"fill-muted\" />\n ) : null}\n <g\n style={{ transform: shown ? \"none\" : \"rotate(-60deg) scale(0.9)\", transformOrigin: `${c.x}px ${c.y}px`, opacity: shown ? 1 : 0 }}\n className=\"motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out\"\n >\n {arcs.map((s, i) => (\n <path\n key={s.key}\n d={slicePath(c, active === i ? r + 4 : r, inner, s.a0 + s.pad, s.a1 - s.pad)}\n className={cx(tone(s.tone, \"fill\"), active != null && active !== i && \"opacity-60\", \"motion-safe:transition-opacity\")}\n onPointerEnter={() => {\n setKeyed(false);\n setActive(i);\n }}\n onPointerDown={() => setActive(i)}\n onClick={() => onSelect?.(s.row, i)}\n />\n ))}\n </g>\n {donut && centre ? (\n <g aria-hidden=\"true\" textAnchor=\"middle\">\n <text x={c.x} y={c.y} dy={centre.label ? \"-0.1em\" : \"0.35em\"} className=\"fill-foreground text-2xl font-semibold\" data-nums>\n {hit ? format(hit.value) : centre.value}\n </text>\n {centre.label || hit ? (\n <text x={c.x} y={c.y} dy=\"1.6em\" className=\"fill-muted-foreground text-xs\">\n {hit ? `${hit.label} · ${pct(hit.value)}` : centre.label}\n </text>\n ) : null}\n </g>\n ) : null}\n </svg>\n ) : null}\n {tipAt ? <TooltipBox at={tipAt} title={hit.label} rows={[{ key: hit.key, label: pct(hit.value), tone: hit.tone, value: format(hit.value) }]} /> : null}\n <div className=\"sr-only\" aria-live=\"polite\">\n {keyed && hit ? `${hit.label}: ${format(hit.value)}, ${pct(hit.value)}` : \"\"}\n </div>\n </div>\n {legendNode}\n <DataTable\n caption={label}\n head={[\"\", \"Value\", \"Share\"]}\n body={slices.map((s) => [s.label, fmt(s.value), hidden.has(s.key) ? \"hidden\" : pct(s.value)])}\n />\n </div>\n );\n}\n"
35
35
  },
36
36
  {
37
37
  "path": "registry/blocks/chart/sparkline.jsx",
@@ -49,7 +49,7 @@
49
49
  "path": "registry/blocks/chart/README.md",
50
50
  "type": "registry:file",
51
51
  "target": "@components/makersclaw/blocks/chart/README.md",
52
- "content": "# Chart\n\nNumbers as a shape: `LineChart`, `AreaChart` (stacked or not), `BarChart`\n(grouped or stacked, columns or bars), `PieChart` (a pie or a donut with\nthe total in its middle), `Sparkline` (a trend beside a KPI) and\n`HeatmapChart` (a grid of two dimensions, each cell shaded by its value,\nwith `ScaleLegend` as its key). Drawn in SVG (the heatmap as an HTML\ntable), no chart library, in the workspace's chart colours. Each one is as\nwide as its container and redraws when that changes. Hover, touch or the\narrow keys show a tooltip with a crosshair; the legend hides and shows a\nseries; a screen reader gets a summary and the numbers as a table; with\nreduced motion nothing animates.\n\n```jsx\nimport { AreaChart, BarChart, HeatmapChart, PieChart, Sparkline } from \"../components/blocks/chart\"; // a Makersclaw app (\"./blocks/chart\" from components/)\nimport { AreaChart, BarChart, HeatmapChart, PieChart, Sparkline } from \"@/components/makersclaw/blocks/chart\"; // a shadcn project\n```\n\nThey are client components: render them from a `\"use client\"` component,\nand load the rows in the server surface (README §4). Put a chart in a\n`Card` — the card gives the title; the chart is only the picture.\n\n## The data\n\nRows, as `loadRows` returns them. One key holds the x (a month, a stage,\na week) and each series is another key holding a number, in the order the\nx should read — sort in `loadRows`. A value that is missing (`null`,\n`undefined`, `\"\"`) or not a number is a gap, never a zero: a line, an area\nand a sparkline break there (a value alone between two gaps is a dot), a\nbar or a slice is not drawn, a heatmap cell is blank, and the tooltip and\nthe table say —. A real zero is `0`.\n\n```js\n[{ month: \"Jan\", newBiz: 24600, expansion: 8100 }, { month: \"Feb\", newBiz: 26900, expansion: 8800 }]\n```\n\nA pie takes one row per slice: a key for the name, a key for the value.\n\nA heatmap takes one row per grid row: a key for the row's name and one key\nper column holding its number — a pivot, shaped in the query:\n\n```js\n[{ slot: \"9–12\", mon: 42, tue: 51, wed: 38 }, { slot: \"12–15\", mon: 18, tue: 24, wed: 65 }]\n```\n\n## Colours\n\n`series[].tone` picks a colour: `\"chart-1\"` … `\"chart-5\"` (or `1` … `5`).\nLeft out, the series take chart-1, chart-2, … in order. The chart colours\ncome from the workspace theme (`app/globals.css`, `--color-chart-N`): the\nfirst is the theme's primary, the others turn its hue so neighbours stay\napart in every theme. A series that means a status may take a theme tone\ninstead: `\"success\"`, `\"warning\"`, `\"danger\"`, `\"info\"`, `\"primary\"`,\n`\"muted\"`. In your own markup around a chart use the same classes:\n`bg-chart-1`, `text-chart-2`, `fill-chart-3`, `stroke-chart-4`.\n\n## Props\n\n### LineChart · AreaChart · BarChart\n\n- `data` — the rows. Empty (or missing) shows the empty state.\n- `x` — the key that holds each row's x, e.g. `\"month\"`.\n- `series` — what to draw, in order: `[{ key, label?, tone?, muted?,\n dashed? }]`. `label` defaults to the key, humanised.\n - `dashed` — LineChart and AreaChart: a dashed line with no fill under\n it. The key and the tooltip show a dashed swatch.\n - `muted` — drawn quieter and underneath the rest: the grey (unless it\n has its own `tone`), a thinner line, a fainter fill. On a BarChart it\n is only the grey.\n - Together they are a \"previous period\" to compare against:\n\n ```jsx\n <LineChart\n data={days} // [{ day: \"Mon\", thisWeek: 42, lastWeek: 35 }, …]\n x=\"day\"\n series={[{ key: \"thisWeek\", label: \"This week\" }, { key: \"lastWeek\", label: \"Last week\", muted: true, dashed: true }]}\n label=\"Signups, this week against last\"\n />\n ```\n- `format(value)` — how a value reads in the tooltip, the summary and the\n table. Default: `1,250` · `48.2K`.\n- `formatAxis(value)` — how a tick reads (default `format`); give a\n shorter one when `format` is long (`$48,200` → `$48K`).\n- `formatX(xValue, row)` — how an x reads (default: as written).\n- `label` — what the chart is, for a screen reader: `\"Revenue by month\"`.\n Default `\"Chart\"`; always set it.\n- `description` — the sentence a screen reader hears after `label`.\n Default: made from the data (the range, each series' first, last and\n highest value).\n- `xLabel` — the x column's heading in the hidden table (default: the key,\n humanised).\n- `height` — pixels, default `240`. The width is the container's.\n- `stacked` — AreaChart and BarChart: pile the series (negatives stack\n down). The tooltip adds a total. Default `false`.\n- `orientation` — BarChart: `\"vertical\"` (columns, the default) or\n `\"horizontal\"` (bars, categories down the left — for long names).\n- `valueLabels` — BarChart: each bar's value at its end (\"10%\"), in\n `format`; stacked, the stack's total. `true`, or `(value, row, key)` →\n the words (`key` is null for a total). With it the value axis is often\n noise: `xAxis={false}` (horizontal) or `yAxis={false}`. Default `false`.\n- `track` — BarChart: a quiet full-length track behind every bar, for a\n share or a distribution (a status's share of tickets). Pair it with\n `grid={false}`. Default `false`.\n- `curve` — LineChart and AreaChart: `\"monotone\"` (default, smooth and\n never overshooting), `\"linear\"` or `\"step\"`.\n- `dots` — LineChart and AreaChart: a dot on every point. Default `false`.\n- `grid` · `xAxis` · `yAxis` — gridlines and the two axes' labels. All\n default `true`.\n- `legend` — the legend under the chart; its entries are toggles that hide\n a series. Default: shown when there is more than one series.\n- `tooltip` — default `true`.\n- `onSelect(row, index)` — a click, a tap or Enter on a point or a bar\n calls it: open the month, filter by the stage.\n- `emptyLabel` · `emptyBody` — what the empty state says. Default\n `\"No data yet\"`.\n- `className` — layout around it (width, margin). Never colour.\n\n### PieChart\n\n- `data` — one row per slice.\n- `name` — the key holding a slice's name, default `\"name\"`.\n- `value` — the key holding its number, default `\"value\"`. Zero and\n negative values draw nothing; a missing one draws nothing and reads —\n in the legend and the table.\n- `donut` — open the middle. It shows the total, and the picked slice's\n value and share while one is picked. Default `false`.\n- `centerLabel` — a donut's middle instead of the total: a string, or\n `{ value, label }`.\n- `totalLabel` — the words under the total, default `\"Total\"`.\n- `format(value)` · `formatName(name, row)` · `label` · `description` ·\n `height` · `tooltip` · `onSelect(row, index)` · `emptyLabel` ·\n `emptyBody` · `className` — as above.\n- `legend` — default `true`; hiding a slice re-shares the rest.\n- `legendValues` — what follows each name in the legend: `\"percent\"`\n (default), `\"value\"` or `\"none\"`.\n- A row's own `tone` colours its slice; otherwise chart-1 … chart-5 in\n order (a sixth slice repeats chart-1 — five is the most a pie reads).\n\n### Sparkline\n\nA trend with no axes, gridlines, legend or tooltip, as wide as its box\nand `height` tall — small enough to sit under a kit `Stat` in a tile:\n\n```jsx\n<Card>\n <Stat label=\"MRR\" value=\"$84.2k\" delta={6.1} />\n <Sparkline data={mrr} label=\"MRR, last 12 months\" height={28} className=\"mt-2\" />\n</Card>\n```\n\n- `data` — numbers (`[12, 15, 14]`) or rows with `value`.\n- `value` — the key when `data` is rows, default `\"value\"`.\n- `label` — what it shows, e.g. `\"MRR, last 12 months\"`: a screen reader\n hears it with the first, last, low and high values. Always set it.\n- `format(value)` — for those words.\n- `tone` — default `\"chart-1\"`.\n- `area` — the faded fill under the line, default `true`.\n- `dot` — a dot on the latest value, default `true`.\n- `height` — default `36`. The width is the container's.\n- `className` — layout. Never colour.\n\n### HeatmapChart\n\n- `data` — one row per grid row. Empty (or no `columns`) shows the empty\n state.\n- `y` — the key holding each row's name, default `\"name\"`.\n- `columns` — the cells of a row, in order: `[\"mon\", \"tue\"]` (labels made\n from the keys) or `[{ key: \"mon\", label: \"Mon\" }, …]`.\n- `formatY(yValue, row)` — how a row's name reads (default: as written).\n- `tone` — the ramp's colour: `\"chart-1\"` (default) … `\"chart-5\"` (or\n `1` … `5`). One colour, light to full: a heatmap is one measure.\n- `min` · `max` — the ends of the ramp. Default: 0 (or the lowest value,\n when negative) and the highest value. Values past them take the end\n shades.\n- `steps` — how many shades, default `5`. Each cell takes the shade its\n value falls in (`scaleStep`), so equal shades mean equal bands.\n- `showValues` — the number on each cell, in `format`; on dark shades it\n is drawn in the background's colour so it stays readable. Default\n `false` (a compact grid of colour).\n- `format(value)` — how a value reads on the cell, in the tooltip, the\n totals and the legend. Default: `1,250` · `48.2K`.\n- `cellShape` — `\"square\"` (default, rounded) or `\"pill\"` (short and\n fully round — the contribution grid).\n- `rowTotals` · `colTotals` — a bold Total column at the right, a bold\n Total row at the foot (and the grand total where they meet). Missing\n values count as nothing. Default `false`.\n- `totalLabel` — default `\"Total\"`.\n- `tooltip` — the kit `Tooltip` on hover or keyboard focus. Default `true`.\n- `formatTooltip(value, row, column)` — its words (a string); `column` is\n `{ key, label }` and `value` is null when the cell has none. Default:\n `\"Tue · 9–12: 51\"`. The same words name the cell to a screen reader.\n- `legend` — a `ScaleLegend` under the grid, at the right. Default `true`.\n Set it `false` to place your own (in the card's header, say).\n- `legendLabels` — its end words, e.g. `[\"Less\", \"More\"]`; default the\n numbers at the two ends.\n- `onSelect(row, columnKey, value)` — a click, or Enter or Space on a\n focused cell.\n- `label` · `description` — as above; the description defaults to the\n size, the range and the highest cell.\n- `yLabel` — the row names' heading for a screen reader (default: the key,\n humanised).\n- `emptyLabel` · `emptyBody` · `className` — as above.\n\nThe grid is a table: a screen reader reads each cell with its row and\ncolumn. Tab lands on one cell; the arrow keys move, Home and End go to the\nrow's ends (with Ctrl or ⌘, the grid's). A grid wider than its box\nscrolls sideways inside it, and the row names stay at the left — on the\ncard's background, so put the heatmap in a `Card`.\n\n### ScaleLegend\n\nThe heatmap's key, for a legend placed yourself: `Less ▪▪▪▪▪ More`.\n\n- `min` · `max` — the ends, default `0` and `1`.\n- `steps` — the shades, default `5` (match the chart's).\n- `labels` — `[\"Less\", \"More\"]`, or leave it out for the numbers at the\n ends, in `format`.\n- `tone` · `format` — as the chart's.\n- `shape` — `\"square\"` (default) or `\"pill\"`, as the chart's cells.\n- `label` — what a screen reader hears it called, default `\"Scale\"`.\n\n### scaleStep · scaleShade\n\nThe ramp itself, for cells a block draws on its own.\n\n- `scaleStep(value, min, max, steps = 5)` → which shade a value falls in:\n `0` … `steps − 1`, or `null` for a missing value.\n- `scaleShade(value, { min, max, steps, tone })` → `{ step, style,\n className }`: put `style` (the fill) and `className` (the text colour\n that reads on it) on your element.\n\n### ChartLegend\n\nThe key the charts draw, for a legend of your own (several sparklines\nsharing one key, say).\n\n- `items` — `[{ key, label, tone?, value? }]`; `value` is a muted note\n after the label.\n- `hidden` — a `Set` (or array) of keys shown as off.\n- `onToggle(key)` — makes each entry a toggle; without it the legend is a\n plain list.\n- `label` — what it is called to a screen reader, default `\"Legend\"`.\n\n## Example\n\n```jsx\n// surfaces/sales.jsx — server: load, hand over\nimport { loadRows } from \"../lib/data\";\nimport SalesCharts from \"../components/SalesCharts\";\n\nexport default async function SalesSurface() {\n const { rows: months, error } = await loadRows({ source: \"public.revenue_by_month\", orderBy: \"month asc\" });\n const { rows: stages } = await loadRows({ source: \"public.deals_by_stage\", orderBy: \"position asc\" });\n const { rows: visits } = await loadRows({ source: \"public.visits_by_slot\", orderBy: \"position asc\" });\n return <SalesCharts months={months} stages={stages} visits={visits} error={error} />;\n}\n```\n\n```jsx\n// components/SalesCharts.jsx — client: the cards and the charts\n\"use client\";\nimport { Card, Notice } from \"./ui\";\nimport { AreaChart, BarChart, HeatmapChart, Sparkline } from \"./blocks/chart\";\n\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 });\nconst usdShort = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\", notation: \"compact\" });\n\nexport default function SalesCharts({ months, stages, visits, error }) {\n return (\n <div className=\"grid gap-4\">\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n <Card title=\"MRR\">\n <Sparkline data={months} value=\"mrr\" label=\"MRR, last 12 months\" format={(v) => usd.format(v)} />\n </Card>\n <Card title=\"Revenue by month\">\n <AreaChart\n data={months}\n x=\"month\"\n series={[{ key: \"new_biz\", label: \"New business\" }, { key: \"expansion\", label: \"Expansion\" }]}\n stacked\n format={(v) => usd.format(v)}\n formatAxis={(v) => usdShort.format(v)}\n label=\"Revenue by month\"\n />\n </Card>\n <Card title=\"Deals by stage\">\n <BarChart data={stages} x=\"stage\" series={[{ key: \"deals\", label: \"Deals\" }]} orientation=\"horizontal\" label=\"Deals by stage\" />\n </Card>\n <Card title=\"Site visits by time of day\">\n <HeatmapChart\n data={visits}\n y=\"slot\"\n columns={[{ key: \"mon\", label: \"Mon\" }, { key: \"tue\", label: \"Tue\" }, { key: \"wed\", label: \"Wed\" }]}\n showValues\n rowTotals\n colTotals\n formatTooltip={(v, row, col) => `${col.label} ${row.slot}: ${v ?? 0} visits`}\n label=\"Site visits by weekday and time slot\"\n />\n </Card>\n </div>\n );\n}\n```\n\n## What it will not do\n\n- Sort, group or sum: the rows come shaped. Totals by month or by stage\n are a query (a view, or `loadRows` over one).\n- A time scale: x is a list of labels, evenly spaced, in row order. Give\n every period a row (a month with no sales is a row with 0; a day with\n no data yet, a row with null).\n- Two value axes, scatter, radar, maps, zoom or brushing.\n- A colour outside the theme: series take the chart tones and the theme's\n status tones only. Colour by value is the heatmap's alone, on one chart\n tone.\n- Draw its own title: put it in a `Card`.\n\nA chart it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add chart` restores them.\n"
52
+ "content": "# Chart\n\nNumbers as a shape: `LineChart`, `AreaChart` (stacked or not), `BarChart`\n(grouped or stacked, columns or bars), `PieChart` (a pie or a donut with\nthe total in its middle), `Sparkline` (a trend beside a KPI) and\n`HeatmapChart` (a grid of two dimensions, each cell shaded by its value,\nwith `ScaleLegend` as its key). Drawn in SVG (the heatmap as an HTML\ntable), no chart library, in the workspace's chart colours. Each one is as\nwide as its container and redraws when that changes. Hover, touch or the\narrow keys show a tooltip with a crosshair; the legend hides and shows a\nseries; a screen reader gets a summary and the numbers as a table; with\nreduced motion nothing animates.\n\n```jsx\nimport { AreaChart, BarChart, HeatmapChart, PieChart, Sparkline } from \"../components/blocks/chart\"; // a Makersclaw app (\"./blocks/chart\" from components/)\nimport { AreaChart, BarChart, HeatmapChart, PieChart, Sparkline } from \"@/components/makersclaw/blocks/chart\"; // a shadcn project\n```\n\nThey are client components: render them from a `\"use client\"` component,\nand load the rows in the server surface (README §4). Put a chart in a\n`Card` — the card gives the title; the chart is only the picture.\n\n## The data\n\nRows, as `loadRows` returns them. One key holds the x (a month, a stage,\na week) and each series is another key holding a number, in the order the\nx should read — sort in `loadRows`. A value that is missing (`null`,\n`undefined`, `\"\"`) or not a number is a gap, never a zero: a line, an area\nand a sparkline break there (a value alone between two gaps is a dot), a\nbar or a slice is not drawn, a heatmap cell is blank, and the tooltip and\nthe table say —. A real zero is `0`.\n\n```js\n[{ month: \"Jan\", newBiz: 24600, expansion: 8100 }, { month: \"Feb\", newBiz: 26900, expansion: 8800 }]\n```\n\nA pie takes one row per slice: a key for the name, a key for the value.\n\nA heatmap takes one row per grid row: a key for the row's name and one key\nper column holding its number — a pivot, shaped in the query:\n\n```js\n[{ slot: \"9–12\", mon: 42, tue: 51, wed: 38 }, { slot: \"12–15\", mon: 18, tue: 24, wed: 65 }]\n```\n\n## Colours\n\n`series[].tone` picks a colour: `\"chart-1\"` … `\"chart-5\"` (or `1` … `5`).\nLeft out, the series take chart-1, chart-2, … in order. The chart colours\ncome from the workspace theme (`app/globals.css`, `--color-chart-N`): the\nfirst is the theme's primary, the others turn its hue so neighbours stay\napart in every theme. A series that means a status may take a theme tone\ninstead: `\"success\"`, `\"warning\"`, `\"danger\"`, `\"info\"`, `\"primary\"`,\n`\"muted\"`. In your own markup around a chart use the same classes:\n`bg-chart-1`, `text-chart-2`, `fill-chart-3`, `stroke-chart-4`.\n\n## Props\n\n### LineChart · AreaChart · BarChart\n\n- `data` — the rows. Empty (or missing) shows the empty state.\n- `x` — the key that holds each row's x, e.g. `\"month\"`.\n- `series` — what to draw, in order: `[{ key, label?, tone?, muted?,\n dashed? }]`. `label` defaults to the key, humanised.\n - `dashed` — LineChart and AreaChart: a dashed line with no fill under\n it. The key and the tooltip show a dashed swatch.\n - `muted` — drawn quieter and underneath the rest: the grey (unless it\n has its own `tone`), a thinner line, a fainter fill. On a BarChart it\n is only the grey.\n - Together they are a \"previous period\" to compare against:\n\n ```jsx\n <LineChart\n data={days} // [{ day: \"Mon\", thisWeek: 42, lastWeek: 35 }, …]\n x=\"day\"\n series={[{ key: \"thisWeek\", label: \"This week\" }, { key: \"lastWeek\", label: \"Last week\", muted: true, dashed: true }]}\n label=\"Signups, this week against last\"\n />\n ```\n- `format(value)` — how a value reads in the tooltip, the summary and the\n table. Default: `1,250` · `48.2K`.\n- `formatAxis(value)` — how a tick reads (default `format`); give a\n shorter one when `format` is long (`$48,200` → `$48K`).\n- `formatX(xValue, row)` — how an x reads (default: as written).\n- `label` — what the chart is, for a screen reader: `\"Revenue by month\"`.\n Default `\"Chart\"`; always set it.\n- `description` — the sentence a screen reader hears after `label`.\n Default: made from the data (the range, each series' first, last and\n highest value).\n- `xLabel` — the x column's heading in the hidden table (default: the key,\n humanised).\n- `height` — pixels, default `240`. The width is the container's.\n- `stacked` — AreaChart and BarChart: pile the series (negatives stack\n down). The tooltip adds a total. Default `false`.\n- `orientation` — BarChart: `\"vertical\"` (columns, the default) or\n `\"horizontal\"` (bars, categories down the left — for long names).\n- `valueLabels` — BarChart: each bar's value at its end (\"10%\"), in\n `format`; stacked, the stack's total. `true`, or `(value, row, key)` →\n the words (`key` is null for a total). With it the value axis is often\n noise: `xAxis={false}` (horizontal) or `yAxis={false}`. Default `false`.\n- `track` — BarChart: a quiet full-length track behind every bar, for a\n share or a distribution (a status's share of tickets). Pair it with\n `grid={false}`. Default `false`.\n- `curve` — LineChart and AreaChart: `\"monotone\"` (default, smooth and\n never overshooting), `\"linear\"` or `\"step\"`.\n- `dots` — LineChart and AreaChart: a dot on every point. Default `false`.\n- `grid` · `xAxis` · `yAxis` — gridlines and the two axes' labels. All\n default `true`.\n- `legend` — the legend under the chart; its entries are toggles that hide\n a series. Default: shown when there is more than one series.\n- `tooltip` — default `true`.\n- `onSelect(row, index)` — a click, a tap or Enter on a point or a bar\n calls it: open the month, filter by the stage.\n- `emptyLabel` · `emptyBody` — what the empty state says. Default\n `\"No data yet\"`.\n- `className` — layout around it (width, margin). Never colour.\n\n### PieChart\n\n- `data` — one row per slice.\n- `name` — the key holding a slice's name, default `\"name\"`.\n- `value` — the key holding its number, default `\"value\"`. Zero and\n negative values draw nothing; a missing one draws nothing and reads —\n in the legend and the table.\n- `donut` — open the middle. It shows the total, and the picked slice's\n value and share while one is picked. Default `false`.\n- `centerLabel` — a donut's middle instead of the total: a string, or\n `{ value, label }`.\n- `totalLabel` — the words under the total, default `\"Total\"`.\n- `format(value)` · `formatName(name, row)` · `label` · `description` ·\n `height` · `tooltip` · `onSelect(row, index)` · `emptyLabel` ·\n `emptyBody` · `className` — as above.\n- `legend` — default `true`; hiding a slice re-shares the rest.\n- `legendValues` — what follows each name in the legend: `\"percent\"`\n (default), `\"value\"` or `\"none\"`.\n- A row's own `tone` colours its slice; otherwise chart-1 … chart-5 in\n order (a sixth slice repeats chart-1 — five is the most a pie reads).\n\n### Sparkline\n\nA trend with no axes, gridlines, legend or tooltip, as wide as its box\nand `height` tall — small enough to sit under a kit `Stat` in a tile:\n\n```jsx\n<Card>\n <Stat label=\"MRR\" value=\"$84.2k\" delta={6.1} />\n <Sparkline data={mrr} label=\"MRR, last 12 months\" height={28} className=\"mt-2\" />\n</Card>\n```\n\n- `data` — numbers (`[12, 15, 14]`) or rows with `value`.\n- `value` — the key when `data` is rows, default `\"value\"`.\n- `label` — what it shows, e.g. `\"MRR, last 12 months\"`: a screen reader\n hears it with the first, last, low and high values. Always set it.\n- `format(value)` — for those words.\n- `tone` — default `\"chart-1\"`.\n- `area` — the faded fill under the line, default `true`.\n- `dot` — a dot on the latest value, default `true`.\n- `height` — default `36`. The width is the container's.\n- `className` — layout. Never colour.\n\n### HeatmapChart\n\n- `data` — one row per grid row. Empty (or no `columns`) shows the empty\n state.\n- `y` — the key holding each row's name, default `\"name\"`.\n- `columns` — the cells of a row, in order: `[\"mon\", \"tue\"]` (labels made\n from the keys) or `[{ key: \"mon\", label: \"Mon\" }, …]`.\n- `formatY(yValue, row)` — how a row's name reads (default: as written).\n- `tone` — the ramp's colour: `\"chart-1\"` (default) … `\"chart-5\"` (or\n `1` … `5`). One colour, light to full: a heatmap is one measure.\n- `min` · `max` — the ends of the ramp. Default: 0 (or the lowest value,\n when negative) and the highest value. Values past them take the end\n shades.\n- `steps` — how many shades, default `5`. Each cell takes the shade its\n value falls in (`scaleStep`), so equal shades mean equal bands.\n- `showValues` — the number on each cell, in `format`; on dark shades it\n is drawn in the background's colour so it stays readable. Default\n `false` (a compact grid of colour).\n- `format(value)` — how a value reads on the cell, in the tooltip, the\n totals and the legend. Default: `1,250` · `48.2K`.\n- `cellShape` — `\"square\"` (default, rounded) or `\"pill\"` (short and\n fully round — the contribution grid).\n- `rowTotals` · `colTotals` — a bold Total column at the right, a bold\n Total row at the foot (and the grand total where they meet). Missing\n values count as nothing. Default `false`.\n- `totalLabel` — default `\"Total\"`.\n- `tooltip` — the kit `Tooltip` on hover or keyboard focus. Default `true`.\n- `formatTooltip(value, row, column)` — its words (a string); `column` is\n `{ key, label }` and `value` is null when the cell has none. Default:\n `\"Tue · 9–12: 51\"`. The same words name the cell to a screen reader.\n- `legend` — a `ScaleLegend` under the grid, at the right. Default `true`.\n Set it `false` to place your own (in the card's header, say).\n- `legendLabels` — its end words, e.g. `[\"Less\", \"More\"]`; default the\n numbers at the two ends.\n- `onSelect(row, columnKey, value)` — a click, or Enter or Space on a\n focused cell.\n- `label` · `description` — as above; the description defaults to the\n size, the range and the highest cell.\n- `yLabel` — the row names' heading for a screen reader (default: the key,\n humanised).\n- `emptyLabel` · `emptyBody` · `className` — as above.\n\nThe grid is a table: a screen reader reads each cell with its row and\ncolumn. Tab lands on one cell; the arrow keys move, Home and End go to the\nrow's ends (with Ctrl or ⌘, the grid's). A grid wider than its box\nscrolls sideways inside it, and the row names stay at the left — on the\ncard's background, so put the heatmap in a `Card`.\n\n### ScaleLegend\n\nThe heatmap's key, for a legend placed yourself: `Less ▪▪▪▪▪ More`.\n\n- `min` · `max` — the ends, default `0` and `1`.\n- `steps` — the shades, default `5` (match the chart's).\n- `labels` — `[\"Less\", \"More\"]`, or leave it out for the numbers at the\n ends, in `format`.\n- `tone` · `format` — as the chart's.\n- `shape` — `\"square\"` (default) or `\"pill\"`, as the chart's cells.\n- `label` — what a screen reader hears it called, default `\"Scale\"`.\n\n### scaleStep · scaleShade\n\nThe ramp itself, for cells a block draws on its own.\n\n- `scaleStep(value, min, max, steps = 5)` → which shade a value falls in:\n `0` … `steps − 1`, or `null` for a missing value.\n- `scaleShade(value, { min, max, steps, tone })` → `{ step, style,\n className }`: put `style` (the fill) and `className` (the text colour\n that reads on it) on your element.\n\n### ChartLegend\n\nThe key the charts draw, for a legend of your own (several sparklines\nsharing one key, say).\n\n- `items` — `[{ key, label, tone?, value? }]`; `value` is a muted note\n after the label.\n- `hidden` — a `Set` (or array) of keys shown as off.\n- `onToggle(key)` — makes each entry a toggle; without it the legend is a\n plain list.\n- `label` — what it is called to a screen reader, default `\"Legend\"`.\n\n## Example\n\n```jsx\n// surfaces/sales.jsx — client: read, hand over\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport SalesCharts from \"../components/SalesCharts\";\n\nexport default function SalesSurface() {\n const months = useRows({ source: \"revenue_by_month\", orderBy: \"month asc\" });\n const stages = useRows({ source: \"deals_by_stage\", orderBy: \"position asc\" });\n const visits = useRows({ source: \"visits_by_slot\", orderBy: \"position asc\" });\n if (months.loading || stages.loading || visits.loading) return null;\n return (\n <SalesCharts months={months.rows} stages={stages.rows} visits={visits.rows}\n error={months.error ?? stages.error ?? visits.error} />\n );\n}\n```\n\n```jsx\n// components/SalesCharts.jsx — client: the cards and the charts\n\"use client\";\nimport { Card, Notice } from \"./ui\";\nimport { AreaChart, BarChart, HeatmapChart, Sparkline } from \"./blocks/chart\";\n\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 });\nconst usdShort = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\", notation: \"compact\" });\n\nexport default function SalesCharts({ months, stages, visits, error }) {\n return (\n <div className=\"grid gap-4\">\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n <Card title=\"MRR\">\n <Sparkline data={months} value=\"mrr\" label=\"MRR, last 12 months\" format={(v) => usd.format(v)} />\n </Card>\n <Card title=\"Revenue by month\">\n <AreaChart\n data={months}\n x=\"month\"\n series={[{ key: \"new_biz\", label: \"New business\" }, { key: \"expansion\", label: \"Expansion\" }]}\n stacked\n format={(v) => usd.format(v)}\n formatAxis={(v) => usdShort.format(v)}\n label=\"Revenue by month\"\n />\n </Card>\n <Card title=\"Deals by stage\">\n <BarChart data={stages} x=\"stage\" series={[{ key: \"deals\", label: \"Deals\" }]} orientation=\"horizontal\" label=\"Deals by stage\" />\n </Card>\n <Card title=\"Site visits by time of day\">\n <HeatmapChart\n data={visits}\n y=\"slot\"\n columns={[{ key: \"mon\", label: \"Mon\" }, { key: \"tue\", label: \"Tue\" }, { key: \"wed\", label: \"Wed\" }]}\n showValues\n rowTotals\n colTotals\n formatTooltip={(v, row, col) => `${col.label} ${row.slot}: ${v ?? 0} visits`}\n label=\"Site visits by weekday and time slot\"\n />\n </Card>\n </div>\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Sort, group or sum: the rows come shaped. Totals by month or by stage\n are a query (a view, or `loadRows` over one).\n- A time scale: x is a list of labels, evenly spaced, in row order. Give\n every period a row (a month with no sales is a row with 0; a day with\n no data yet, a row with null).\n- Two value axes, scatter, radar, maps, zoom or brushing.\n- A colour outside the theme: series take the chart tones and the theme's\n status tones only. Colour by value is the heatmap's alone, on one chart\n tone.\n- Draw its own title: put it in a `Card`.\n\nA chart it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add chart` restores them.\n"
53
53
  }
54
54
  ],
55
55
  "categories": [
@@ -76,7 +76,7 @@
76
76
  "docs": "Installed to components/makersclaw/blocks/chart. Import: import { LineChart, AreaChart, BarChart, PieChart, Sparkline, ChartLegend, HeatmapChart, ScaleLegend, scaleStep, scaleShade } from \"@/components/makersclaw/blocks/chart\". Props and an example: components/makersclaw/blocks/chart/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
77
77
  "meta": {
78
78
  "makersclaw": {
79
- "version": "0.6.0",
79
+ "version": "0.8.0",
80
80
  "useWhen": "Numbers the founder should see as a shape: revenue by month, deals by stage, pipeline by source, a KPI's trend. Not for a single figure with no history (a stat), exact lookups (a table), or records on a timeline (a calendar)."
81
81
  }
82
82
  }