@twoux/ui 0.0.0-stage → 0.1.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/LICENSE.md +61 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE.md +64 -0
- package/SHADCN-DIFF.md +228 -0
- package/agent/AGENTS.md +358 -0
- package/agent/docs-md/accessibility.md +49 -0
- package/agent/docs-md/action-catalogue.md +81 -0
- package/agent/docs-md/actions.md +74 -0
- package/agent/docs-md/agent-activity-log.md +70 -0
- package/agent/docs-md/agent-permissions.md +82 -0
- package/agent/docs-md/agent-run.md +90 -0
- package/agent/docs-md/agents-on-behalf.md +74 -0
- package/agent/docs-md/ai-column.md +85 -0
- package/agent/docs-md/ai-origin-label.md +70 -0
- package/agent/docs-md/announcing-features.md +73 -0
- package/agent/docs-md/answer-actions.md +81 -0
- package/agent/docs-md/app-shell.md +114 -0
- package/agent/docs-md/artifact-card.md +89 -0
- package/agent/docs-md/ask-on-screen.md +72 -0
- package/agent/docs-md/assistant-page.md +100 -0
- package/agent/docs-md/assistant-panel.md +142 -0
- package/agent/docs-md/avatar.md +82 -0
- package/agent/docs-md/banner.md +106 -0
- package/agent/docs-md/board.md +163 -0
- package/agent/docs-md/breadcrumb.md +89 -0
- package/agent/docs-md/breakdown-table.md +97 -0
- package/agent/docs-md/bulk-action-bar.md +112 -0
- package/agent/docs-md/button.md +293 -0
- package/agent/docs-md/card.md +115 -0
- package/agent/docs-md/change-list.md +99 -0
- package/agent/docs-md/change-request.md +166 -0
- package/agent/docs-md/chart-area.md +87 -0
- package/agent/docs-md/chart-bar.md +100 -0
- package/agent/docs-md/chart-box-plot.md +88 -0
- package/agent/docs-md/chart-bullet.md +89 -0
- package/agent/docs-md/chart-candlestick.md +112 -0
- package/agent/docs-md/chart-card-line.md +120 -0
- package/agent/docs-md/chart-donut.md +90 -0
- package/agent/docs-md/chart-drill-down.md +72 -0
- package/agent/docs-md/chart-funnel.md +71 -0
- package/agent/docs-md/chart-heatmap.md +76 -0
- package/agent/docs-md/chart-histogram.md +81 -0
- package/agent/docs-md/chart-map.md +165 -0
- package/agent/docs-md/chart-mixed.md +88 -0
- package/agent/docs-md/chart-price.md +92 -0
- package/agent/docs-md/chart-radial-bars.md +109 -0
- package/agent/docs-md/chart-radial-lines.md +111 -0
- package/agent/docs-md/chart-sankey.md +104 -0
- package/agent/docs-md/chart-scatter.md +87 -0
- package/agent/docs-md/chart-share-bar.md +89 -0
- package/agent/docs-md/chart-treemap.md +98 -0
- package/agent/docs-md/chart-waterfall.md +93 -0
- package/agent/docs-md/checkbox.md +131 -0
- package/agent/docs-md/choosing-selection-control.md +71 -0
- package/agent/docs-md/citations-sources.md +79 -0
- package/agent/docs-md/citing-sources.md +63 -0
- package/agent/docs-md/clarify-before-acting.md +69 -0
- package/agent/docs-md/clarifying-question.md +92 -0
- package/agent/docs-md/code-block.md +174 -0
- package/agent/docs-md/colours.md +87 -0
- package/agent/docs-md/columns-menu.md +140 -0
- package/agent/docs-md/combobox.md +161 -0
- package/agent/docs-md/command-palette.md +126 -0
- package/agent/docs-md/confidence-note.md +87 -0
- package/agent/docs-md/configurable-dashboard.md +76 -0
- package/agent/docs-md/confirmation-dialog.md +93 -0
- package/agent/docs-md/connected-tools.md +80 -0
- package/agent/docs-md/connector-picker.md +127 -0
- package/agent/docs-md/consistency.md +53 -0
- package/agent/docs-md/content-density.md +61 -0
- package/agent/docs-md/conversation-history.md +78 -0
- package/agent/docs-md/create-record.md +80 -0
- package/agent/docs-md/csv-import.md +88 -0
- package/agent/docs-md/dashboard-items.md +78 -0
- package/agent/docs-md/data-export.md +61 -0
- package/agent/docs-md/data-grid.md +137 -0
- package/agent/docs-md/data-vis-colours.md +76 -0
- package/agent/docs-md/data-visualisation.md +76 -0
- package/agent/docs-md/date-picker.md +178 -0
- package/agent/docs-md/delete-record.md +75 -0
- package/agent/docs-md/dense-table.md +495 -0
- package/agent/docs-md/design-tokens.md +53 -0
- package/agent/docs-md/disabled-read-only.md +75 -0
- package/agent/docs-md/drag-and-drop.md +74 -0
- package/agent/docs-md/dropdown-menu.md +137 -0
- package/agent/docs-md/edit-record.md +73 -0
- package/agent/docs-md/empty-loading-error.md +78 -0
- package/agent/docs-md/empty-state.md +128 -0
- package/agent/docs-md/error-messages.md +75 -0
- package/agent/docs-md/error-state.md +123 -0
- package/agent/docs-md/export-menu.md +86 -0
- package/agent/docs-md/feedback-prompt.md +88 -0
- package/agent/docs-md/field-mapping.md +78 -0
- package/agent/docs-md/file-drop.md +110 -0
- package/agent/docs-md/filter-bar.md +138 -0
- package/agent/docs-md/filter-panel.md +103 -0
- package/agent/docs-md/filtering-search.md +79 -0
- package/agent/docs-md/follow-ups-starters.md +79 -0
- package/agent/docs-md/form-validation.md +95 -0
- package/agent/docs-md/generated-columns.md +65 -0
- package/agent/docs-md/generated-drafts-files.md +71 -0
- package/agent/docs-md/grouped-list.md +89 -0
- package/agent/docs-md/guide-cue.md +170 -0
- package/agent/docs-md/help-panel.md +103 -0
- package/agent/docs-md/help-system.md +74 -0
- package/agent/docs-md/iconography.md +49 -0
- package/agent/docs-md/inbox.md +108 -0
- package/agent/docs-md/inline-editing-validation.md +80 -0
- package/agent/docs-md/inline-input-in-answer.md +70 -0
- package/agent/docs-md/items-palette.md +116 -0
- package/agent/docs-md/keyboard-model.md +47 -0
- package/agent/docs-md/keyboard-navigation.md +68 -0
- package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
- package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
- package/agent/docs-md/labelling-ai-output.md +66 -0
- package/agent/docs-md/layout.md +47 -0
- package/agent/docs-md/list-table.md +254 -0
- package/agent/docs-md/loading-skeleton.md +91 -0
- package/agent/docs-md/managing-agents.md +71 -0
- package/agent/docs-md/message-composer.md +105 -0
- package/agent/docs-md/migrating.md +49 -0
- package/agent/docs-md/motion.md +93 -0
- package/agent/docs-md/navigation.md +76 -0
- package/agent/docs-md/notification-channels.md +65 -0
- package/agent/docs-md/notifications-decision.md +100 -0
- package/agent/docs-md/onboarding-step.md +114 -0
- package/agent/docs-md/order-book.md +108 -0
- package/agent/docs-md/page-header.md +96 -0
- package/agent/docs-md/pagination.md +88 -0
- package/agent/docs-md/pane-layout.md +105 -0
- package/agent/docs-md/period-comparison.md +66 -0
- package/agent/docs-md/plan-card.md +120 -0
- package/agent/docs-md/popover-rules.md +88 -0
- package/agent/docs-md/prompt-editor.md +90 -0
- package/agent/docs-md/propose-apply.md +65 -0
- package/agent/docs-md/provider-logo.md +97 -0
- package/agent/docs-md/radio.md +83 -0
- package/agent/docs-md/ranked-list.md +102 -0
- package/agent/docs-md/reasoning-progress.md +72 -0
- package/agent/docs-md/record-detail.md +71 -0
- package/agent/docs-md/regenerating-answers.md +70 -0
- package/agent/docs-md/run-status-bar.md +94 -0
- package/agent/docs-md/saved-tasks-schedules.md +63 -0
- package/agent/docs-md/saved-views.md +149 -0
- package/agent/docs-md/saving-views.md +68 -0
- package/agent/docs-md/scheduler.md +258 -0
- package/agent/docs-md/secondary-panels.md +72 -0
- package/agent/docs-md/segmented-control.md +88 -0
- package/agent/docs-md/selection-bulk-actions.md +70 -0
- package/agent/docs-md/separator.md +75 -0
- package/agent/docs-md/setup-checklist.md +92 -0
- package/agent/docs-md/shape-elevation.md +55 -0
- package/agent/docs-md/sheet-grid.md +211 -0
- package/agent/docs-md/side-panel.md +134 -0
- package/agent/docs-md/sign-in.md +114 -0
- package/agent/docs-md/spacing.md +55 -0
- package/agent/docs-md/split-button.md +100 -0
- package/agent/docs-md/stat-compare.md +82 -0
- package/agent/docs-md/static-dashboard.md +75 -0
- package/agent/docs-md/status-badge.md +140 -0
- package/agent/docs-md/step-card.md +75 -0
- package/agent/docs-md/stepper.md +92 -0
- package/agent/docs-md/suggested-changes.md +91 -0
- package/agent/docs-md/switch.md +78 -0
- package/agent/docs-md/table-settings.md +81 -0
- package/agent/docs-md/tabs.md +85 -0
- package/agent/docs-md/tag-input.md +108 -0
- package/agent/docs-md/tag.md +79 -0
- package/agent/docs-md/task-builder.md +87 -0
- package/agent/docs-md/team-instructions.md +72 -0
- package/agent/docs-md/templates-gallery.md +85 -0
- package/agent/docs-md/text-input.md +160 -0
- package/agent/docs-md/thinking-steps.md +89 -0
- package/agent/docs-md/timeline.md +189 -0
- package/agent/docs-md/timestamp.md +84 -0
- package/agent/docs-md/timestamps.md +70 -0
- package/agent/docs-md/toast.md +117 -0
- package/agent/docs-md/tooltip.md +90 -0
- package/agent/docs-md/typography.md +62 -0
- package/agent/docs-md/undo-destructive.md +66 -0
- package/agent/docs-md/unsaved-changes.md +71 -0
- package/agent/docs-md/usage-meter.md +80 -0
- package/agent/docs-md/user-feedback.md +71 -0
- package/agent/docs-md/voice-and-tone.md +60 -0
- package/agent/docs-md/why-choice.md +93 -0
- package/agent/docs-md/workspace-onboarding.md +74 -0
- package/agent/docs-md/writing.md +48 -0
- package/agent/llms-full.txt +18369 -0
- package/agent/llms.txt +198 -0
- package/agent/skills/twoux-audit/SKILL.md +49 -0
- package/agent/skills/twoux-build/SKILL.md +39 -0
- package/agent/skills/twoux-design/SKILL.md +54 -0
- package/agent/skills/twoux-write/SKILL.md +32 -0
- package/bin/check.mjs +123 -0
- package/bin/docs.mjs +24 -0
- package/bin/guard.mjs +27 -0
- package/bin/init.mjs +113 -0
- package/bin/knowledge.mjs +87 -0
- package/bin/mcp.mjs +116 -0
- package/bin/theme.mjs +168 -0
- package/bin/twoux.mjs +25 -0
- package/package.json +80 -3
- package/registry/jobs.json +1028 -0
- package/registry/palette.json +770 -0
- package/registry/rules.json +913 -0
- package/src/components/agent-review.tsx +364 -0
- package/src/components/ai-basics.tsx +212 -0
- package/src/components/ai-work.tsx +304 -0
- package/src/components/ai.tsx +227 -0
- package/src/components/alert-dialog.tsx +203 -0
- package/src/components/alert.tsx +86 -0
- package/src/components/avatar.tsx +114 -0
- package/src/components/badge.tsx +59 -0
- package/src/components/board.tsx +397 -0
- package/src/components/breadcrumb.tsx +114 -0
- package/src/components/button-group.tsx +90 -0
- package/src/components/button.tsx +109 -0
- package/src/components/calendar.tsx +247 -0
- package/src/components/card.tsx +102 -0
- package/src/components/chart-map.tsx +336 -0
- package/src/components/charts-more.tsx +1331 -0
- package/src/components/charts-radial.tsx +328 -0
- package/src/components/charts.tsx +390 -0
- package/src/components/checkbox.tsx +40 -0
- package/src/components/code.tsx +139 -0
- package/src/components/collapsible.tsx +37 -0
- package/src/components/combobox.tsx +339 -0
- package/src/components/command-extras.tsx +11 -0
- package/src/components/command.tsx +196 -0
- package/src/components/data-grid.tsx +213 -0
- package/src/components/data-table.tsx +334 -0
- package/src/components/data-views.tsx +740 -0
- package/src/components/date-picker.tsx +686 -0
- package/src/components/dense-table.tsx +207 -0
- package/src/components/dialog.tsx +162 -0
- package/src/components/dropdown-menu.tsx +264 -0
- package/src/components/empty.tsx +114 -0
- package/src/components/feedback-prompt.tsx +80 -0
- package/src/components/field.tsx +254 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/flow.tsx +840 -0
- package/src/components/guide-cue.tsx +273 -0
- package/src/components/inbox.tsx +113 -0
- package/src/components/input-group.tsx +176 -0
- package/src/components/input.tsx +26 -0
- package/src/components/kbd.tsx +34 -0
- package/src/components/label.tsx +27 -0
- package/src/components/labels.tsx +176 -0
- package/src/components/motion.tsx +136 -0
- package/src/components/onboarding.tsx +454 -0
- package/src/components/order-book.tsx +185 -0
- package/src/components/pane-layout.tsx +417 -0
- package/src/components/popover.tsx +96 -0
- package/src/components/provider-logos.data.ts +49 -0
- package/src/components/radio-group.tsx +52 -0
- package/src/components/scheduler.tsx +997 -0
- package/src/components/segmented-control.tsx +28 -0
- package/src/components/select.tsx +195 -0
- package/src/components/separator.tsx +31 -0
- package/src/components/sheet.tsx +148 -0
- package/src/components/shell.tsx +426 -0
- package/src/components/shortcut-sheet.tsx +83 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sonner.tsx +107 -0
- package/src/components/spinner.tsx +13 -0
- package/src/components/split-button.tsx +58 -0
- package/src/components/stepper.tsx +78 -0
- package/src/components/switch.tsx +43 -0
- package/src/components/table.tsx +126 -0
- package/src/components/tabs.tsx +118 -0
- package/src/components/tag-input.tsx +253 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/theme.tsx +80 -0
- package/src/components/timeline.tsx +408 -0
- package/src/components/toggle-group.tsx +87 -0
- package/src/components/toggle.tsx +54 -0
- package/src/components/tooltip.tsx +65 -0
- package/src/components/workspace-actions.tsx +294 -0
- package/src/components/workspace-import.tsx +465 -0
- package/src/components/workspace.tsx +1884 -0
- package/src/index.ts +80 -0
- package/src/lib/active-indicator.tsx +53 -0
- package/src/lib/chart-kit.tsx +72 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/shortcuts.tsx +61 -0
- package/src/lib/workspace.tsx +10 -0
- package/src/shadcn/alert-dialog.tsx +196 -0
- package/src/shadcn/alert.tsx +65 -0
- package/src/shadcn/avatar.tsx +108 -0
- package/src/shadcn/badge.tsx +47 -0
- package/src/shadcn/breadcrumb.tsx +108 -0
- package/src/shadcn/button-group.tsx +83 -0
- package/src/shadcn/button.tsx +63 -0
- package/src/shadcn/calendar.tsx +220 -0
- package/src/shadcn/card.tsx +91 -0
- package/src/shadcn/checkbox.tsx +31 -0
- package/src/shadcn/collapsible.tsx +33 -0
- package/src/shadcn/command.tsx +184 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/dropdown-menu.tsx +256 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +248 -0
- package/src/shadcn/input-group.tsx +170 -0
- package/src/shadcn/input.tsx +20 -0
- package/src/shadcn/kbd.tsx +28 -0
- package/src/shadcn/label.tsx +23 -0
- package/src/shadcn/popover.tsx +88 -0
- package/src/shadcn/radio-group.tsx +44 -0
- package/src/shadcn/select.tsx +189 -0
- package/src/shadcn/separator.tsx +27 -0
- package/src/shadcn/sheet.tsx +142 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/sonner.tsx +40 -0
- package/src/shadcn/switch.tsx +34 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +90 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +83 -0
- package/src/shadcn/toggle.tsx +46 -0
- package/src/shadcn/tooltip.tsx +56 -0
- package/src/styles/palette.css +127 -0
- package/src/styles/theme.css +35 -0
- package/src/styles/twoux.css +236 -0
- package/theme.json +14 -0
- package/README.md +0 -3
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX radial charts: two narrow uses of polar coordinates, adapted from MUI X radial bars and radial lines.
|
|
3
|
+
*
|
|
4
|
+
* RadialBars: one to four concentric rings, each the share of a fixed limit (seats, storage, credits, an SLA budget).
|
|
5
|
+
* Small, with value and limit always written as text; the ring only repeats them. Never actual against target
|
|
6
|
+
* (that is BulletChart), never categories compared around a circle (that is a bar chart), never a dial with a needle.
|
|
7
|
+
*
|
|
8
|
+
* RadialLines: a line chart in polar coordinates for cyclical periods only (hour of day, day of week, month),
|
|
9
|
+
* where the last period meets the first. One to three series, value axis from zero, missing data as a gap.
|
|
10
|
+
*
|
|
11
|
+
* Same rules as charts.tsx: own SVG, series colours in fixed order (c1…c8), text never in a series colour,
|
|
12
|
+
* one tab stop per chart with arrow keys between marks, Esc clears, a Tip on hover and focus, and a table helper.
|
|
13
|
+
*/
|
|
14
|
+
import * as React from "react"
|
|
15
|
+
import { cn } from "../lib/cn"
|
|
16
|
+
import { Badge } from "./badge"
|
|
17
|
+
import { ChartLegend, niceTicks, seriesColor } from "./charts"
|
|
18
|
+
import { fmt, pct, svgCls, halo, useWidth, useActive, Tip, type TipRow } from "../lib/chart-kit"
|
|
19
|
+
|
|
20
|
+
/* ---------------------------------------------------------------- shared */
|
|
21
|
+
|
|
22
|
+
const TAU = Math.PI * 2
|
|
23
|
+
/** Angle for a share of a full turn, starting at 12 o'clock and running clockwise. */
|
|
24
|
+
const turn = (share: number) => -Math.PI / 2 + share * TAU
|
|
25
|
+
const polar = (cx: number, cy: number, r: number, a: number): [number, number] => [cx + r * Math.cos(a), cy + r * Math.sin(a)]
|
|
26
|
+
|
|
27
|
+
/* ---------------------------------------------------------------- radial bars */
|
|
28
|
+
|
|
29
|
+
export type RadialBarItem = {
|
|
30
|
+
id: string
|
|
31
|
+
/** What is measured, e.g. "Seats". */
|
|
32
|
+
label: string
|
|
33
|
+
value: number
|
|
34
|
+
/** The fixed limit the value is a share of: capacity, quota or budget. */
|
|
35
|
+
limit: number
|
|
36
|
+
/** Unit written after the numbers, e.g. "seats", "GB". */
|
|
37
|
+
unit?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MAX_RINGS = 4
|
|
41
|
+
|
|
42
|
+
const ringShare = (it: RadialBarItem) => (it.limit > 0 ? it.value / it.limit : 0)
|
|
43
|
+
const ringText = (it: RadialBarItem, format: (n: number) => string) => `${format(it.value)} of ${format(it.limit)}${it.unit ? ` ${it.unit}` : ""}`
|
|
44
|
+
const ringStatus = (it: RadialBarItem) => (it.value > it.limit ? "Over limit" : it.value === it.limit && it.limit > 0 ? "At limit" : null)
|
|
45
|
+
|
|
46
|
+
export const radialBarsTable = (items: RadialBarItem[], format: (n: number) => string = fmt) => ({
|
|
47
|
+
columns: ["Measure", "Used", "Limit", "Share of limit"],
|
|
48
|
+
rows: items.slice(0, MAX_RINGS).map((it) => [it.label, `${format(it.value)}${it.unit ? ` ${it.unit}` : ""}`, `${format(it.limit)}${it.unit ? ` ${it.unit}` : ""}`, pct(it.value, it.limit, 0)]),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Concentric rings, outermost first, each the share of its own limit from 12 o'clock clockwise.
|
|
53
|
+
* The list beside the rings carries every value in words; a ring at or over its limit adds a status badge.
|
|
54
|
+
*/
|
|
55
|
+
export function RadialBars({
|
|
56
|
+
items,
|
|
57
|
+
format = fmt,
|
|
58
|
+
size = 128,
|
|
59
|
+
"aria-label": label,
|
|
60
|
+
className,
|
|
61
|
+
}: {
|
|
62
|
+
/** One to four measures; extra items are not drawn. */
|
|
63
|
+
items: RadialBarItem[]
|
|
64
|
+
format?: (n: number) => string
|
|
65
|
+
/** Diameter of the rings in px (96 to 160). */
|
|
66
|
+
size?: number
|
|
67
|
+
"aria-label": string
|
|
68
|
+
className?: string
|
|
69
|
+
}) {
|
|
70
|
+
const rings = items.slice(0, MAX_RINGS)
|
|
71
|
+
const { active, setActive, onKey } = useActive(rings.length)
|
|
72
|
+
const S = Math.min(160, Math.max(96, size))
|
|
73
|
+
const c = S / 2
|
|
74
|
+
const n = rings.length
|
|
75
|
+
const gap = 3
|
|
76
|
+
const stroke = Math.max(6, Math.min(12, (c - 18 - gap * (n - 1)) / Math.max(n, 2)))
|
|
77
|
+
const radius = (i: number) => c - stroke / 2 - i * (stroke + gap)
|
|
78
|
+
const geo = rings.map((it, i) => {
|
|
79
|
+
const share = ringShare(it), shown = Math.min(1, Math.max(0, share)), r = radius(i)
|
|
80
|
+
const [x0, y0] = polar(c, c, r, turn(0)), [x1, y1] = polar(c, c, r, turn(shown))
|
|
81
|
+
const d = shown <= 0 || shown >= 1 ? null : `M${x0},${y0}A${r},${r} 0 ${shown > 0.5 ? 1 : 0} 1 ${x1},${y1}`
|
|
82
|
+
return { it, i, r, share, shown, d, end: [x1, y1] as [number, number] }
|
|
83
|
+
})
|
|
84
|
+
const summary = `${label}. ${rings.map((it) => `${it.label}: ${ringText(it, format)}, ${pct(it.value, it.limit, 0)} of the limit${ringStatus(it) ? `, ${ringStatus(it)!.toLowerCase()}` : ""}`).join("; ")}.`
|
|
85
|
+
const cur = active === null ? null : geo[active]
|
|
86
|
+
return (
|
|
87
|
+
<div data-slot="radial-bars" className={cn("flex flex-wrap items-center gap-x-4 gap-y-3", className)}>
|
|
88
|
+
<div className="relative shrink-0">
|
|
89
|
+
<svg
|
|
90
|
+
width={S}
|
|
91
|
+
height={S}
|
|
92
|
+
viewBox={`0 0 ${S} ${S}`}
|
|
93
|
+
role="img"
|
|
94
|
+
tabIndex={0}
|
|
95
|
+
aria-label={summary}
|
|
96
|
+
onKeyDown={onKey}
|
|
97
|
+
onBlur={() => setActive(null)}
|
|
98
|
+
onMouseLeave={() => setActive(null)}
|
|
99
|
+
className={svgCls}
|
|
100
|
+
>
|
|
101
|
+
{geo.map((g) => (
|
|
102
|
+
<g key={g.it.id} onMouseEnter={() => setActive(g.i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== g.i && "opacity-40")}>
|
|
103
|
+
<circle cx={c} cy={c} r={g.r} fill="none" stroke="var(--color-hover)" strokeWidth={stroke} />
|
|
104
|
+
{g.shown >= 1 ? (
|
|
105
|
+
<circle cx={c} cy={c} r={g.r} fill="none" stroke={seriesColor(g.i)} strokeWidth={stroke} />
|
|
106
|
+
) : g.d ? (
|
|
107
|
+
<path d={g.d} fill="none" stroke={seriesColor(g.i)} strokeWidth={stroke} />
|
|
108
|
+
) : null}
|
|
109
|
+
{/* zero mark at 12 o'clock so an empty ring still shows where it starts */}
|
|
110
|
+
<line x1={c} x2={c} y1={c - g.r - stroke / 2} y2={c - g.r + stroke / 2} stroke="var(--color-line-strong)" strokeWidth={1} />
|
|
111
|
+
</g>
|
|
112
|
+
))}
|
|
113
|
+
{n === 1 && (
|
|
114
|
+
<text x={c} y={c} dy="0.35em" textAnchor="middle" className="fill-ink text-lg font-semibold tabular">{pct(rings[0].value, rings[0].limit, 0)}</text>
|
|
115
|
+
)}
|
|
116
|
+
</svg>
|
|
117
|
+
{cur && (
|
|
118
|
+
<Tip x={-12} y={S + 6} width={S} title={cur.it.label} rows={[
|
|
119
|
+
{ label: "Used", value: ringText(cur.it, format), color: seriesColor(cur.i) },
|
|
120
|
+
{ label: "Of limit", value: pct(cur.it.value, cur.it.limit, 0) },
|
|
121
|
+
...(ringStatus(cur.it) ? [{ label: "Status", value: ringStatus(cur.it)! }] : []),
|
|
122
|
+
]} />
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
<ul className="grid min-w-0 flex-1 basis-32 gap-2" aria-label="Values">
|
|
126
|
+
{rings.map((it, i) => {
|
|
127
|
+
const status = ringStatus(it)
|
|
128
|
+
return (
|
|
129
|
+
<li key={it.id} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)} className={cn("grid grid-cols-[auto_1fr] items-center gap-x-2 text-sm transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-60")}>
|
|
130
|
+
<span aria-hidden="true" className="size-2.5 rounded-xs bg-(--c)" style={{ "--c": seriesColor(i) } as React.CSSProperties} />
|
|
131
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
132
|
+
<span className="truncate text-ink-2">{it.label}</span>
|
|
133
|
+
{status && <Badge variant={status === "Over limit" ? "destructive" : "warning"}>{status}</Badge>}
|
|
134
|
+
</span>
|
|
135
|
+
<span className="tabular col-start-2 text-ink"><span className="font-medium">{ringText(it, format)}</span><span className="text-ink-3"> · {pct(it.value, it.limit, 0)}</span></span>
|
|
136
|
+
</li>
|
|
137
|
+
)
|
|
138
|
+
})}
|
|
139
|
+
</ul>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ---------------------------------------------------------------- radial lines */
|
|
145
|
+
|
|
146
|
+
export type RadialLineSeries = {
|
|
147
|
+
id: string
|
|
148
|
+
label: string
|
|
149
|
+
/** One value per period, in period order; null for a gap (never zero). */
|
|
150
|
+
data: (number | null)[]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const radialLinesTable = (periods: string[], series: RadialLineSeries[], format: (n: number) => string = fmt, periodLabel = "Period") => ({
|
|
154
|
+
columns: [periodLabel, ...series.slice(0, 3).map((s) => s.label)],
|
|
155
|
+
rows: periods.map((p, i) => [p, ...series.slice(0, 3).map((s) => (s.data[i] == null ? "No data" : format(s.data[i] as number)))]),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/** Rings at round values from zero: three or four labelled rings. */
|
|
159
|
+
function ringTicks(max: number) {
|
|
160
|
+
let t = niceTicks(max, 4)
|
|
161
|
+
if (t.length - 1 > 4) t = niceTicks(max, 3)
|
|
162
|
+
return t
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A line chart wrapped round a circle, for cyclical periods only. The first period sits at 12 o'clock and
|
|
167
|
+
* periods run clockwise, so the last one meets the first. Distance from the centre is the value, from zero.
|
|
168
|
+
*/
|
|
169
|
+
export function RadialLines({
|
|
170
|
+
periods,
|
|
171
|
+
series,
|
|
172
|
+
format = fmt,
|
|
173
|
+
periodLabel = "Period",
|
|
174
|
+
maxSize = 320,
|
|
175
|
+
"aria-label": label,
|
|
176
|
+
className,
|
|
177
|
+
}: {
|
|
178
|
+
/** Labels for each period of the cycle, e.g. "00:00"…"23:00" or "Mon"…"Sun". */
|
|
179
|
+
periods: string[]
|
|
180
|
+
/** One to three series; extra series are not drawn. */
|
|
181
|
+
series: RadialLineSeries[]
|
|
182
|
+
format?: (n: number) => string
|
|
183
|
+
/** Name of a period, for the tooltip and table, e.g. "Hour". */
|
|
184
|
+
periodLabel?: string
|
|
185
|
+
/** Largest diameter in px; the chart shrinks to its container. */
|
|
186
|
+
maxSize?: number
|
|
187
|
+
"aria-label": string
|
|
188
|
+
className?: string
|
|
189
|
+
}) {
|
|
190
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
191
|
+
const all = series.slice(0, 3)
|
|
192
|
+
const [hidden, setHidden] = React.useState<string[]>([])
|
|
193
|
+
const shown = all.map((s, i) => ({ s, i })).filter(({ s }) => !hidden.includes(s.id))
|
|
194
|
+
const n = periods.length
|
|
195
|
+
const { active, setActive } = useActive(n)
|
|
196
|
+
const S = Math.max(160, Math.min(maxSize, width))
|
|
197
|
+
const c = S / 2
|
|
198
|
+
const labelPad = 22
|
|
199
|
+
const R = c - labelPad
|
|
200
|
+
const max = Math.max(0, ...all.flatMap((s) => s.data.filter((v): v is number => v != null)))
|
|
201
|
+
const ticks = ringTicks(max)
|
|
202
|
+
const top = ticks[ticks.length - 1] || 1
|
|
203
|
+
const ang = (i: number) => turn(i / Math.max(1, n))
|
|
204
|
+
const pt = (i: number, v: number) => polar(c, c, (v / top) * R, ang(i))
|
|
205
|
+
// label every period when there is room, otherwise every 2nd, 3rd or 6th
|
|
206
|
+
const every = [1, 2, 3, 4, 6].find((k) => (TAU * (R + 12)) / (n / k) >= 34) ?? Math.ceil(n / 8)
|
|
207
|
+
|
|
208
|
+
const paths = (data: (number | null)[]) => {
|
|
209
|
+
// runs of consecutive values; the run that ends at the last period joins the one starting at the first
|
|
210
|
+
const segs: string[] = []
|
|
211
|
+
const dots: [number, number][] = []
|
|
212
|
+
let run: number[] = []
|
|
213
|
+
const flush = () => {
|
|
214
|
+
if (run.length === 1) dots.push(pt(run[0], data[run[0]] as number))
|
|
215
|
+
else if (run.length > 1) segs.push(run.map((j, k) => `${k ? "L" : "M"}${pt(j, data[j] as number).join(",")}`).join(""))
|
|
216
|
+
run = []
|
|
217
|
+
}
|
|
218
|
+
for (let j = 0; j < n; j++) { if (data[j] == null) flush(); else run.push(j) }
|
|
219
|
+
flush()
|
|
220
|
+
if (n > 2 && data[n - 1] != null && data[0] != null) segs.push(`M${pt(n - 1, data[n - 1] as number).join(",")}L${pt(0, data[0] as number).join(",")}`)
|
|
221
|
+
return { d: segs.join(""), dots }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const onKey = (e: React.KeyboardEvent) => {
|
|
225
|
+
if (!n) return
|
|
226
|
+
const step = (d: number) => { e.preventDefault(); setActive((a) => (a === null ? (d > 0 ? 0 : n - 1) : (a + d + n) % n)) }
|
|
227
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") step(1)
|
|
228
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") step(-1)
|
|
229
|
+
else if (e.key === "Home") { e.preventDefault(); setActive(0) }
|
|
230
|
+
else if (e.key === "End") { e.preventDefault(); setActive(n - 1) }
|
|
231
|
+
else if (e.key === "Escape") setActive(null)
|
|
232
|
+
}
|
|
233
|
+
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
|
234
|
+
const box = e.currentTarget.getBoundingClientRect()
|
|
235
|
+
const dx = e.clientX - box.left - c, dy = e.clientY - box.top - c
|
|
236
|
+
if (Math.hypot(dx, dy) > R + 16) { setActive(null); return }
|
|
237
|
+
const a = (Math.atan2(dy, dx) + Math.PI / 2 + TAU) % TAU
|
|
238
|
+
setActive(Math.round((a / TAU) * n) % n)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const describe = (s: RadialLineSeries) => {
|
|
242
|
+
const vals = s.data.map((v, i) => ({ v, i })).filter((x): x is { v: number; i: number } => x.v != null)
|
|
243
|
+
if (!vals.length) return `${s.label}: no data`
|
|
244
|
+
const hi = vals.reduce((a, b) => (b.v > a.v ? b : a)), lo = vals.reduce((a, b) => (b.v < a.v ? b : a))
|
|
245
|
+
const gaps = n - vals.length
|
|
246
|
+
return `${s.label}: highest at ${periods[hi.i]} with ${format(hi.v)}, lowest at ${periods[lo.i]} with ${format(lo.v)}${gaps ? `, ${gaps} ${gaps === 1 ? "period" : "periods"} without data` : ""}`
|
|
247
|
+
}
|
|
248
|
+
const summary = `${label}. Radial line chart over ${n} ${periodLabel.toLowerCase()}s, starting at ${periods[0]} at the top and running clockwise. ${all.map(describe).join("; ")}.`
|
|
249
|
+
|
|
250
|
+
const tipRows: TipRow[] = active === null ? [] : shown.map(({ s, i }) => ({ label: s.label, value: s.data[active] == null ? "No data" : format(s.data[active] as number), color: seriesColor(i) }))
|
|
251
|
+
const tipAt = active === null ? null : polar(c, c, R * 0.6, ang(active))
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div data-slot="radial-lines" className={cn("grid gap-3", className)}>
|
|
255
|
+
<div ref={wrap} className="w-full">
|
|
256
|
+
{width > 0 && (
|
|
257
|
+
<div className="relative mx-auto w-fit">
|
|
258
|
+
<svg
|
|
259
|
+
width={S}
|
|
260
|
+
height={S}
|
|
261
|
+
viewBox={`0 0 ${S} ${S}`}
|
|
262
|
+
role="img"
|
|
263
|
+
tabIndex={0}
|
|
264
|
+
aria-label={summary}
|
|
265
|
+
onKeyDown={onKey}
|
|
266
|
+
onBlur={() => setActive(null)}
|
|
267
|
+
onMouseMove={onMove}
|
|
268
|
+
onMouseLeave={() => setActive(null)}
|
|
269
|
+
className={svgCls}
|
|
270
|
+
>
|
|
271
|
+
{/* value rings from zero */}
|
|
272
|
+
{ticks.slice(1).map((t, k) => (
|
|
273
|
+
<circle key={t} cx={c} cy={c} r={(t / top) * R} fill="none" className={k === ticks.length - 2 ? "stroke-line-strong" : "stroke-line"} strokeWidth={1} />
|
|
274
|
+
))}
|
|
275
|
+
{/* one spoke per period */}
|
|
276
|
+
{periods.map((p, i) => {
|
|
277
|
+
const [x, y] = polar(c, c, R, ang(i))
|
|
278
|
+
return <line key={p + i} x1={c} y1={c} x2={x} y2={y} className={active === i ? "stroke-ink-3" : "stroke-line"} strokeWidth={1} />
|
|
279
|
+
})}
|
|
280
|
+
{/* period labels round the outside */}
|
|
281
|
+
{periods.map((p, i) => {
|
|
282
|
+
if (i % every !== 0 && active !== i) return null
|
|
283
|
+
const a = ang(i), cos = Math.cos(a), sin = Math.sin(a)
|
|
284
|
+
const [x, y] = polar(c, c, R + 8, a)
|
|
285
|
+
return (
|
|
286
|
+
<text key={`l${p}${i}`} x={x} y={y} dy={sin < -0.3 ? "-0.1em" : sin > 0.3 ? "0.8em" : "0.32em"} textAnchor={cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"} className={cn("text-xs tabular", active === i ? "fill-ink font-medium" : "fill-ink-3")}>
|
|
287
|
+
{p}
|
|
288
|
+
</text>
|
|
289
|
+
)
|
|
290
|
+
})}
|
|
291
|
+
{/* lines */}
|
|
292
|
+
{shown.map(({ s, i }) => {
|
|
293
|
+
const { d, dots } = paths(s.data)
|
|
294
|
+
return (
|
|
295
|
+
<g key={s.id}>
|
|
296
|
+
{d && <path d={d} fill="none" stroke={seriesColor(i)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
|
297
|
+
{dots.map(([x, y]) => <circle key={`${x},${y}`} cx={x} cy={y} r={2.5} fill={seriesColor(i)} />)}
|
|
298
|
+
</g>
|
|
299
|
+
)
|
|
300
|
+
})}
|
|
301
|
+
{/* ring values, along the 12 o'clock spoke */}
|
|
302
|
+
{ticks.slice(1).map((t) => (
|
|
303
|
+
<text key={`v${t}`} x={c + 4} y={c - (t / top) * R} dy="0.9em" strokeWidth={3} className={cn("fill-ink-3 text-xs tabular", halo)}>{format(t)}</text>
|
|
304
|
+
))}
|
|
305
|
+
{/* active period: a dot per series */}
|
|
306
|
+
{active !== null && shown.map(({ s, i }) => {
|
|
307
|
+
const v = s.data[active]
|
|
308
|
+
if (v == null) return null
|
|
309
|
+
const [x, y] = pt(active, v)
|
|
310
|
+
return <circle key={`a${s.id}`} cx={x} cy={y} r={4} fill={seriesColor(i)} className="stroke-surface" strokeWidth={2} />
|
|
311
|
+
})}
|
|
312
|
+
</svg>
|
|
313
|
+
{active !== null && tipAt && (
|
|
314
|
+
<Tip x={tipAt[0]} y={Math.min(tipAt[1], S - 40 - 24 * tipRows.length)} width={S} title={`${periodLabel} ${periods[active]}`} rows={tipRows} />
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
{all.length >= 2 && (
|
|
320
|
+
<ChartLegend
|
|
321
|
+
items={all.map((s, i) => ({ id: s.id, label: s.label, color: seriesColor(i), shape: "line" as const }))}
|
|
322
|
+
hidden={hidden}
|
|
323
|
+
onToggle={(id) => setHidden((h) => (h.includes(id) ? h.filter((x) => x !== id) : h.length + 1 >= all.length ? h : [...h, id]))}
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|