makersclaw 0.7.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.
- package/README.md +8 -1
- package/package.json +1 -1
- package/r/annotate.json +4 -4
- package/r/approval-request.json +2 -2
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +5 -5
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/contact-section.json +2 -2
- package/r/core.json +7 -1
- package/r/csv-import.json +3 -3
- package/r/data-table.json +2 -2
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +3 -3
- package/r/footer.json +3 -3
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +4 -4
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +3 -3
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +4 -4
- package/r/registry.json +126 -121
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +3 -3
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +3 -3
- package/r/spreadsheet.json +2 -2
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +4 -4
- package/registry/blocks/annotate/README.md +14 -11
- package/registry/blocks/chart/cartesian.jsx +1 -1
- package/registry/blocks/chart/core.jsx +33 -23
- package/registry/blocks/chart/pie.jsx +1 -1
- package/registry/blocks/chat/README.md +4 -1
- package/registry/blocks/chat/demo.jsx +2 -2
- package/registry/blocks/flow-canvas/README.md +15 -8
- package/registry/blocks/footer/README.md +3 -2
- package/registry/blocks/mind-map/README.md +4 -2
- package/registry/blocks/org-chart/README.md +4 -2
- package/registry/blocks/page-builder/README.md +1 -1
- package/registry/blocks/page-builder/demo.jsx +3 -3
- package/registry/blocks/pdf-viewer/README.md +4 -1
- package/registry/blocks/sidebar/README.md +23 -13
- package/registry/blocks/sketch-canvas/README.md +15 -9
- package/registry/blocks/whiteboard/README.md +20 -14
- package/src/cli.mjs +2 -2
- package/src/install.mjs +23 -6
- package/src/registry.mjs +34 -3
- package/templates/next/files/README.md +291 -11
- package/templates/next/files/app/shell.jsx +54 -37
- package/templates/next/files/check/run.mjs +76 -4
- package/templates/next/files/components/ui/index.js +1 -0
- package/templates/next/files/components/ui/layout.jsx +307 -0
- package/templates/next/files/lib/claw.js +4 -1
- package/templates/next/files/lib/hooks.js +71 -2
- package/templates/next/files/lib/platform.js +93 -4
- package/templates/next/template.json +10 -1
package/README.md
CHANGED
|
@@ -48,9 +48,16 @@ them: [makersclaw.com](https://makersclaw.com).
|
|
|
48
48
|
## Components come with the template; blocks come when a surface needs one
|
|
49
49
|
|
|
50
50
|
- **Components** — the kit (`components/ui`: Button, Field, Select, Sheet,
|
|
51
|
-
Tabs, Menu, Notice, Icon
|
|
51
|
+
Tabs, Menu, Notice, Icon…, and the layouts AppLayout and
|
|
52
|
+
DocumentLayout) and the parts bound to the data layer
|
|
52
53
|
(`components/parts`: RecordSwitcher, useFixture). `init` lays them into
|
|
53
54
|
every app. `add <component>` only restores one.
|
|
55
|
+
- **Layouts** — `AppLayout` is the one owner of the window's height (a
|
|
56
|
+
header, navigation, content that scrolls or fills, an inspector, a
|
|
57
|
+
footer); the starter shell is one, and `npm run check` refuses
|
|
58
|
+
anything else in an app that sizes by the window (`min-h-dvh`,
|
|
59
|
+
`h-screen`, `100vh`, `h-[80vh]`…). `DocumentLayout` is a page that
|
|
60
|
+
flows, for print and export. Props: the app's README §3b.
|
|
54
61
|
- **Blocks** — the bigger pieces a surface is assembled from: a board, a
|
|
55
62
|
job panel, and more to come. No app has one until `add` puts it in
|
|
56
63
|
`components/blocks/<name>/`. `search` finds them by what they do. A
|
package/package.json
CHANGED
package/r/annotate.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"title": "Annotate",
|
|
6
6
|
"description": "Mark up a screenshot: drop, pick or paste one, then draw arrows, boxes, highlights, redaction bars and numbered notes on it (Excalidraw underneath, via sketch-canvas). A side list holds the words of each numbered note; a click on a note's number shows its pin. Starter templates (bug report, design review, walkthrough) on a fresh screenshot. The annotation is plain JSON saved through onChange / an async onSave; onSubmit hands over a PNG of the marked-up screenshot with the notes and the sketch.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/sketch-canvas.json"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [],
|
|
12
12
|
"files": [
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"path": "registry/blocks/annotate/README.md",
|
|
33
33
|
"type": "registry:file",
|
|
34
34
|
"target": "@components/makersclaw/blocks/annotate/README.md",
|
|
35
|
-
"content": "# Annotate\n\nMark up a screenshot and say what each mark means. Drop a screenshot on\nit, pick one, or paste one (Cmd/Ctrl+V). It lies locked at the back of the\ncanvas, and on top you draw: numbered notes (pins, <kbd>N</kbd>), arrows,\nboxes, ellipses, freehand, text, a highlight band, and a solid bar to hide\nsomething private. Every numbered pin has a row in the Notes list beside\nthe canvas, where its words are typed; a click on a row's number shows its\npin. A fresh screenshot offers three starters — Bug report, Design review,\nWalkthrough — that lay pins (and a box or arrows) with prompts in their\nrows. A top bar holds the title, the note count, the save state, Replace,\nStart over and the send button.\n\nIt is `sketch-canvas` underneath (Excalidraw, in the workspace theme): its\ntool rail, selection toolbar, zoom, undo and PNG export.\n\n```jsx\nimport { Annotate, annotationToText } from \"../components/blocks/annotate\"; // a Makersclaw app (\"./blocks/annotate\" from components/)\nimport { Annotate, annotationToText } from \"@/components/makersclaw/blocks/annotate\"; // a shadcn project\n```\n\n`Annotate` is a client component: render it from a `\"use client\"`\ncomponent. The helpers are plain functions and work on the server.\n`makersclaw add annotate` adds `sketch-canvas` with it (and Excalidraw:\nrun `npm install` after).\n\n## The data\n\nAn annotation is plain JSON. Store it as it comes out of `onChange` /\n`onSave` (a `jsonb` column).\n\n```js\n{\n type: \"annotation\",\n version: 1,\n image: { src: \"data:image/png;base64,…\", width: 1280, height: 800 }, // or an https URL; null before one is added\n sketch: { type: \"sketch\", version: 1, elements: [ … ], files: {}, appState: {} }, // sketch-canvas's value: the marks\n notes: [\n { n: 1, text: \"The total is wrong here\" },\n { n: 2, text: \"\", hint: \"What did you expect instead?\" }, // hint: a starter's prompt, shown while empty\n ],\n}\n```\n\n- **A pin is the truth for which notes exist.** A pin is an ellipse in the\n sketch whose `customData.note` is its number, with the number as its\n label. `notes` lists one entry per pin on the sketch, by number. Delete\n a pin on the canvas and its entry is gone from the next `onChange`; the\n words stay in memory, so an undo brings both back.\n- **Numbers are stable.** Deleting 2 of 1, 2, 3 leaves 1 and 3, and the\n next pin is 4. A pin duplicated (Cmd+D) or pasted gets the next free\n number.\n- **The screenshot is a data URL** unless you pass `uploadImage`. It is\n scaled down to `maxImageSize` on its long side first (1600 px: about\n 0.5–2 MB as a PNG). To keep images out of the row, upload the file and\n store the URL (see `uploadImage`).\n- Colours in the marks are theme tokens (see sketch-canvas's README): a\n saved annotation repaints with the theme.\n\n## Props\n\n- `value` — the annotation, controlled. A new `value` that is not what the\n block last sent loads it (another record). Pass back exactly what\n `onChange` gave you and nothing resets.\n- `defaultValue` — the starting annotation, uncontrolled. `{ image }`\n alone opens a screenshot with nothing on it. With neither, the block\n starts at the drop zone.\n- `onChange(annotation)` — the whole annotation after an edit settles: a\n mark (250 ms after the canvas goes quiet), a note's words (500 ms after\n typing stops), a new screenshot (at once). Not called for pan, zoom or\n selection.\n- `onSave(annotation)` — `async`, returns `{ ok: true }` or\n `{ ok: false, error? }`. Called right after `onChange`. The top bar\n shows \"Saving…\", \"Saved\", or the error with Retry. A throw counts as a\n failure.\n- `onSubmit({ image, notes, sketch, screenshot, text })` — `async`,\n `{ ok }`. Without it there is no send button. `image` is a PNG `Blob`\n of the screenshot with the marks (2× scale); `notes` is\n `[{ n, text, hint?, x, y }]` (x, y: the pin's centre in screenshot\n pixels); `sketch` the marks; `screenshot` the `{ src, width, height }`\n underneath; `text` is `annotationToText` of it all. The bar shows\n \"Sent\" or the error.\n- `submitLabel` — the send button's words (default \"Send\"): \"File the\n bug\", \"Send to support\", \"Ask the agent\".\n- `uploadImage(file)` — `async`, returns `{ ok: true, src }` or\n `{ ok: false, error? }`. With it, a dropped or picked screenshot is\n uploaded as it is and `image.src` is the URL you return (same origin,\n or `exportImage` cannot read it back). Without it, the file becomes a\n data URL, scaled to `maxImageSize`.\n- `maxImageSize` — px on the long side a screenshot is scaled down to when\n it becomes a data URL (default 1600; `0` keeps it as it is).\n- `title` — the top bar's heading and the region's name (default\n \"Annotate a screenshot\").\n- `readOnly` — `false` (default). `true`: the marks to look at (pan and\n zoom), the notes as text; no rail, no drop, no send.\n- `tone` — the pins' colour, a token: `destructive` (default), `primary`,\n `warning`, `success`, `info`, `chart-2` … `chart-5`.\n- `tools` — the canvas tools in the rail (sketch-canvas ids). Default\n `ANNOTATE_TOOLS`: select, pan, arrow, rectangle, ellipse, draw, text,\n eraser. The three annotate stamps (numbered note, highlight, hide) always\n follow them.\n- `canvasRef` — a ref (`useSketchCanvas()` from sketch-canvas) that\n receives the canvas's handle: `exportImage()`, `getScene()`,\n `zoomToFit()`…\n- `label` — the region's accessible name (default: `title`).\n- `className` — layout: width, margin, height. From 768 px the default\n height is `h-[36rem]` (canvas and notes side by side); a height class of\n yours replaces it. Below that the notes stack under a 26rem canvas.\n Never colour.\n\n### Keys\n\nOn the canvas: <kbd>N</kbd> a numbered note (click to place it; Enter\ndrops it in the middle), <kbd>A</kbd> arrow, <kbd>R</kbd> rectangle,\n<kbd>O</kbd> ellipse, <kbd>P</kbd> draw, <kbd>T</kbd> text, <kbd>E</kbd>\neraser, <kbd>V</kbd> select, <kbd>H</kbd> pan, Delete, Cmd/Ctrl+D, Cmd/Ctrl+Z,\nShift+1 fit. \"Add note\" in the Notes list drops a pin in the middle of the\nview without the mouse.\n\n### Helpers\n\nPlain functions; they take an annotation (or a sketch) and never throw on\na partial one:\n\n- `emptyAnnotation()`, `toAnnotation(value)` — the shape above.\n- `annotationNotes(sketch, notes)` — `[{ n, text, hint?, x, y }]`: one per\n pin, by number.\n- `annotationToText(annotation)` — the words, for an agent or a reader:\n `\"1. The total is wrong here\\n2. …\"`, then any free text drawn on it\n under \"Also written on it:\".\n- `noteMarkers(sketch)` — the pins: `[{ n, id, x, y, width, height }]`.\n- `nextNoteNumber(sketch)` — the number the next pin gets.\n- `markerSkeleton(n, { size, tone })` — a pin as sketch-canvas skeletons,\n for `canvasRef.current.insertElements` (a pin you place from code).\n- `ANNOTATE_TEMPLATES`, `ANNOTATE_TOOLS` — the starters and the default\n tools.\n\n## Example\n\nA feedback widget that saves itself and files a ticket:\n\n```jsx\n// surfaces/feedback.jsx — client: one ticket, annotated\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { insertRow, updateRow } from \"../lib/actions\";\nimport { Annotate } from \"../components/blocks/annotate\";\n\nexport default function Feedback() {\n const id = useSearchParams().get(\"row\");\n const { row: ticket, loading } = useRow({ source: \"tickets\", id });\n if (loading || !ticket) return null;\n return (\n <Annotate\n
|
|
35
|
+
"content": "# Annotate\n\nMark up a screenshot and say what each mark means. Drop a screenshot on\nit, pick one, or paste one (Cmd/Ctrl+V). It lies locked at the back of the\ncanvas, and on top you draw: numbered notes (pins, <kbd>N</kbd>), arrows,\nboxes, ellipses, freehand, text, a highlight band, and a solid bar to hide\nsomething private. Every numbered pin has a row in the Notes list beside\nthe canvas, where its words are typed; a click on a row's number shows its\npin. A fresh screenshot offers three starters — Bug report, Design review,\nWalkthrough — that lay pins (and a box or arrows) with prompts in their\nrows. A top bar holds the title, the note count, the save state, Replace,\nStart over and the send button.\n\nIt is `sketch-canvas` underneath (Excalidraw, in the workspace theme): its\ntool rail, selection toolbar, zoom, undo and PNG export.\n\n```jsx\nimport { Annotate, annotationToText } from \"../components/blocks/annotate\"; // a Makersclaw app (\"./blocks/annotate\" from components/)\nimport { Annotate, annotationToText } from \"@/components/makersclaw/blocks/annotate\"; // a shadcn project\n```\n\n`Annotate` is a client component: render it from a `\"use client\"`\ncomponent. The helpers are plain functions and work on the server.\n`makersclaw add annotate` adds `sketch-canvas` with it (and Excalidraw:\nrun `npm install` after).\n\n## The data\n\nAn annotation is plain JSON. Store it as it comes out of `onChange` /\n`onSave` (a `jsonb` column).\n\n```js\n{\n type: \"annotation\",\n version: 1,\n image: { src: \"data:image/png;base64,…\", width: 1280, height: 800 }, // or an https URL; null before one is added\n sketch: { type: \"sketch\", version: 1, elements: [ … ], files: {}, appState: {} }, // sketch-canvas's value: the marks\n notes: [\n { n: 1, text: \"The total is wrong here\" },\n { n: 2, text: \"\", hint: \"What did you expect instead?\" }, // hint: a starter's prompt, shown while empty\n ],\n}\n```\n\n- **A pin is the truth for which notes exist.** A pin is an ellipse in the\n sketch whose `customData.note` is its number, with the number as its\n label. `notes` lists one entry per pin on the sketch, by number. Delete\n a pin on the canvas and its entry is gone from the next `onChange`; the\n words stay in memory, so an undo brings both back.\n- **Numbers are stable.** Deleting 2 of 1, 2, 3 leaves 1 and 3, and the\n next pin is 4. A pin duplicated (Cmd+D) or pasted gets the next free\n number.\n- **The screenshot is a data URL** unless you pass `uploadImage`. It is\n scaled down to `maxImageSize` on its long side first (1600 px: about\n 0.5–2 MB as a PNG). To keep images out of the row, upload the file and\n store the URL (see `uploadImage`).\n- Colours in the marks are theme tokens (see sketch-canvas's README): a\n saved annotation repaints with the theme.\n\n## Props\n\n- `value` — the annotation, controlled. A new `value` that is not what the\n block last sent loads it (another record). Pass back exactly what\n `onChange` gave you and nothing resets.\n- `defaultValue` — the starting annotation, uncontrolled. `{ image }`\n alone opens a screenshot with nothing on it. With neither, the block\n starts at the drop zone.\n- `onChange(annotation)` — the whole annotation after an edit settles: a\n mark (250 ms after the canvas goes quiet), a note's words (500 ms after\n typing stops), a new screenshot (at once). Not called for pan, zoom or\n selection.\n- `onSave(annotation)` — `async`, returns `{ ok: true }` or\n `{ ok: false, error? }`. Called right after `onChange`. The top bar\n shows \"Saving…\", \"Saved\", or the error with Retry. A throw counts as a\n failure.\n- `onSubmit({ image, notes, sketch, screenshot, text })` — `async`,\n `{ ok }`. Without it there is no send button. `image` is a PNG `Blob`\n of the screenshot with the marks (2× scale); `notes` is\n `[{ n, text, hint?, x, y }]` (x, y: the pin's centre in screenshot\n pixels); `sketch` the marks; `screenshot` the `{ src, width, height }`\n underneath; `text` is `annotationToText` of it all. The bar shows\n \"Sent\" or the error.\n- `submitLabel` — the send button's words (default \"Send\"): \"File the\n bug\", \"Send to support\", \"Ask the agent\".\n- `uploadImage(file)` — `async`, returns `{ ok: true, src }` or\n `{ ok: false, error? }`. With it, a dropped or picked screenshot is\n uploaded as it is and `image.src` is the URL you return (same origin,\n or `exportImage` cannot read it back). Without it, the file becomes a\n data URL, scaled to `maxImageSize`.\n- `maxImageSize` — px on the long side a screenshot is scaled down to when\n it becomes a data URL (default 1600; `0` keeps it as it is).\n- `title` — the top bar's heading and the region's name (default\n \"Annotate a screenshot\").\n- `readOnly` — `false` (default). `true`: the marks to look at (pan and\n zoom), the notes as text; no rail, no drop, no send.\n- `tone` — the pins' colour, a token: `destructive` (default), `primary`,\n `warning`, `success`, `info`, `chart-2` … `chart-5`.\n- `tools` — the canvas tools in the rail (sketch-canvas ids). Default\n `ANNOTATE_TOOLS`: select, pan, arrow, rectangle, ellipse, draw, text,\n eraser. The three annotate stamps (numbered note, highlight, hide) always\n follow them.\n- `canvasRef` — a ref (`useSketchCanvas()` from sketch-canvas) that\n receives the canvas's handle: `exportImage()`, `getScene()`,\n `zoomToFit()`…\n- `label` — the region's accessible name (default: `title`).\n- `className` — layout: width, margin, height. From 768 px the default\n height is `h-[36rem]` (canvas and notes side by side); a height class of\n yours replaces it. Below that the notes stack under a 26rem canvas.\n Never colour.\n\n### Keys\n\nOn the canvas: <kbd>N</kbd> a numbered note (click to place it; Enter\ndrops it in the middle), <kbd>A</kbd> arrow, <kbd>R</kbd> rectangle,\n<kbd>O</kbd> ellipse, <kbd>P</kbd> draw, <kbd>T</kbd> text, <kbd>E</kbd>\neraser, <kbd>V</kbd> select, <kbd>H</kbd> pan, Delete, Cmd/Ctrl+D, Cmd/Ctrl+Z,\nShift+1 fit. \"Add note\" in the Notes list drops a pin in the middle of the\nview without the mouse.\n\n### Helpers\n\nPlain functions; they take an annotation (or a sketch) and never throw on\na partial one:\n\n- `emptyAnnotation()`, `toAnnotation(value)` — the shape above.\n- `annotationNotes(sketch, notes)` — `[{ n, text, hint?, x, y }]`: one per\n pin, by number.\n- `annotationToText(annotation)` — the words, for an agent or a reader:\n `\"1. The total is wrong here\\n2. …\"`, then any free text drawn on it\n under \"Also written on it:\".\n- `noteMarkers(sketch)` — the pins: `[{ n, id, x, y, width, height }]`.\n- `nextNoteNumber(sketch)` — the number the next pin gets.\n- `markerSkeleton(n, { size, tone })` — a pin as sketch-canvas skeletons,\n for `canvasRef.current.insertElements` (a pin you place from code).\n- `ANNOTATE_TEMPLATES`, `ANNOTATE_TOOLS` — the starters and the default\n tools.\n\n## Example\n\nA feedback widget that saves itself and files a ticket:\n\n```jsx\n// surfaces/feedback.jsx — client: one ticket, annotated\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { insertRow, updateRow } from \"../lib/actions\";\nimport { AppLayout } from \"../components/ui\";\nimport { Annotate } from \"../components/blocks/annotate\";\n\nexport default function Feedback() {\n const id = useSearchParams().get(\"row\");\n const { row: ticket, loading } = useRow({ source: \"tickets\", id });\n if (loading || !ticket) return null;\n return (\n <AppLayout contentMode=\"fill\">\n <Annotate\n key={ticket.id}\n title=\"What should change?\"\n defaultValue={ticket.annotation} // jsonb, may be null; read once per ticket\n onSave={(annotation) => updateRow({ source: \"tickets\", id: ticket.id, patch: { annotation } })}\n onSubmit={({ notes, text }) => // image: a PNG Blob, to upload if you keep it\n insertRow({ source: \"bug_reports\", values: { ticket_id: ticket.id, summary: text, note_count: notes.length } })\n }\n submitLabel=\"File the bug\"\n className=\"min-h-0 flex-1\"\n />\n </AppLayout>\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- Crop, rotate or resize the screenshot. Replace swaps it for another;\n the marks stay where they were.\n- Annotate several screenshots at once: one image per annotation (keep a\n list of annotations for a series).\n- Renumber pins after a delete — numbers stay what they were.\n- Blur: \"hide\" is a solid bar drawn over the image. The original pixels\n are still in `image.src`; send the exported PNG, not the source, when\n what is hidden must not leave.\n- Take a screenshot for you: the browser cannot capture the page without\n asking; the person drops or pastes one.\n- Collaborate live: saves are last-write-wins.\n\nA markup tool it cannot be is your own component in `components/` —\nnot an edit to this block. Its files are fixed: `npm run check` refuses a\nchange, and `makersclaw add annotate` restores them.\n"
|
|
36
36
|
}
|
|
37
37
|
],
|
|
38
38
|
"categories": [
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"docs": "Installed to components/makersclaw/blocks/annotate. Import: import { Annotate, ANNOTATE_TOOLS, ANNOTATE_TEMPLATES, emptyAnnotation, toAnnotation, annotationNotes, annotationToText, noteMarkers, nextNoteNumber, markerSkeleton } from \"@/components/makersclaw/blocks/annotate\". Props and an example: components/makersclaw/blocks/annotate/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
56
56
|
"meta": {
|
|
57
57
|
"makersclaw": {
|
|
58
|
-
"version": "0.
|
|
58
|
+
"version": "0.8.0",
|
|
59
59
|
"useWhen": "Someone needs to point at parts of a screen and say something about each: a bug report, design feedback, a support question, a walkthrough, a request to an agent about what to change. Not a blank drawing board (whiteboard or sketch-canvas), not a wireframe (sketch-to-app), not an image upload alone (file-upload)."
|
|
60
60
|
}
|
|
61
61
|
}
|
package/r/approval-request.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "ApprovalRequest",
|
|
6
6
|
"description": "One request waiting on a decision: what is asked for as label/value facts (an amount in large type), who asked and when, why, a status badge, and Approve / Reject (reject asks for a reason). The decision goes out through async onApprove / onReject; the block shows it working, a failure, then the outcome in place of the buttons. A card alone, or a page of detail sections beside the card.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"docs": "Installed to components/makersclaw/blocks/approval-request. Import: import { ApprovalRequest } from \"@/components/makersclaw/blocks/approval-request\". Props and an example: components/makersclaw/blocks/approval-request/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
46
46
|
"meta": {
|
|
47
47
|
"makersclaw": {
|
|
48
|
-
"version": "0.
|
|
48
|
+
"version": "0.8.0",
|
|
49
49
|
"useWhen": "Someone must say yes or no to one thing another person asked for: a purchase, a refund, time off, a discount, an access request, a published post. Not for a queue of many requests (a data-table with row actions) or a yes/no about the founder's own action (kit Confirm)."
|
|
50
50
|
}
|
|
51
51
|
}
|
package/r/audio-player.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "AudioPlayer",
|
|
6
6
|
"description": "A waveform audio player (wavesurfer.js) in the workspace theme: play, skip back and forward, speed, mute; regions (spans of the audio in a theme tone, labelled in a lane under the waveform, click to play one) and markers (pins at moments, click to jump); a hover time readout; drag across the waveform to mark a new region; add a marker at the playhead; keyboard like a video player (K, J, L, M, arrows). Time events and a handle (seek, play a range) for syncing a transcript.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [
|
|
11
11
|
"wavesurfer.js@8.0.0",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"docs": "Installed to components/makersclaw/blocks/audio-player. Import: import { AudioPlayer, useAudioPlayer, formatTime, formatRange, spokenTime, regionAt, TONES, RATES } from \"@/components/makersclaw/blocks/audio-player\". Props and an example: components/makersclaw/blocks/audio-player/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
68
68
|
"meta": {
|
|
69
69
|
"makersclaw": {
|
|
70
|
-
"version": "0.
|
|
70
|
+
"version": "0.8.0",
|
|
71
71
|
"useWhen": "A screen plays a recording: a call or meeting recording, a voice note, a podcast clip, a voicemail, a sound file on a record. The call-review block is built on it. Not for video, not for recording audio from the microphone."
|
|
72
72
|
}
|
|
73
73
|
}
|
package/r/auth-form.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "AuthForm",
|
|
6
6
|
"description": "The account screens in one form: sign in (password, email only, or provider buttons for Google, GitHub, Apple and Microsoft), sign up with extra fields, a magic link, a reset link, and changing a password against a live rules checklist. The auth is yours: each submit is an async callback that returns { ok } or an error the form shows.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"docs": "Installed to components/makersclaw/blocks/auth-form. Import: import { AuthForm, PASSWORD_RULES } from \"@/components/makersclaw/blocks/auth-form\". Props and an example: components/makersclaw/blocks/auth-form/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
49
49
|
"meta": {
|
|
50
50
|
"makersclaw": {
|
|
51
|
-
"version": "0.
|
|
51
|
+
"version": "0.8.0",
|
|
52
52
|
"useWhen": "An app needs a sign-in, sign-up, forgot-password or change-password screen, or a settings page where a signed-in person changes their password."
|
|
53
53
|
}
|
|
54
54
|
}
|
package/r/board.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "Board",
|
|
6
6
|
"description": "Records as cards in columns, one per stage; drag a card to another column (mouse, touch, or the card's menu from the keyboard) and the stage is saved. Optional column icons, a per-column total (\"13pt\"), a category colour on each card's edge, and a stacked layout of folding sections with compact rows.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"docs": "Installed to components/makersclaw/blocks/board. Import: import { Board } from \"@/components/makersclaw/blocks/board\". Props and an example: components/makersclaw/blocks/board/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
49
49
|
"meta": {
|
|
50
50
|
"makersclaw": {
|
|
51
|
-
"version": "0.
|
|
51
|
+
"version": "0.8.0",
|
|
52
52
|
"useWhen": "A process where each record sits in one stage and moves forward: a pipeline, a hiring funnel, a sprint, a content calendar by status, outreach by reply state. Not for records with no stages (a table) or items placed on a timeline."
|
|
53
53
|
}
|
|
54
54
|
}
|
package/r/calendar-view.json
CHANGED
|
@@ -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.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
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": [
|
|
@@ -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.
|
|
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.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -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.
|
|
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.
|
|
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",
|
|
@@ -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.
|
|
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
|
}
|