@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,1331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX charts, part two: the forms data-heavy B2B and finance screens need beyond charts.tsx.
|
|
3
|
+
* Donut, heatmap, funnel, scatter, share bar, ranked list, change list, price chart, waterfall, bullet chart, treemap, sankey,
|
|
4
|
+
* histogram, box plot and candlestick. Same rules as charts.tsx: own SVG, one axis, series colours
|
|
5
|
+
* in fixed order (c1…c8), status colours only for good and bad, text never in a series colour,
|
|
6
|
+
* one tab stop per chart with arrow keys between marks, and a table helper for every chart.
|
|
7
|
+
*/
|
|
8
|
+
import * as React from "react"
|
|
9
|
+
import { cn } from "../lib/cn"
|
|
10
|
+
import { niceTicks, seriesColor, Sparkline } from "./charts"
|
|
11
|
+
import { fmt, MINUS, pct, NEUTRAL, textW, svgCls, halo, useWidth, useActive, niceStep, Tip, type TipRow } from "../lib/chart-kit"
|
|
12
|
+
|
|
13
|
+
/* ---------------------------------------------------------------- shared */
|
|
14
|
+
|
|
15
|
+
/** "+£1,200" / "−£800": a real minus sign, never a hyphen. */
|
|
16
|
+
export const signed = (n: number, f: (n: number) => string = fmt) => `${n > 0 ? "+" : n < 0 ? MINUS : ""}${f(Math.abs(n))}`
|
|
17
|
+
|
|
18
|
+
/** Ticks for a range that need not start at zero. */
|
|
19
|
+
export function rangeTicks(min: number, max: number, count = 4) {
|
|
20
|
+
if (max <= min) max = min + 1
|
|
21
|
+
const step = niceStep((max - min) / count)
|
|
22
|
+
const lo = Math.floor(min / step) * step, hi = Math.ceil(max / step) * step
|
|
23
|
+
return Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => +(lo + i * step).toFixed(10))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A legend whose keys mirror the marks: filled box, hollow box or line. */
|
|
27
|
+
export function KeyLegend({ items }: { items: { label: string; color: string; shape?: "box" | "hollow" | "line" }[] }) {
|
|
28
|
+
if (items.length < 2) return null
|
|
29
|
+
return (
|
|
30
|
+
<ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label="Legend">
|
|
31
|
+
{items.map((it) => (
|
|
32
|
+
<li key={it.label} className="inline-flex items-center gap-control text-sm text-ink-2">
|
|
33
|
+
<span
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
className={cn("shrink-0", it.shape === "line" ? "h-3 w-0.5 rounded-full bg-(--c)" : it.shape === "hollow" ? "size-2.5 rounded-xs border-2 border-(--c)" : "size-2.5 rounded-xs bg-(--c)")}
|
|
36
|
+
style={{ "--c": it.color } as React.CSSProperties}
|
|
37
|
+
/>
|
|
38
|
+
{it.label}
|
|
39
|
+
</li>
|
|
40
|
+
))}
|
|
41
|
+
</ul>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Rounded at the data end, square where the bar starts. */
|
|
46
|
+
function barPath(x: number, yTop: number, yBot: number, w: number, end: "top" | "bottom") {
|
|
47
|
+
const h = Math.max(1, yBot - yTop)
|
|
48
|
+
yBot = yTop + h
|
|
49
|
+
const r = Math.min(4, w / 2, h)
|
|
50
|
+
return end === "top"
|
|
51
|
+
? `M${x},${yBot}V${yTop + r}Q${x},${yTop} ${x + r},${yTop}H${x + w - r}Q${x + w},${yTop} ${x + w},${yTop + r}V${yBot}Z`
|
|
52
|
+
: `M${x},${yTop}V${yBot - r}Q${x},${yBot} ${x + r},${yBot}H${x + w - r}Q${x + w},${yBot} ${x + w},${yBot - r}V${yTop}Z`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ---------------------------------------------------------------- change value */
|
|
56
|
+
|
|
57
|
+
/** A change shown with arrow and sign, coloured by whether the direction is good; the word is read out. */
|
|
58
|
+
export function ChangeValue({ value, format = (n) => `${n.toFixed(2)}%`, upIs = "good", className }: { value: number; format?: (n: number) => string; upIs?: "good" | "bad" | "neutral"; className?: string }) {
|
|
59
|
+
const dir = value > 0 ? "up" : value < 0 ? "down" : "flat"
|
|
60
|
+
const tone = upIs === "neutral" || dir === "flat" ? "text-ink-2" : (dir === "up") === (upIs === "good") ? "text-ok" : "text-bad"
|
|
61
|
+
return (
|
|
62
|
+
<span className={cn("tabular whitespace-nowrap", tone, className)}>
|
|
63
|
+
<span aria-hidden="true">{dir === "up" ? "↑" : dir === "down" ? "↓" : "→"} {signed(value, format)}</span>
|
|
64
|
+
<span className="sr-only">{dir === "flat" ? "unchanged" : `${dir} ${format(Math.abs(value))}`}</span>
|
|
65
|
+
</span>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------------------------------------------------------------- share bar */
|
|
70
|
+
|
|
71
|
+
export type SharePart = { id: string; label: string; value: number }
|
|
72
|
+
type FoldedPart = SharePart & { other?: number }
|
|
73
|
+
|
|
74
|
+
/** Keep the largest parts, fold the rest into “Other” so a bar never has more than `max` parts. */
|
|
75
|
+
export function foldParts<T extends SharePart>(parts: T[], max = 6, otherLabel = "Other"): (T | FoldedPart)[] {
|
|
76
|
+
if (parts.length <= max) return parts
|
|
77
|
+
const keep = new Set([...parts].sort((a, b) => b.value - a.value).slice(0, max - 1).map((p) => p.id))
|
|
78
|
+
const rest = parts.filter((p) => !keep.has(p.id))
|
|
79
|
+
return [...parts.filter((p) => keep.has(p.id)), { id: "__other", label: otherLabel, value: rest.reduce((a, p) => a + p.value, 0), other: rest.length }]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Table view for a share bar or treemap. */
|
|
83
|
+
export const shareTable = (parts: SharePart[], format: (n: number) => string = fmt, first = "Part") => {
|
|
84
|
+
const sum = parts.reduce((a, p) => a + p.value, 0)
|
|
85
|
+
return { columns: [first, "Value", "Share"], rows: parts.map((p) => [p.label, format(p.value), pct(p.value, sum)]) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One whole split into up to six parts, read at a glance. Thin (8px, figures in the legend above)
|
|
90
|
+
* or tall (28px, share printed in each block where it fits, names in the legend).
|
|
91
|
+
*/
|
|
92
|
+
export function ShareBar({
|
|
93
|
+
parts,
|
|
94
|
+
max = 6,
|
|
95
|
+
variant = "thin",
|
|
96
|
+
format = fmt,
|
|
97
|
+
showValue = false,
|
|
98
|
+
"aria-label": label,
|
|
99
|
+
className,
|
|
100
|
+
}: {
|
|
101
|
+
parts: SharePart[]
|
|
102
|
+
/** Parts past this fold into “Other”. */
|
|
103
|
+
max?: number
|
|
104
|
+
variant?: "thin" | "tall"
|
|
105
|
+
format?: (n: number) => string
|
|
106
|
+
/** Also print each part's amount in the legend (thin only). */
|
|
107
|
+
showValue?: boolean
|
|
108
|
+
"aria-label": string
|
|
109
|
+
className?: string
|
|
110
|
+
}) {
|
|
111
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
112
|
+
const items = foldParts(parts, max).filter((p) => p.value > 0)
|
|
113
|
+
const sum = items.reduce((a, p) => a + p.value, 0) || 1
|
|
114
|
+
const { active, setActive, onKey } = useActive(items.length)
|
|
115
|
+
const color = (p: FoldedPart, i: number) => (p.other ? NEUTRAL : seriesColor(i))
|
|
116
|
+
const tall = variant === "tall"
|
|
117
|
+
let acc = 0
|
|
118
|
+
const segs = items.map((p, i) => {
|
|
119
|
+
const start = acc / sum
|
|
120
|
+
acc += p.value
|
|
121
|
+
return { p, i, start, share: p.value / sum }
|
|
122
|
+
})
|
|
123
|
+
const cur = active === null ? null : segs[active]
|
|
124
|
+
const summary = `${label}. ${items.map((p) => `${p.label} ${pct(p.value, sum)} (${format(p.value)})`).join(", ")}.`
|
|
125
|
+
return (
|
|
126
|
+
<div className={cn("grid gap-2", className)}>
|
|
127
|
+
<ul className="flex flex-wrap gap-x-4 gap-y-1" aria-hidden="true">
|
|
128
|
+
{segs.map(({ p, i }) => (
|
|
129
|
+
<li key={p.id} className={cn("inline-flex items-center gap-control text-sm transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-60")} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)}>
|
|
130
|
+
<span className="size-2.5 shrink-0 rounded-xs bg-(--c)" style={{ "--c": color(p, i) } as React.CSSProperties} />
|
|
131
|
+
<span className="text-ink-2">{p.label}</span>
|
|
132
|
+
{!tall && <span className="tabular font-medium text-ink">{pct(p.value, sum)}</span>}
|
|
133
|
+
{!tall && showValue && <span className="tabular text-ink-3">{format(p.value)}</span>}
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
<div ref={wrap} className="relative">
|
|
138
|
+
<div
|
|
139
|
+
role="group"
|
|
140
|
+
tabIndex={0}
|
|
141
|
+
aria-roledescription="chart"
|
|
142
|
+
aria-label={summary}
|
|
143
|
+
onKeyDown={onKey}
|
|
144
|
+
onBlur={() => setActive(null)}
|
|
145
|
+
onMouseLeave={() => setActive(null)}
|
|
146
|
+
className={cn("flex gap-0.5 rounded-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary", tall ? "h-7" : "h-2")}
|
|
147
|
+
>
|
|
148
|
+
{segs.map(({ p, i, share }) => {
|
|
149
|
+
const w = share * width
|
|
150
|
+
const lbl = pct(p.value, sum, 0)
|
|
151
|
+
const fits = tall && w >= textW(lbl) + 12
|
|
152
|
+
return (
|
|
153
|
+
<span
|
|
154
|
+
key={p.id}
|
|
155
|
+
onMouseEnter={() => setActive(i)}
|
|
156
|
+
className={cn(
|
|
157
|
+
"flex min-w-0.5 basis-0 grow-(--g) items-center bg-(--c) transition-opacity duration-(--duration-fast)",
|
|
158
|
+
tall ? "rounded-xs px-2" : "first:rounded-l-full last:rounded-r-full",
|
|
159
|
+
active !== null && active !== i && "opacity-55",
|
|
160
|
+
)}
|
|
161
|
+
style={{ "--g": p.value, "--c": color(p, i) } as React.CSSProperties}
|
|
162
|
+
>
|
|
163
|
+
{fits && <span aria-hidden="true" className={cn("tabular text-xs font-medium", "other" in p && p.other ? "text-surface" : i === 3 ? "text-neutral-950" : "text-white")}>{lbl}</span>}
|
|
164
|
+
</span>
|
|
165
|
+
)
|
|
166
|
+
})}
|
|
167
|
+
</div>
|
|
168
|
+
{cur && width > 0 && (
|
|
169
|
+
<Tip
|
|
170
|
+
x={(cur.start + cur.share / 2) * width}
|
|
171
|
+
y={tall ? 34 : 14}
|
|
172
|
+
width={width}
|
|
173
|
+
title={cur.p.label}
|
|
174
|
+
rows={[
|
|
175
|
+
{ label: "Share", value: pct(cur.p.value, sum), color: color(cur.p, cur.i) },
|
|
176
|
+
{ label: "Value", value: format(cur.p.value) },
|
|
177
|
+
...((cur.p as FoldedPart).other ? [{ label: "Parts folded in", value: String((cur.p as FoldedPart).other) }] : []),
|
|
178
|
+
]}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ---------------------------------------------------------------- ranked list */
|
|
187
|
+
|
|
188
|
+
export type RankedItem = {
|
|
189
|
+
id: string
|
|
190
|
+
label: string
|
|
191
|
+
value: number
|
|
192
|
+
/** Change since the comparison period, in the unit formatChange expects (percent by default). */
|
|
193
|
+
change?: number
|
|
194
|
+
spark?: number[]
|
|
195
|
+
href?: string
|
|
196
|
+
/** An icon in place of the colour dot. */
|
|
197
|
+
icon?: React.ReactNode
|
|
198
|
+
/** Series slot when the list is the legend of a chart; defaults to the row's position. */
|
|
199
|
+
colorIndex?: number
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Ranked rows with value and change. Beside a stacked chart it is the chart's legend, so dots follow the series colours. */
|
|
203
|
+
export function RankedList({
|
|
204
|
+
items,
|
|
205
|
+
format = fmt,
|
|
206
|
+
formatChange = (n) => `${n.toFixed(1)}%`,
|
|
207
|
+
upIs = "good",
|
|
208
|
+
dots = true,
|
|
209
|
+
onSelect,
|
|
210
|
+
activeId,
|
|
211
|
+
onActiveChange,
|
|
212
|
+
"aria-label": label,
|
|
213
|
+
className,
|
|
214
|
+
}: {
|
|
215
|
+
items: RankedItem[]
|
|
216
|
+
format?: (n: number) => string
|
|
217
|
+
formatChange?: (n: number) => string
|
|
218
|
+
upIs?: "good" | "bad" | "neutral"
|
|
219
|
+
/** Show a series dot on each row (when the list is a chart legend). */
|
|
220
|
+
dots?: boolean
|
|
221
|
+
onSelect?: (item: RankedItem) => void
|
|
222
|
+
/** Highlight a row, such as the series under the chart's crosshair. */
|
|
223
|
+
activeId?: string | null
|
|
224
|
+
onActiveChange?: (id: string | null) => void
|
|
225
|
+
"aria-label": string
|
|
226
|
+
className?: string
|
|
227
|
+
}) {
|
|
228
|
+
return (
|
|
229
|
+
<ol aria-label={label} className={cn("grid gap-px", className)}>
|
|
230
|
+
{items.map((it, i) => {
|
|
231
|
+
const body = (
|
|
232
|
+
<>
|
|
233
|
+
<span className="tabular w-4 shrink-0 text-right text-xs text-ink-3">{i + 1}</span>
|
|
234
|
+
{it.icon ? <span aria-hidden="true" className="grid size-4 shrink-0 place-items-center text-ink-2 [&_svg]:size-4">{it.icon}</span>
|
|
235
|
+
: dots ? <span aria-hidden="true" className="size-2 shrink-0 rounded-full bg-(--c)" style={{ "--c": seriesColor(it.colorIndex ?? i) } as React.CSSProperties} /> : null}
|
|
236
|
+
<span className="min-w-0 flex-1 truncate text-ink">{it.label}</span>
|
|
237
|
+
{it.spark && <Sparkline data={it.spark} aria-label={`${it.label} trend`} className="h-5 w-14 shrink-0 max-sm:hidden" />}
|
|
238
|
+
<span className="tabular shrink-0 font-medium text-ink">{format(it.value)}</span>
|
|
239
|
+
{it.change !== undefined && <ChangeValue value={it.change} format={formatChange} upIs={upIs} className="w-16 shrink-0 text-right text-xs" />}
|
|
240
|
+
</>
|
|
241
|
+
)
|
|
242
|
+
const cls = cn(
|
|
243
|
+
"flex h-8 w-full items-center gap-2 rounded-xs px-2 text-left text-sm transition-colors duration-(--duration-fast) hover:bg-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary",
|
|
244
|
+
activeId === it.id && "bg-hover",
|
|
245
|
+
)
|
|
246
|
+
const hover = { onMouseEnter: () => onActiveChange?.(it.id), onMouseLeave: () => onActiveChange?.(null), onFocus: () => onActiveChange?.(it.id), onBlur: () => onActiveChange?.(null) }
|
|
247
|
+
return (
|
|
248
|
+
<li key={it.id}>
|
|
249
|
+
{it.href ? <a href={it.href} className={cls} {...hover}>{body}</a>
|
|
250
|
+
: onSelect ? <button type="button" className={cls} onClick={() => onSelect(it)} {...hover}>{body}</button>
|
|
251
|
+
: <div className={cls} {...hover}>{body}</div>}
|
|
252
|
+
</li>
|
|
253
|
+
)
|
|
254
|
+
})}
|
|
255
|
+
</ol>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ---------------------------------------------------------------- change list */
|
|
260
|
+
|
|
261
|
+
export type ChangeItem = { id: string; name: string; symbol?: string; value: number; unit?: string; change: number; spark?: number[]; href?: string }
|
|
262
|
+
|
|
263
|
+
/** Ticker rows: name and code, value with unit, change by arrow, sign and colour, optional sparkline. */
|
|
264
|
+
export function ChangeList({
|
|
265
|
+
title,
|
|
266
|
+
description,
|
|
267
|
+
items,
|
|
268
|
+
format = (n) => n.toLocaleString("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
|
269
|
+
formatChange = (n) => `${n.toFixed(2)}%`,
|
|
270
|
+
upIs = "good",
|
|
271
|
+
seeAll,
|
|
272
|
+
level = 3,
|
|
273
|
+
className,
|
|
274
|
+
}: {
|
|
275
|
+
title: string
|
|
276
|
+
description?: string
|
|
277
|
+
/** Heading level of the title. Use 2 when the list sits straight under the page's h1, as on a dashboard. */
|
|
278
|
+
level?: 2 | 3 | 4
|
|
279
|
+
items: ChangeItem[]
|
|
280
|
+
format?: (n: number) => string
|
|
281
|
+
formatChange?: (n: number) => string
|
|
282
|
+
upIs?: "good" | "bad" | "neutral"
|
|
283
|
+
seeAll?: { href: string; label?: string }
|
|
284
|
+
className?: string
|
|
285
|
+
}) {
|
|
286
|
+
const id = React.useId()
|
|
287
|
+
return (
|
|
288
|
+
<section aria-labelledby={`${id}-t`} data-slot="change-list" className={cn("grid gap-2 rounded-sm border border-line bg-surface p-4 shadow-card", className)}>
|
|
289
|
+
<header className="grid gap-0.5">
|
|
290
|
+
{React.createElement(`h${level}`, { id: `${id}-t`, className: "text-md font-semibold text-ink" }, title)}
|
|
291
|
+
{description && <p className="text-sm text-ink-2">{description}</p>}
|
|
292
|
+
</header>
|
|
293
|
+
<ul className="-mx-2 grid divide-y divide-line">
|
|
294
|
+
{items.map((it) => {
|
|
295
|
+
const body = (
|
|
296
|
+
<>
|
|
297
|
+
<span className="grid min-w-0 flex-1">
|
|
298
|
+
<span className="truncate text-sm font-medium text-ink">{it.name}</span>
|
|
299
|
+
{it.symbol && <span className="truncate text-xs text-ink-3">{it.symbol}</span>}
|
|
300
|
+
</span>
|
|
301
|
+
{it.spark && <Sparkline data={it.spark} aria-label={`${it.name} today`} className="h-6 w-16 shrink-0 max-sm:hidden" />}
|
|
302
|
+
<span className="grid shrink-0 justify-items-end">
|
|
303
|
+
<span className="tabular text-sm font-medium text-ink">{format(it.value)}{it.unit && <span className="font-normal text-ink-3">{it.unit.startsWith("%") ? "" : " "}{it.unit}</span>}</span>
|
|
304
|
+
<ChangeValue value={it.change} format={formatChange} upIs={upIs} className="text-xs" />
|
|
305
|
+
</span>
|
|
306
|
+
</>
|
|
307
|
+
)
|
|
308
|
+
const cls = "flex min-h-11 items-center gap-3 rounded-xs px-2 py-2"
|
|
309
|
+
return (
|
|
310
|
+
<li key={it.id}>
|
|
311
|
+
{it.href ? <a href={it.href} className={cn(cls, "transition-colors duration-(--duration-fast) hover:bg-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary")}>{body}</a> : <div className={cls}>{body}</div>}
|
|
312
|
+
</li>
|
|
313
|
+
)
|
|
314
|
+
})}
|
|
315
|
+
</ul>
|
|
316
|
+
{seeAll && <a href={seeAll.href} className="justify-self-start text-sm font-medium text-primary-ink hover:underline">{seeAll.label ?? "See all"}</a>}
|
|
317
|
+
</section>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ---------------------------------------------------------------- price chart */
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One price over a day: line with a wash to the axis floor, last value tagged on the right axis,
|
|
325
|
+
* an optional dashed reference (previous close) and a live dot. When the range is narrow the axis
|
|
326
|
+
* does not start at zero, and the chart says so under the axis.
|
|
327
|
+
*/
|
|
328
|
+
export function PriceChart({
|
|
329
|
+
x,
|
|
330
|
+
data,
|
|
331
|
+
height = 240,
|
|
332
|
+
format = fmt,
|
|
333
|
+
reference,
|
|
334
|
+
live = false,
|
|
335
|
+
baseline = "auto",
|
|
336
|
+
valueLabel = "Price",
|
|
337
|
+
"aria-label": label,
|
|
338
|
+
}: {
|
|
339
|
+
/** Time labels for the whole session, e.g. 08:00 to 16:30. Later slots can be null in data. */
|
|
340
|
+
x: string[]
|
|
341
|
+
data: (number | null)[]
|
|
342
|
+
height?: number
|
|
343
|
+
format?: (n: number) => string
|
|
344
|
+
reference?: { value: number; label: string }
|
|
345
|
+
/** Pulse the last point while the market is open. Still under reduced motion. */
|
|
346
|
+
live?: boolean
|
|
347
|
+
/** auto: start at zero unless the range is narrow; zero: always; range: never. */
|
|
348
|
+
baseline?: "auto" | "zero" | "range"
|
|
349
|
+
valueLabel?: string
|
|
350
|
+
"aria-label": string
|
|
351
|
+
}) {
|
|
352
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
353
|
+
const n = x.length
|
|
354
|
+
const vals = data.filter((v): v is number => v !== null)
|
|
355
|
+
const last = data.reduce<number>((a, v, i) => (v === null ? a : i), -1)
|
|
356
|
+
const { active, setActive, onKey } = useActive(last + 1)
|
|
357
|
+
const firstV = vals[0] ?? 0, lastV = last >= 0 ? (data[last] as number) : 0
|
|
358
|
+
const lo0 = Math.min(...vals, reference?.value ?? Infinity), hi0 = Math.max(...vals, reference?.value ?? -Infinity)
|
|
359
|
+
const fromRange = baseline === "range" || (baseline === "auto" && lo0 > 0 && (hi0 - lo0) / hi0 < 0.5)
|
|
360
|
+
const ticks = fromRange ? rangeTicks(lo0, hi0) : niceTicks(hi0)
|
|
361
|
+
const lo = ticks[0], hi = ticks[ticks.length - 1]
|
|
362
|
+
const tagW = Math.max(...ticks.map((t) => textW(format(t))), textW(format(lastV))) + 12
|
|
363
|
+
const pad = { t: 10, r: tagW + 6, b: 22, l: 2 }
|
|
364
|
+
const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
|
|
365
|
+
const sx = (i: number) => pad.l + (n <= 1 ? w / 2 : (i / (n - 1)) * w)
|
|
366
|
+
const sy = (v: number) => pad.t + h - ((v - lo) / (hi - lo || 1)) * h
|
|
367
|
+
// contiguous runs, so missing data stays a gap
|
|
368
|
+
const runs: [number, number][][] = []
|
|
369
|
+
data.forEach((v, i) => {
|
|
370
|
+
if (v === null) return
|
|
371
|
+
if (i === 0 || data[i - 1] === null) runs.push([])
|
|
372
|
+
runs[runs.length - 1].push([sx(i), sy(v)])
|
|
373
|
+
})
|
|
374
|
+
const line = (pts: [number, number][]) => pts.map(([px, py], i) => `${i ? "L" : "M"}${px.toFixed(1)},${py.toFixed(1)}`).join("")
|
|
375
|
+
const every = Math.max(1, Math.ceil(n / Math.max(2, Math.floor(w / 64))))
|
|
376
|
+
const tagY = sy(lastV)
|
|
377
|
+
const cur = active !== null && data[active] !== null ? (data[active] as number) : null
|
|
378
|
+
const base = reference?.value ?? firstV
|
|
379
|
+
const summary = `${label}. ${x[0]} to ${x[Math.max(0, last)]}. Opened at ${format(firstV)}, last ${format(lastV)}, high ${format(Math.max(...vals))}, low ${format(Math.min(...vals))}.${fromRange ? ` The axis starts at ${format(lo)}, not zero.` : ""}`
|
|
380
|
+
const onMove = (e: React.MouseEvent<SVGRectElement>) => {
|
|
381
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
382
|
+
const i = Math.round(((e.clientX - r.left) / (r.width || 1)) * (n - 1))
|
|
383
|
+
setActive(Math.max(0, Math.min(last, i)))
|
|
384
|
+
}
|
|
385
|
+
return (
|
|
386
|
+
<div className="grid gap-1">
|
|
387
|
+
<div ref={wrap} className="relative w-full">
|
|
388
|
+
{width > 0 && (
|
|
389
|
+
<svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary}
|
|
390
|
+
onKeyDown={onKey}
|
|
391
|
+
onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
|
|
392
|
+
{ticks.map((t) => (
|
|
393
|
+
<g key={t}>
|
|
394
|
+
<line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className={t === lo ? "stroke-line-strong" : "stroke-line"} />
|
|
395
|
+
{Math.abs(sy(t) - tagY) > 12 && <text x={pad.l + w + 8} y={sy(t)} dy="0.32em" className="fill-ink-3 text-xs tabular">{format(t)}</text>}
|
|
396
|
+
</g>
|
|
397
|
+
))}
|
|
398
|
+
{x.map((lbl, i) => i % every === 0 && (
|
|
399
|
+
<text key={lbl + i} x={sx(i)} y={height - 6} textAnchor={i === 0 ? "start" : "middle"} className="fill-ink-3 text-xs tabular">{lbl}</text>
|
|
400
|
+
))}
|
|
401
|
+
{reference && (
|
|
402
|
+
<g>
|
|
403
|
+
<line x1={pad.l} x2={pad.l + w} y1={sy(reference.value)} y2={sy(reference.value)} className="stroke-ink-3" strokeDasharray="4 3" />
|
|
404
|
+
<text x={pad.l + 4} y={sy(reference.value) - 5} strokeWidth={3} className={cn("fill-ink-2 text-xs", halo)}>{reference.label} {format(reference.value)}</text>
|
|
405
|
+
</g>
|
|
406
|
+
)}
|
|
407
|
+
{runs.map((pts, k) => (
|
|
408
|
+
<path key={`a${k}`} d={`${line(pts)}L${pts[pts.length - 1][0]},${pad.t + h}L${pts[0][0]},${pad.t + h}Z`} fill="var(--color-c1)" fillOpacity={0.1} />
|
|
409
|
+
))}
|
|
410
|
+
{runs.map((pts, k) => <path key={`l${k}`} d={line(pts)} fill="none" stroke="var(--color-c1)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />)}
|
|
411
|
+
{last >= 0 && (
|
|
412
|
+
<g>
|
|
413
|
+
{live && <circle cx={sx(last)} cy={tagY} r={4} fill="var(--color-c1)" className="origin-center opacity-40 [transform-box:fill-box] motion-safe:animate-ping" />}
|
|
414
|
+
<circle cx={sx(last)} cy={tagY} r={4} fill="var(--color-c1)" className="stroke-surface" strokeWidth={2} />
|
|
415
|
+
<rect x={pad.l + w + 2} y={tagY - 9} width={tagW} height={18} rx={3} fill="var(--color-c1)" />
|
|
416
|
+
<text x={pad.l + w + 8} y={tagY} dy="0.32em" className="fill-white text-xs font-medium tabular">{format(lastV)}</text>
|
|
417
|
+
</g>
|
|
418
|
+
)}
|
|
419
|
+
{cur !== null && active !== null && (
|
|
420
|
+
<g>
|
|
421
|
+
<line x1={sx(active)} x2={sx(active)} y1={pad.t} y2={pad.t + h} className="stroke-ink-3" />
|
|
422
|
+
<circle cx={sx(active)} cy={sy(cur)} r={4} fill="var(--color-c1)" className="stroke-surface" strokeWidth={2} />
|
|
423
|
+
</g>
|
|
424
|
+
)}
|
|
425
|
+
<rect x={pad.l} y={pad.t} width={w} height={h} fill="transparent" onMouseMove={onMove} />
|
|
426
|
+
</svg>
|
|
427
|
+
)}
|
|
428
|
+
{cur !== null && active !== null && width > 0 && (
|
|
429
|
+
<Tip x={sx(active)} y={4} width={width} title={x[active]} rows={[
|
|
430
|
+
{ label: valueLabel, value: format(cur), color: "var(--color-c1)" },
|
|
431
|
+
{ label: `vs ${reference ? reference.label.toLowerCase() : "open"}`, value: `${signed(cur - base, format)} (${signed(((cur - base) / (base || 1)) * 100, (v) => `${v.toFixed(2)}%`)})` },
|
|
432
|
+
]} />
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
{fromRange && <p className="text-right text-xs text-ink-3">Axis starts at {format(lo)}, not zero</p>}
|
|
436
|
+
</div>
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/* ---------------------------------------------------------------- waterfall */
|
|
441
|
+
|
|
442
|
+
export type WaterfallStep = { id: string; label: string; /** Change, or for a total its level (omit to use the running total). */ value?: number; total?: boolean }
|
|
443
|
+
|
|
444
|
+
function waterfallBars(steps: WaterfallStep[]) {
|
|
445
|
+
let run = 0
|
|
446
|
+
return steps.map((s) => {
|
|
447
|
+
if (s.total) { const lvl = s.value ?? run; run = lvl; return { s, from: 0, to: lvl, delta: lvl, kind: "total" as const } }
|
|
448
|
+
const v = s.value ?? 0, from = run
|
|
449
|
+
run += v
|
|
450
|
+
return { s, from, to: run, delta: v, kind: v >= 0 ? ("increase" as const) : ("decrease" as const) }
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export const waterfallTable = (steps: WaterfallStep[], format: (n: number) => string = fmt) => ({
|
|
455
|
+
columns: ["Step", "Change", "Running total"],
|
|
456
|
+
rows: waterfallBars(steps).map((b) => [b.s.label, b.kind === "total" ? "Total" : signed(b.delta, format), format(b.to)]),
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
/** Opening, increases, decreases, closing on one axis. Increases and decreases in two categorical colours with +/− labels; totals neutral. */
|
|
460
|
+
export function Waterfall({
|
|
461
|
+
steps,
|
|
462
|
+
height = 240,
|
|
463
|
+
format = fmt,
|
|
464
|
+
labels = { increase: "Increase", decrease: "Decrease", total: "Total" },
|
|
465
|
+
"aria-label": label,
|
|
466
|
+
}: {
|
|
467
|
+
steps: WaterfallStep[]
|
|
468
|
+
height?: number
|
|
469
|
+
format?: (n: number) => string
|
|
470
|
+
labels?: { increase: string; decrease: string; total: string }
|
|
471
|
+
"aria-label": string
|
|
472
|
+
}) {
|
|
473
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
474
|
+
const bars = waterfallBars(steps)
|
|
475
|
+
const n = bars.length
|
|
476
|
+
const { active, setActive, onKey } = useActive(n)
|
|
477
|
+
const lo0 = Math.min(0, ...bars.map((b) => Math.min(b.from, b.to))), hi0 = Math.max(...bars.map((b) => Math.max(b.from, b.to)))
|
|
478
|
+
const ticks = lo0 < 0 ? rangeTicks(lo0, hi0) : niceTicks(hi0)
|
|
479
|
+
const lo = ticks[0], hi = ticks[ticks.length - 1]
|
|
480
|
+
const padL = Math.max(...ticks.map((t) => textW(format(t)))) + 10
|
|
481
|
+
const band = n ? Math.max(0, width - padL - 4) / n : 0
|
|
482
|
+
const pad = { t: 18, r: 4, b: band > 0 && band < 64 && Math.max(...bars.map((b) => b.s.label.length)) * 6.4 + 4 > band ? 36 : 22, l: padL }
|
|
483
|
+
const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
|
|
484
|
+
const bw = Math.min(band * 0.6, 36)
|
|
485
|
+
const sy = (v: number) => pad.t + h - ((v - lo) / (hi - lo || 1)) * h
|
|
486
|
+
const bx = (i: number) => pad.l + band * i + (band - bw) / 2
|
|
487
|
+
const colour = { increase: "var(--color-c1)", decrease: "var(--color-c2)", total: NEUTRAL }
|
|
488
|
+
const stagger = band > 0 && Math.max(...bars.map((b) => b.s.label.length)) * 6.4 + 4 > band && band < 64
|
|
489
|
+
const maxChars = Math.max(2, Math.floor(((stagger ? band * 2 : band) - 6) / 6.4))
|
|
490
|
+
const short = (s: string) => (s.length > maxChars ? `${s.slice(0, maxChars - 1)}…` : s)
|
|
491
|
+
const summary = `${label}. ${bars.map((b) => `${b.s.label} ${b.kind === "total" ? format(b.to) : signed(b.delta, format)}`).join(", ")}.`
|
|
492
|
+
const cur = active === null ? null : bars[active]
|
|
493
|
+
return (
|
|
494
|
+
<div className="grid gap-3">
|
|
495
|
+
<div ref={wrap} className="relative w-full">
|
|
496
|
+
{width > 0 && (
|
|
497
|
+
<svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
|
|
498
|
+
{ticks.map((t) => (
|
|
499
|
+
<g key={t}>
|
|
500
|
+
<line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} />
|
|
501
|
+
<text x={pad.l - 6} y={sy(t)} dy="0.32em" textAnchor="end" className="fill-ink-3 text-xs tabular">{format(t)}</text>
|
|
502
|
+
</g>
|
|
503
|
+
))}
|
|
504
|
+
{bars.map((b, i) => i < n - 1 && (
|
|
505
|
+
<line key={`c${i}`} x1={bx(i) + bw} x2={bx(i + 1)} y1={sy(b.to)} y2={sy(b.to)} className="stroke-ink-3" strokeOpacity={0.6} />
|
|
506
|
+
))}
|
|
507
|
+
{bars.map((b, i) => {
|
|
508
|
+
const top = sy(Math.max(b.from, b.to)), bot = sy(Math.min(b.from, b.to))
|
|
509
|
+
const txt = b.kind === "total" ? format(b.to) : signed(b.delta, format)
|
|
510
|
+
const fits = textW(txt) <= band - 2
|
|
511
|
+
const below = b.kind === "decrease" && bot + 14 < pad.t + h
|
|
512
|
+
return (
|
|
513
|
+
<g key={b.s.id} onMouseEnter={() => setActive(i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-55")}>
|
|
514
|
+
<rect x={pad.l + band * i} y={pad.t} width={band} height={h} fill="transparent" />
|
|
515
|
+
<path d={barPath(bx(i), top, bot, bw, b.kind === "decrease" ? "bottom" : "top")} fill={colour[b.kind]} />
|
|
516
|
+
{fits && <text x={bx(i) + bw / 2} y={below ? bot + 12 : top - 5} textAnchor="middle" className="fill-ink-2 text-xs font-medium tabular">{txt}</text>}
|
|
517
|
+
<text x={pad.l + band * i + band / 2} y={height - 6 - (stagger && i % 2 === 0 ? 14 : 0)} textAnchor="middle" className="fill-ink-3 text-xs">{short(b.s.label)}</text>
|
|
518
|
+
</g>
|
|
519
|
+
)
|
|
520
|
+
})}
|
|
521
|
+
</svg>
|
|
522
|
+
)}
|
|
523
|
+
{cur && active !== null && width > 0 && (
|
|
524
|
+
<Tip x={bx(active) + bw} y={Math.max(0, sy(Math.max(cur.from, cur.to)) - 20)} width={width} title={cur.s.label} rows={
|
|
525
|
+
cur.kind === "total"
|
|
526
|
+
? [{ label: labels.total, value: format(cur.to), color: NEUTRAL }]
|
|
527
|
+
: [{ label: labels[cur.kind], value: signed(cur.delta, format), color: colour[cur.kind] }, { label: "Running total", value: format(cur.to) }]
|
|
528
|
+
} />
|
|
529
|
+
)}
|
|
530
|
+
</div>
|
|
531
|
+
<KeyLegend items={[{ label: labels.increase, color: colour.increase }, { label: labels.decrease, color: colour.decrease }, { label: labels.total, color: NEUTRAL }]} />
|
|
532
|
+
</div>
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* ---------------------------------------------------------------- bullet chart */
|
|
537
|
+
|
|
538
|
+
export type BulletRow = {
|
|
539
|
+
id: string
|
|
540
|
+
label: string
|
|
541
|
+
value: number
|
|
542
|
+
target: number
|
|
543
|
+
/** Upper edges of the qualitative bands, ascending, e.g. [300000, 400000, 500000]. */
|
|
544
|
+
bands: number[]
|
|
545
|
+
}
|
|
546
|
+
const BAND_FILL = ["bg-input", "bg-line-strong", "bg-hover", "bg-surface-2"]
|
|
547
|
+
|
|
548
|
+
function BulletRowView({ r, bandLabels, format }: { r: BulletRow; bandLabels: string[]; format: (n: number) => string }) {
|
|
549
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
550
|
+
const [on, setOn] = React.useState(false)
|
|
551
|
+
const domain = Math.max(r.value, r.target, ...r.bands)
|
|
552
|
+
const ticks = niceTicks(domain, Math.max(2, Math.min(5, Math.floor(width / 72)))).filter((t) => t <= domain)
|
|
553
|
+
const top = domain || 1
|
|
554
|
+
const p = (v: number) => `${Math.min(100, Math.max(0, (v / top) * 100))}%`
|
|
555
|
+
const band = Math.max(0, r.bands.findIndex((b) => r.value <= b))
|
|
556
|
+
const bandName = bandLabels[band] ?? bandLabels[bandLabels.length - 1]
|
|
557
|
+
const of = pct(r.value, r.target, 0)
|
|
558
|
+
const desc = `${r.label}: ${format(r.value)} against a target of ${format(r.target)}, ${of} of target, in the ${bandName.toLowerCase()} band.`
|
|
559
|
+
return (
|
|
560
|
+
<li tabIndex={0} aria-label={desc} onMouseEnter={() => setOn(true)} onMouseLeave={() => setOn(false)} onFocus={() => setOn(true)} onBlur={() => setOn(false)}
|
|
561
|
+
className="relative grid gap-1 rounded-xs focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary">
|
|
562
|
+
<div className="flex items-baseline justify-between gap-3 text-sm" aria-hidden="true">
|
|
563
|
+
<span className="min-w-0 truncate text-ink-2">{r.label}</span>
|
|
564
|
+
<span className="tabular shrink-0"><span className="font-medium text-ink">{format(r.value)}</span><span className="text-ink-3"> of {format(r.target)}</span></span>
|
|
565
|
+
</div>
|
|
566
|
+
<div ref={wrap} className="relative h-6" aria-hidden="true">
|
|
567
|
+
{r.bands.map((b, i) => {
|
|
568
|
+
const prev = i ? r.bands[i - 1] : 0
|
|
569
|
+
return <span key={i} className={cn("absolute inset-y-0 left-(--l) w-(--w) first:rounded-l-xs", BAND_FILL[i], i === r.bands.length - 1 && "rounded-r-xs")} style={{ "--l": p(prev), "--w": `calc(${p(b)} - ${p(prev)})` } as React.CSSProperties} />
|
|
570
|
+
})}
|
|
571
|
+
<span className="absolute top-2 left-0 h-2 w-(--w) rounded-r-xs bg-c1" style={{ "--w": p(r.value) } as React.CSSProperties} />
|
|
572
|
+
<span className="absolute inset-y-0.5 left-(--l) w-0.5 -translate-x-1/2 rounded-full bg-ink" style={{ "--l": p(r.target) } as React.CSSProperties} />
|
|
573
|
+
</div>
|
|
574
|
+
<div className="relative h-4 text-xs text-ink-3" aria-hidden="true">
|
|
575
|
+
{ticks.map((t, i) => (
|
|
576
|
+
<span key={t} className={cn("tabular absolute top-0 left-(--l) whitespace-nowrap", i === 0 ? "" : t / top > 0.9 ? "-translate-x-full" : "-translate-x-1/2")} style={{ "--l": p(t) } as React.CSSProperties}>{format(t)}</span>
|
|
577
|
+
))}
|
|
578
|
+
</div>
|
|
579
|
+
{on && width > 0 && (
|
|
580
|
+
<Tip x={(Math.min(r.target, top) / top) * width} y={52} width={width} title={r.label} rows={[
|
|
581
|
+
{ label: "Actual", value: format(r.value), color: "var(--color-c1)" },
|
|
582
|
+
{ label: "Target", value: format(r.target), color: "var(--color-ink)" },
|
|
583
|
+
{ label: "Of target", value: of },
|
|
584
|
+
{ label: "Band", value: bandName },
|
|
585
|
+
]} />
|
|
586
|
+
)}
|
|
587
|
+
</li>
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Actual against target over qualitative bands in neutral steps. The alternative to a gauge. */
|
|
592
|
+
export function BulletChart({ rows, bandLabels = ["Poor", "Satisfactory", "Good"], format = fmt, "aria-label": label }: { rows: BulletRow[]; bandLabels?: string[]; format?: (n: number) => string; "aria-label": string }) {
|
|
593
|
+
return (
|
|
594
|
+
<div className="grid gap-4">
|
|
595
|
+
<ul aria-label={label} className="grid gap-4">
|
|
596
|
+
{rows.map((r) => <BulletRowView key={r.id} r={r} bandLabels={bandLabels} format={format} />)}
|
|
597
|
+
</ul>
|
|
598
|
+
<ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label="Legend">
|
|
599
|
+
<li className="inline-flex items-center gap-control text-sm text-ink-2"><span aria-hidden="true" className="h-2 w-3 rounded-xs bg-c1" />Actual</li>
|
|
600
|
+
<li className="inline-flex items-center gap-control text-sm text-ink-2"><span aria-hidden="true" className="h-3 w-0.5 rounded-full bg-ink" />Target</li>
|
|
601
|
+
{bandLabels.slice(0, Math.max(...rows.map((r) => r.bands.length))).map((b, i) => (
|
|
602
|
+
<li key={b} className="inline-flex items-center gap-control text-sm text-ink-2"><span aria-hidden="true" className={cn("size-2.5 rounded-xs", BAND_FILL[i])} />{b}</li>
|
|
603
|
+
))}
|
|
604
|
+
</ul>
|
|
605
|
+
</div>
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/* ---------------------------------------------------------------- treemap */
|
|
610
|
+
|
|
611
|
+
export type TreemapItem = SharePart & { /** Up to eight parent groups, each its own colour with a legend. */ group?: string }
|
|
612
|
+
type Rect = { x: number; y: number; w: number; h: number }
|
|
613
|
+
|
|
614
|
+
function squarify(areas: { i: number; a: number }[], r: Rect) {
|
|
615
|
+
const out: (Rect & { i: number })[] = []
|
|
616
|
+
let rect = { ...r }
|
|
617
|
+
let row: { i: number; a: number }[] = []
|
|
618
|
+
const worst = (rw: { a: number }[], len: number) => {
|
|
619
|
+
const s = rw.reduce((x, it) => x + it.a, 0)
|
|
620
|
+
const mx = Math.max(...rw.map((it) => it.a)), mn = Math.min(...rw.map((it) => it.a))
|
|
621
|
+
return Math.max((len * len * mx) / (s * s), (s * s) / (len * len * mn))
|
|
622
|
+
}
|
|
623
|
+
const place = () => {
|
|
624
|
+
const s = row.reduce((x, it) => x + it.a, 0)
|
|
625
|
+
if (rect.w >= rect.h) {
|
|
626
|
+
const cw = s / rect.h
|
|
627
|
+
let yy = rect.y
|
|
628
|
+
for (const it of row) { const hh = it.a / cw; out.push({ i: it.i, x: rect.x, y: yy, w: cw, h: hh }); yy += hh }
|
|
629
|
+
rect = { x: rect.x + cw, y: rect.y, w: rect.w - cw, h: rect.h }
|
|
630
|
+
} else {
|
|
631
|
+
const rh = s / rect.w
|
|
632
|
+
let xx = rect.x
|
|
633
|
+
for (const it of row) { const ww = it.a / rh; out.push({ i: it.i, x: xx, y: rect.y, w: ww, h: rh }); xx += ww }
|
|
634
|
+
rect = { x: rect.x, y: rect.y + rh, w: rect.w, h: rect.h - rh }
|
|
635
|
+
}
|
|
636
|
+
row = []
|
|
637
|
+
}
|
|
638
|
+
const rest = [...areas]
|
|
639
|
+
while (rest.length) {
|
|
640
|
+
const len = Math.min(rect.w, rect.h)
|
|
641
|
+
if (!row.length || worst([...row, rest[0]], len) <= worst(row, len)) row.push(rest.shift()!)
|
|
642
|
+
else place()
|
|
643
|
+
}
|
|
644
|
+
if (row.length) place()
|
|
645
|
+
return out
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Break a label into lines that fit; an empty result means a single word does not fit. */
|
|
649
|
+
function wrapLabel(label: string, width: number) {
|
|
650
|
+
const out: string[] = []
|
|
651
|
+
for (const word of label.split(" ")) {
|
|
652
|
+
if (textW(word) > width) return []
|
|
653
|
+
const last = out[out.length - 1]
|
|
654
|
+
if (last !== undefined && textW(`${last} ${word}`) <= width) out[out.length - 1] = `${last} ${word}`
|
|
655
|
+
else out.push(word)
|
|
656
|
+
}
|
|
657
|
+
return out
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Squarified treemap: area by value, labels only where they fit, value in the tooltip, at most `max` tiles then Other. */
|
|
661
|
+
export function Treemap({ items, max = 12, height = 260, format = fmt, "aria-label": label }: { items: TreemapItem[]; max?: number; height?: number; format?: (n: number) => string; "aria-label": string }) {
|
|
662
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
663
|
+
const folded = foldParts(items, max).filter((p) => p.value > 0) as (TreemapItem & { other?: number })[]
|
|
664
|
+
const tiles = [...folded].sort((a, b) => (a.other ? 1 : b.other ? -1 : b.value - a.value))
|
|
665
|
+
const groups = [...new Set(items.map((it) => it.group).filter((g): g is string => !!g))].slice(0, 8)
|
|
666
|
+
const sum = tiles.reduce((a, t) => a + t.value, 0) || 1
|
|
667
|
+
const { active, setActive, onKey } = useActive(tiles.length)
|
|
668
|
+
const rects = width > 0 ? squarify(tiles.map((t, i) => ({ i, a: (t.value / sum) * width * height })), { x: 0, y: 0, w: width, h: height }) : []
|
|
669
|
+
const slot = (t: (typeof tiles)[number]) => (t.other ? -1 : t.group ? groups.indexOf(t.group) : 0)
|
|
670
|
+
const fill = (s: number) => (s < 0 ? NEUTRAL : seriesColor(s))
|
|
671
|
+
const ink = (s: number) => (s < 0 ? "fill-surface" : s === 3 ? "fill-neutral-950" : "fill-white")
|
|
672
|
+
const curR = active === null ? null : rects.find((r) => r.i === active)
|
|
673
|
+
const cur = active === null ? null : tiles[active]
|
|
674
|
+
const summary = `${label}. ${tiles.map((t) => `${t.label} ${format(t.value)} (${pct(t.value, sum)})`).join(", ")}.`
|
|
675
|
+
return (
|
|
676
|
+
<div className="grid gap-3">
|
|
677
|
+
<div ref={wrap} className="relative w-full">
|
|
678
|
+
{width > 0 && (
|
|
679
|
+
<svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
|
|
680
|
+
{rects.map((r) => {
|
|
681
|
+
const t = tiles[r.i], s = slot(t)
|
|
682
|
+
const x = r.x + 1, y = r.y + 1, w = Math.max(0, r.w - 2), h = Math.max(0, r.h - 2)
|
|
683
|
+
const share = pct(t.value, sum)
|
|
684
|
+
const lines = wrapLabel(t.label, w - 12)
|
|
685
|
+
const nameFits = lines.length > 0 && lines.length <= 2 && h >= 8 + lines.length * 14
|
|
686
|
+
const shareFits = nameFits && h >= 24 + lines.length * 14 && w >= textW(share) + 12
|
|
687
|
+
return (
|
|
688
|
+
<g key={t.id} onMouseEnter={() => setActive(r.i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== r.i && "opacity-60")}>
|
|
689
|
+
<rect x={x} y={y} width={w} height={h} rx={3} fill={fill(s)} />
|
|
690
|
+
{nameFits && <text x={x + 6} y={y + 16} className={cn("text-xs font-medium", ink(s))}>{lines.map((ln, k) => <tspan key={k} x={x + 6} dy={k ? 14 : 0}>{ln}</tspan>)}</text>}
|
|
691
|
+
{shareFits && <text x={x + 6} y={y + 17 + lines.length * 14} className={cn("text-xs tabular opacity-90", ink(s))}>{share}</text>}
|
|
692
|
+
</g>
|
|
693
|
+
)
|
|
694
|
+
})}
|
|
695
|
+
</svg>
|
|
696
|
+
)}
|
|
697
|
+
{cur && curR && (
|
|
698
|
+
<Tip x={curR.x + curR.w / 2} y={Math.min(curR.y + 8, height - 90)} width={width} title={cur.label} rows={[
|
|
699
|
+
{ label: "Value", value: format(cur.value), color: fill(slot(cur)) },
|
|
700
|
+
{ label: "Share", value: pct(cur.value, sum) },
|
|
701
|
+
...(cur.group ? [{ label: "Group", value: cur.group }] : []),
|
|
702
|
+
...(cur.other ? [{ label: "Items folded in", value: String(cur.other) }] : []),
|
|
703
|
+
]} />
|
|
704
|
+
)}
|
|
705
|
+
</div>
|
|
706
|
+
<KeyLegend items={groups.map((g, i) => ({ label: g, color: seriesColor(i) }))} />
|
|
707
|
+
</div>
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/* ---------------------------------------------------------------- sankey */
|
|
712
|
+
|
|
713
|
+
export type SankeyNode = { id: string; label: string }
|
|
714
|
+
export type SankeyLink = { source: string; target: string; value: number }
|
|
715
|
+
|
|
716
|
+
export const sankeyTable = (nodes: SankeyNode[], links: SankeyLink[], format: (n: number) => string = fmt) => {
|
|
717
|
+
const name = (id: string) => nodes.find((n) => n.id === id)?.label ?? id
|
|
718
|
+
return { columns: ["From", "To", "Value"], rows: links.map((l) => [name(l.source), name(l.target), format(l.value)]) }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Left-to-right flows in two or three columns. Link width by value; hover or arrow keys highlight a path. */
|
|
722
|
+
export function SankeyChart({ nodes, links, height = 280, format = fmt, "aria-label": label }: { nodes: SankeyNode[]; links: SankeyLink[]; height?: number; format?: (n: number) => string; "aria-label": string }) {
|
|
723
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
724
|
+
const [hoverNode, setHoverNode] = React.useState<string | null>(null)
|
|
725
|
+
// columns: longest path from a source
|
|
726
|
+
const col: Record<string, number> = {}
|
|
727
|
+
nodes.forEach((n) => (col[n.id] = 0))
|
|
728
|
+
for (let k = 0; k < nodes.length; k++) for (const l of links) col[l.target] = Math.max(col[l.target], col[l.source] + 1)
|
|
729
|
+
const C = Math.max(...Object.values(col)) + 1
|
|
730
|
+
const inSum = (id: string) => links.filter((l) => l.target === id).reduce((a, l) => a + l.value, 0)
|
|
731
|
+
const outSum = (id: string) => links.filter((l) => l.source === id).reduce((a, l) => a + l.value, 0)
|
|
732
|
+
const val = (id: string) => Math.max(inSum(id), outSum(id))
|
|
733
|
+
const cols = Array.from({ length: C }, (_, c) => nodes.filter((n) => col[n.id] === c))
|
|
734
|
+
const gap = 16, nodeW = 8, pad = { t: 4, b: 4 }
|
|
735
|
+
const H = height - pad.t - pad.b
|
|
736
|
+
const k = Math.min(...cols.map((cn_) => (H - gap * (cn_.length - 1)) / (cn_.reduce((a, n) => a + val(n.id), 0) || 1)))
|
|
737
|
+
const colX = (c: number) => (C <= 1 ? 0 : (c / (C - 1)) * (width - nodeW))
|
|
738
|
+
const pos: Record<string, { x: number; y: number; h: number }> = {}
|
|
739
|
+
cols.forEach((cn_, c) => {
|
|
740
|
+
const used = cn_.reduce((a, n) => a + val(n.id) * k, 0) + gap * (cn_.length - 1)
|
|
741
|
+
let y = pad.t + (H - used) / 2
|
|
742
|
+
cn_.forEach((n) => { const h = Math.max(2, val(n.id) * k); pos[n.id] = { x: colX(c), y, h }; y += h + gap })
|
|
743
|
+
})
|
|
744
|
+
const colTotal = (c: number) => cols[c].reduce((a, n) => a + val(n.id), 0)
|
|
745
|
+
// link offsets
|
|
746
|
+
const order = links.map((l, i) => ({ l, i })).sort((a, b) => col[a.l.source] - col[b.l.source] || pos[a.l.source].y - pos[b.l.source].y || pos[a.l.target].y - pos[b.l.target].y)
|
|
747
|
+
const outOff: Record<string, number> = {}, inOff: Record<string, number> = {}
|
|
748
|
+
const geo: { i: number; d: string; mid: [number, number] }[] = []
|
|
749
|
+
if (width > 0) {
|
|
750
|
+
const bySrc = [...order].sort((a, b) => pos[a.l.target].y - pos[b.l.target].y)
|
|
751
|
+
const sy0: Record<number, number> = {}, ty0: Record<number, number> = {}
|
|
752
|
+
for (const { l, i } of bySrc) { sy0[i] = pos[l.source].y + (outOff[l.source] ?? 0); outOff[l.source] = (outOff[l.source] ?? 0) + l.value * k }
|
|
753
|
+
const byTgt = [...order].sort((a, b) => pos[a.l.source].y - pos[b.l.source].y)
|
|
754
|
+
for (const { l, i } of byTgt) { ty0[i] = pos[l.target].y + (inOff[l.target] ?? 0); inOff[l.target] = (inOff[l.target] ?? 0) + l.value * k }
|
|
755
|
+
for (const { l, i } of order) {
|
|
756
|
+
const t = l.value * k, x0 = pos[l.source].x + nodeW, x1 = pos[l.target].x, xm = (x0 + x1) / 2
|
|
757
|
+
const a = sy0[i], b = ty0[i]
|
|
758
|
+
geo.push({ i, d: `M${x0},${a}C${xm},${a} ${xm},${b} ${x1},${b}L${x1},${b + t}C${xm},${b + t} ${xm},${a + t} ${x0},${a + t}Z`, mid: [xm, (a + b) / 2 + t / 2] })
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const { active, setActive, onKey } = useActive(geo.length)
|
|
762
|
+
const activeLink = active === null ? null : geo[active]?.i ?? null
|
|
763
|
+
// highlighted path: a link, or every link through a node (upstream and downstream)
|
|
764
|
+
const lit = new Set<number>()
|
|
765
|
+
if (activeLink !== null) lit.add(activeLink)
|
|
766
|
+
if (hoverNode) {
|
|
767
|
+
const walk = (id: string, dir: "up" | "down") => links.forEach((l, i) => {
|
|
768
|
+
if (dir === "down" && l.source === id && !lit.has(i)) { lit.add(i); walk(l.target, "down") }
|
|
769
|
+
if (dir === "up" && l.target === id && !lit.has(i)) { lit.add(i); walk(l.source, "up") }
|
|
770
|
+
})
|
|
771
|
+
walk(hoverNode, "down"); walk(hoverNode, "up")
|
|
772
|
+
}
|
|
773
|
+
const any = lit.size > 0
|
|
774
|
+
const litNodes = new Set([...lit].flatMap((i) => [links[i].source, links[i].target]))
|
|
775
|
+
const name = (id: string) => nodes.find((n) => n.id === id)?.label ?? id
|
|
776
|
+
const roomy = C <= 1 || (width - nodeW) / (C - 1) >= 230
|
|
777
|
+
const cur = activeLink === null ? null : links[activeLink]
|
|
778
|
+
const curGeo = active === null ? null : geo[active]
|
|
779
|
+
const summary = `${label}. ${links.map((l) => `${name(l.source)} to ${name(l.target)}: ${format(l.value)}`).join("; ")}.`
|
|
780
|
+
return (
|
|
781
|
+
<div ref={wrap} className="relative w-full">
|
|
782
|
+
{width > 0 && (
|
|
783
|
+
<svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => { setActive(null); setHoverNode(null) }} className={svgCls}>
|
|
784
|
+
{geo.map((g, gi) => (
|
|
785
|
+
<path key={g.i} d={g.d} fill="var(--color-c1)" fillOpacity={!any ? 0.2 : lit.has(g.i) ? 0.45 : 0.06} onMouseEnter={() => { setHoverNode(null); setActive(gi) }} className="transition-[fill-opacity] duration-(--duration-fast)" />
|
|
786
|
+
))}
|
|
787
|
+
{nodes.map((n) => {
|
|
788
|
+
const p = pos[n.id], c = col[n.id], lastCol = c === C - 1 && C > 1
|
|
789
|
+
const share = pct(val(n.id), colTotal(c), 0)
|
|
790
|
+
return (
|
|
791
|
+
<g key={n.id} onMouseEnter={() => { setActive(null); setHoverNode(n.id) }} className={cn("transition-opacity duration-(--duration-fast)", any && !litNodes.has(n.id) && "opacity-50")}>
|
|
792
|
+
<rect x={p.x} y={p.y} width={nodeW} height={p.h} rx={2} fill="var(--color-c1)" />
|
|
793
|
+
<rect x={p.x - 4} y={p.y - 4} width={nodeW + 8} height={p.h + 8} fill="transparent" />
|
|
794
|
+
<text x={lastCol ? p.x - 6 : p.x + nodeW + 6} y={p.y + p.h / 2} dy="0.32em" textAnchor={lastCol ? "end" : "start"} strokeWidth={3} className={cn("text-xs", halo)}>
|
|
795
|
+
<tspan className="fill-ink font-medium">{n.label}</tspan>
|
|
796
|
+
{roomy && <tspan className="fill-ink-2 tabular"> {format(val(n.id))} · {share}</tspan>}
|
|
797
|
+
</text>
|
|
798
|
+
</g>
|
|
799
|
+
)
|
|
800
|
+
})}
|
|
801
|
+
</svg>
|
|
802
|
+
)}
|
|
803
|
+
{cur && curGeo && (
|
|
804
|
+
<Tip x={curGeo.mid[0]} y={Math.min(curGeo.mid[1], height - 80)} width={width} title={`${name(cur.source)} → ${name(cur.target)}`} rows={[
|
|
805
|
+
{ label: "Value", value: format(cur.value), color: "var(--color-c1)" },
|
|
806
|
+
{ label: `Of ${name(cur.source)}`, value: pct(cur.value, outSum(cur.source) || 1) },
|
|
807
|
+
]} />
|
|
808
|
+
)}
|
|
809
|
+
{hoverNode && !cur && pos[hoverNode] && (
|
|
810
|
+
<Tip x={pos[hoverNode].x + nodeW} y={Math.min(pos[hoverNode].y, height - 80)} width={width} title={name(hoverNode)} rows={[
|
|
811
|
+
{ label: "Total", value: format(val(hoverNode)), color: "var(--color-c1)" },
|
|
812
|
+
{ label: "Of column", value: pct(val(hoverNode), colTotal(col[hoverNode])) },
|
|
813
|
+
]} />
|
|
814
|
+
)}
|
|
815
|
+
</div>
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/* ---------------------------------------------------------------- histogram */
|
|
820
|
+
|
|
821
|
+
export type Bin = { from: number; to: number; count: number }
|
|
822
|
+
|
|
823
|
+
/** Count values into equal, round-edged bins. */
|
|
824
|
+
export function binValues(values: number[], { bins = 10, binWidth, start }: { bins?: number; binWidth?: number; start?: number } = {}): Bin[] {
|
|
825
|
+
if (!values.length) return []
|
|
826
|
+
const lo = Math.min(...values), hi = Math.max(...values)
|
|
827
|
+
const step = binWidth ?? niceStep((hi - lo) / bins || 1)
|
|
828
|
+
const s0 = start ?? Math.floor(lo / step) * step
|
|
829
|
+
const n = Math.max(1, Math.floor((hi - s0) / step) + 1)
|
|
830
|
+
const out = Array.from({ length: n }, (_, i) => ({ from: +(s0 + i * step).toFixed(10), to: +(s0 + (i + 1) * step).toFixed(10), count: 0 }))
|
|
831
|
+
for (const v of values) out[Math.min(n - 1, Math.max(0, Math.floor((v - s0) / step)))].count++
|
|
832
|
+
return out
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export const histogramTable = (bins: Bin[], format: (n: number) => string = fmt, first = "Range") => ({
|
|
836
|
+
columns: [first, "Count"],
|
|
837
|
+
rows: bins.map((b) => [`${format(b.from)} to ${format(b.to)}`, b.count]),
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
/** How values spread: touching columns, one per bin, counted on one axis. */
|
|
841
|
+
export function Histogram({
|
|
842
|
+
bins,
|
|
843
|
+
height = 220,
|
|
844
|
+
format = fmt,
|
|
845
|
+
xTitle,
|
|
846
|
+
countLabel = "records",
|
|
847
|
+
marker,
|
|
848
|
+
"aria-label": label,
|
|
849
|
+
}: {
|
|
850
|
+
bins: Bin[]
|
|
851
|
+
height?: number
|
|
852
|
+
/** Formats bin edges. */
|
|
853
|
+
format?: (n: number) => string
|
|
854
|
+
xTitle: string
|
|
855
|
+
/** Plural noun for what is counted, e.g. “roles”. */
|
|
856
|
+
countLabel?: string
|
|
857
|
+
/** A dashed reference such as the median. */
|
|
858
|
+
marker?: { value: number; label: string }
|
|
859
|
+
"aria-label": string
|
|
860
|
+
}) {
|
|
861
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
862
|
+
const n = bins.length
|
|
863
|
+
const { active, setActive, onKey } = useActive(n)
|
|
864
|
+
const ticks = niceTicks(Math.max(...bins.map((b) => b.count), 1))
|
|
865
|
+
const top = ticks[ticks.length - 1] || 1
|
|
866
|
+
const lo = bins[0]?.from ?? 0, hi = bins[n - 1]?.to ?? 1
|
|
867
|
+
const pad = { t: 16, r: textW(format(hi)) / 2 + 2, b: 38, l: Math.max(...ticks.map((t) => textW(fmt(t)))) + 10 }
|
|
868
|
+
const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
|
|
869
|
+
const band = n ? w / n : 0
|
|
870
|
+
const sx = (v: number) => pad.l + ((v - lo) / (hi - lo || 1)) * w
|
|
871
|
+
const sy = (v: number) => pad.t + h - (v / top) * h
|
|
872
|
+
const edges = [...bins.map((b) => b.from), hi]
|
|
873
|
+
const labelEvery = Math.max(1, Math.ceil((Math.max(...edges.map((e) => textW(format(e)))) + 12) / (band || 1)))
|
|
874
|
+
const cur = active === null ? null : bins[active]
|
|
875
|
+
const total = bins.reduce((a, b) => a + b.count, 0)
|
|
876
|
+
const peak = bins.reduce((a, b) => (b.count > a.count ? b : a), bins[0] ?? { from: 0, to: 0, count: 0 })
|
|
877
|
+
const summary = `${label}. ${total} ${countLabel} in ${n} bins from ${format(lo)} to ${format(hi)}. Most common: ${format(peak.from)} to ${format(peak.to)} with ${peak.count}.`
|
|
878
|
+
return (
|
|
879
|
+
<div ref={wrap} className="relative w-full">
|
|
880
|
+
{width > 0 && (
|
|
881
|
+
<svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
|
|
882
|
+
{ticks.map((t) => (
|
|
883
|
+
<g key={t}>
|
|
884
|
+
<line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} />
|
|
885
|
+
<text x={pad.l - 6} y={sy(t)} dy="0.32em" textAnchor="end" className="fill-ink-3 text-xs tabular">{fmt(t)}</text>
|
|
886
|
+
</g>
|
|
887
|
+
))}
|
|
888
|
+
{edges.map((e, i) => i % labelEvery === 0 && (
|
|
889
|
+
<text key={e} x={sx(e)} y={pad.t + h + 14} textAnchor={i === 0 ? "start" : "middle"} className="fill-ink-3 text-xs tabular">{format(e)}</text>
|
|
890
|
+
))}
|
|
891
|
+
<text x={pad.l + w / 2} y={height - 4} textAnchor="middle" className="fill-ink-2 text-xs">{xTitle}</text>
|
|
892
|
+
{bins.map((b, i) => (
|
|
893
|
+
<g key={i} onMouseEnter={() => setActive(i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-55")}>
|
|
894
|
+
<rect x={pad.l + band * i} y={pad.t} width={band} height={h} fill="transparent" />
|
|
895
|
+
{b.count > 0 && <path d={barPath(pad.l + band * i + 1, sy(b.count), sy(0), Math.max(1, band - 2), "top")} fill="var(--color-c1)" />}
|
|
896
|
+
</g>
|
|
897
|
+
))}
|
|
898
|
+
{marker && (
|
|
899
|
+
<g>
|
|
900
|
+
<line x1={sx(marker.value)} x2={sx(marker.value)} y1={pad.t - 4} y2={pad.t + h} className="stroke-ink" strokeDasharray="4 3" />
|
|
901
|
+
<text x={sx(marker.value) + 4} y={pad.t - 6} strokeWidth={3} className={cn("fill-ink-2 text-xs", halo)}>{marker.label}</text>
|
|
902
|
+
</g>
|
|
903
|
+
)}
|
|
904
|
+
</svg>
|
|
905
|
+
)}
|
|
906
|
+
{cur && active !== null && (
|
|
907
|
+
<Tip x={pad.l + band * (active + 1)} y={Math.max(0, sy(cur.count) - 30)} width={width} title={`${format(cur.from)} to ${format(cur.to)}`} rows={[
|
|
908
|
+
{ label: countLabel[0].toUpperCase() + countLabel.slice(1), value: fmt(cur.count), color: "var(--color-c1)" },
|
|
909
|
+
{ label: "Of all", value: pct(cur.count, total || 1) },
|
|
910
|
+
]} />
|
|
911
|
+
)}
|
|
912
|
+
</div>
|
|
913
|
+
)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/* ---------------------------------------------------------------- box plot */
|
|
917
|
+
|
|
918
|
+
export type BoxStats = { min: number; q1: number; median: number; q3: number; max: number; outliers: number[] }
|
|
919
|
+
const quantile = (s: number[], q: number) => {
|
|
920
|
+
const p = (s.length - 1) * q, b = Math.floor(p)
|
|
921
|
+
return s[b + 1] !== undefined ? s[b] + (p - b) * (s[b + 1] - s[b]) : s[b]
|
|
922
|
+
}
|
|
923
|
+
/** Median, quartiles, whiskers at 1.5 × IQR and the points beyond them. */
|
|
924
|
+
export function boxStats(values: number[]): BoxStats {
|
|
925
|
+
const s = [...values].sort((a, b) => a - b)
|
|
926
|
+
const q1 = quantile(s, 0.25), q3 = quantile(s, 0.75), iqr = q3 - q1
|
|
927
|
+
const inside = s.filter((v) => v >= q1 - 1.5 * iqr && v <= q3 + 1.5 * iqr)
|
|
928
|
+
return { min: inside[0] ?? q1, q1, median: quantile(s, 0.5), q3, max: inside[inside.length - 1] ?? q3, outliers: s.filter((v) => v < q1 - 1.5 * iqr || v > q3 + 1.5 * iqr) }
|
|
929
|
+
}
|
|
930
|
+
export type BoxGroup = { id: string; label: string; values?: number[]; stats?: BoxStats }
|
|
931
|
+
|
|
932
|
+
export const boxTable = (groups: BoxGroup[], format: (n: number) => string = fmt, first = "Group") => ({
|
|
933
|
+
columns: [first, "Lower whisker", "Q1", "Median", "Q3", "Upper whisker", "Outliers"],
|
|
934
|
+
rows: groups.map((g) => { const s = g.stats ?? boxStats(g.values ?? []); return [g.label, format(s.min), format(s.q1), format(s.median), format(s.q3), format(s.max), s.outliers.length ? s.outliers.map(format).join(", ") : "None"] }),
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
/** Distributions side by side on one axis: box from Q1 to Q3, median line, whiskers, outlier dots. */
|
|
938
|
+
export function BoxPlot({ groups, format = fmt, xTitle, "aria-label": label }: { groups: BoxGroup[]; format?: (n: number) => string; xTitle: string; "aria-label": string }) {
|
|
939
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
940
|
+
const stats = groups.map((g) => g.stats ?? boxStats(g.values ?? []))
|
|
941
|
+
const n = groups.length
|
|
942
|
+
const { active, setActive, onKey } = useActive(n)
|
|
943
|
+
const all = stats.flatMap((s) => [s.min, s.max, ...s.outliers])
|
|
944
|
+
const ticks = rangeTicks(Math.min(...all), Math.max(...all), Math.max(2, Math.min(6, Math.floor(width / 90))))
|
|
945
|
+
const lo = ticks[0], hi = ticks[ticks.length - 1]
|
|
946
|
+
const rowH = 36
|
|
947
|
+
const pad = { t: 4, r: 12, b: 38, l: Math.min(140, Math.max(...groups.map((g) => textW(g.label))) + 12) }
|
|
948
|
+
const height = pad.t + n * rowH + pad.b
|
|
949
|
+
const w = Math.max(0, width - pad.l - pad.r)
|
|
950
|
+
const sx = (v: number) => pad.l + ((v - lo) / (hi - lo || 1)) * w
|
|
951
|
+
const cy = (i: number) => pad.t + rowH * i + rowH / 2
|
|
952
|
+
const cur = active === null ? null : stats[active]
|
|
953
|
+
const summary = `${label}. ${groups.map((g, i) => `${g.label}: median ${format(stats[i].median)}, middle half ${format(stats[i].q1)} to ${format(stats[i].q3)}${stats[i].outliers.length ? `, ${stats[i].outliers.length} outliers` : ""}`).join("; ")}.`
|
|
954
|
+
return (
|
|
955
|
+
<div className="grid gap-3">
|
|
956
|
+
<div ref={wrap} className="relative w-full">
|
|
957
|
+
{width > 0 && (
|
|
958
|
+
<svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
|
|
959
|
+
{ticks.map((t, i) => (
|
|
960
|
+
<g key={t}>
|
|
961
|
+
<line x1={sx(t)} x2={sx(t)} y1={pad.t} y2={pad.t + n * rowH} className="stroke-line" />
|
|
962
|
+
<text x={sx(t)} y={pad.t + n * rowH + 14} textAnchor={i === 0 ? "start" : i === ticks.length - 1 ? "end" : "middle"} className="fill-ink-3 text-xs tabular">{format(t)}</text>
|
|
963
|
+
</g>
|
|
964
|
+
))}
|
|
965
|
+
<text x={pad.l + w / 2} y={height - 4} textAnchor="middle" className="fill-ink-2 text-xs">{xTitle}</text>
|
|
966
|
+
{groups.map((g, i) => {
|
|
967
|
+
const s = stats[i], y = cy(i)
|
|
968
|
+
return (
|
|
969
|
+
<g key={g.id} onMouseEnter={() => setActive(i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-55")}>
|
|
970
|
+
<rect x={0} y={y - rowH / 2} width={width} height={rowH} fill="transparent" />
|
|
971
|
+
<text x={pad.l - 10} y={y} dy="0.32em" textAnchor="end" className="fill-ink-2 text-xs">{g.label}</text>
|
|
972
|
+
<line x1={sx(s.min)} x2={sx(s.q1)} y1={y} y2={y} className="stroke-ink-3" />
|
|
973
|
+
<line x1={sx(s.q3)} x2={sx(s.max)} y1={y} y2={y} className="stroke-ink-3" />
|
|
974
|
+
<line x1={sx(s.min)} x2={sx(s.min)} y1={y - 5} y2={y + 5} className="stroke-ink-3" />
|
|
975
|
+
<line x1={sx(s.max)} x2={sx(s.max)} y1={y - 5} y2={y + 5} className="stroke-ink-3" />
|
|
976
|
+
<rect x={sx(s.q1)} y={y - 9} width={Math.max(1, sx(s.q3) - sx(s.q1))} height={18} rx={3} fill="var(--color-c1)" fillOpacity={0.22} stroke="var(--color-c1)" strokeWidth={1.5} />
|
|
977
|
+
<line x1={sx(s.median)} x2={sx(s.median)} y1={y - 9} y2={y + 9} className="stroke-ink" strokeWidth={2} />
|
|
978
|
+
{s.outliers.map((o, k) => <circle key={k} cx={sx(o)} cy={y} r={3.5} fill="var(--color-c1)" className="stroke-surface" strokeWidth={1.5} />)}
|
|
979
|
+
</g>
|
|
980
|
+
)
|
|
981
|
+
})}
|
|
982
|
+
</svg>
|
|
983
|
+
)}
|
|
984
|
+
{cur && active !== null && (
|
|
985
|
+
<Tip x={sx(cur.q3)} y={Math.max(0, cy(active) - 20)} width={width} title={groups[active].label} rows={[
|
|
986
|
+
{ label: "Median", value: format(cur.median), color: "var(--color-ink)" },
|
|
987
|
+
{ label: "Middle half", value: `${format(cur.q1)} to ${format(cur.q3)}`, color: "var(--color-c1)" },
|
|
988
|
+
{ label: "Whiskers", value: `${format(cur.min)} to ${format(cur.max)}` },
|
|
989
|
+
{ label: "Outliers", value: cur.outliers.length ? String(cur.outliers.length) : "None" },
|
|
990
|
+
]} />
|
|
991
|
+
)}
|
|
992
|
+
</div>
|
|
993
|
+
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-ink-2" aria-label="Legend">
|
|
994
|
+
<li className="inline-flex items-center gap-control"><span aria-hidden="true" className="h-2.5 w-4 rounded-xs border-2 border-c1 bg-c1/20" />Middle half (Q1 to Q3)</li>
|
|
995
|
+
<li className="inline-flex items-center gap-control"><span aria-hidden="true" className="h-3 w-0.5 rounded-full bg-ink" />Median</li>
|
|
996
|
+
<li className="inline-flex items-center gap-control"><span aria-hidden="true" className="h-px w-4 bg-ink-3" />Whiskers, 1.5 × IQR</li>
|
|
997
|
+
<li className="inline-flex items-center gap-control"><span aria-hidden="true" className="size-2 rounded-full bg-c1" />Outlier</li>
|
|
998
|
+
</ul>
|
|
999
|
+
</div>
|
|
1000
|
+
)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/* ---------------------------------------------------------------- candlestick */
|
|
1004
|
+
|
|
1005
|
+
export type Candle = { t: string; o: number; h: number; l: number; c: number; v?: number }
|
|
1006
|
+
|
|
1007
|
+
export const candleTable = (candles: Candle[], format: (n: number) => string = fmt, formatVolume: (n: number) => string = fmt) => ({
|
|
1008
|
+
columns: ["Date", "Open", "High", "Low", "Close", "Change", ...(candles.some((k) => k.v !== undefined) ? ["Volume"] : [])],
|
|
1009
|
+
rows: candles.map((k) => [k.t, format(k.o), format(k.h), format(k.l), format(k.c), signed(k.c - k.o, format), ...(k.v !== undefined ? [formatVolume(k.v)] : [])]),
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Open, high, low, close per period. Up and down are two categorical colours and differ in fill too:
|
|
1014
|
+
* up hollow, down filled, so direction never rests on colour. Volume is a second chart below, never a second axis.
|
|
1015
|
+
*/
|
|
1016
|
+
export function CandlestickChart({
|
|
1017
|
+
candles,
|
|
1018
|
+
height = 240,
|
|
1019
|
+
volumeHeight = 64,
|
|
1020
|
+
format = fmt,
|
|
1021
|
+
formatVolume = (n) => n.toLocaleString("en-GB", { notation: "compact", maximumFractionDigits: 1 }),
|
|
1022
|
+
colors = "categorical",
|
|
1023
|
+
showLast = false,
|
|
1024
|
+
lastNote,
|
|
1025
|
+
live = false,
|
|
1026
|
+
volumeLabel = "Volume",
|
|
1027
|
+
"aria-label": label,
|
|
1028
|
+
}: {
|
|
1029
|
+
candles: Candle[]
|
|
1030
|
+
height?: number
|
|
1031
|
+
/** "market" draws up in green and down in red, for trading screens where people expect it; up stays hollow and down filled either way. */
|
|
1032
|
+
colors?: "categorical" | "market"
|
|
1033
|
+
/** Tags the last close on the price axis, in the colour of the last candle. */
|
|
1034
|
+
showLast?: boolean
|
|
1035
|
+
/** A second line under the last-price tag, such as the time until the open candle closes ("12:41"). */
|
|
1036
|
+
lastNote?: string
|
|
1037
|
+
/** The last candle and the price tag ease to each new value, for a feed that updates every second. Off under reduced motion. */
|
|
1038
|
+
live?: boolean
|
|
1039
|
+
/** Names the volume chart with its unit, such as "Volume (BTC)". */
|
|
1040
|
+
volumeLabel?: string
|
|
1041
|
+
/** 0 hides the volume chart. */
|
|
1042
|
+
volumeHeight?: number
|
|
1043
|
+
format?: (n: number) => string
|
|
1044
|
+
formatVolume?: (n: number) => string
|
|
1045
|
+
"aria-label": string
|
|
1046
|
+
}) {
|
|
1047
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
1048
|
+
const n = candles.length
|
|
1049
|
+
const { active, setActive, onKey } = useActive(n)
|
|
1050
|
+
const ticks = rangeTicks(Math.min(...candles.map((k) => k.l)), Math.max(...candles.map((k) => k.h)))
|
|
1051
|
+
const lo = ticks[0], hi = ticks[ticks.length - 1]
|
|
1052
|
+
const showVol = volumeHeight > 0 && candles.some((k) => k.v !== undefined)
|
|
1053
|
+
const vTicks = niceTicks(Math.max(...candles.map((k) => k.v ?? 0), 1), 2)
|
|
1054
|
+
const vTop = vTicks[vTicks.length - 1] || 1
|
|
1055
|
+
const lastC = candles[n - 1]?.c ?? 0
|
|
1056
|
+
const axisW = Math.max(...ticks.map((t) => textW(format(t))), ...vTicks.map((t) => textW(formatVolume(t))), showLast ? textW(format(lastC)) + 6 : 0, lastNote ? textW(lastNote) + 6 : 0) + 10
|
|
1057
|
+
const ease = live ? "motion-safe:transition-all motion-safe:duration-(--duration-base) motion-safe:ease-(--ease-standard)" : undefined
|
|
1058
|
+
const pad = { t: 8, r: axisW, b: 22, l: 2 }
|
|
1059
|
+
const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
|
|
1060
|
+
const band = n ? w / n : 0
|
|
1061
|
+
const bw = Math.max(3, Math.min(band * 0.6, 12))
|
|
1062
|
+
const cx = (i: number) => pad.l + band * i + band / 2
|
|
1063
|
+
const sy = (v: number) => pad.t + h - ((v - lo) / (hi - lo || 1)) * h
|
|
1064
|
+
const vh = volumeHeight - 8
|
|
1065
|
+
const vy = (v: number) => 4 + vh - (v / vTop) * vh
|
|
1066
|
+
const every = Math.max(1, Math.ceil(n / Math.max(2, Math.floor(w / 64))))
|
|
1067
|
+
const [up, down] = colors === "market" ? ["var(--color-ok)", "var(--color-bad)"] : ["var(--color-c1)", "var(--color-c2)"]
|
|
1068
|
+
const cur = active === null ? null : candles[active]
|
|
1069
|
+
const first = candles[0], lastK = candles[n - 1]
|
|
1070
|
+
const summary = `${label}. ${n} periods from ${first?.t} to ${lastK?.t}. Opened at ${format(first?.o ?? 0)}, closed at ${format(lastK?.c ?? 0)}. High ${format(Math.max(...candles.map((k) => k.h)))}, low ${format(Math.min(...candles.map((k) => k.l)))}. The price axis starts at ${format(lo)}, not zero.`
|
|
1071
|
+
const hits = (hgt: number, y0: number) => candles.map((_, i) => <rect key={`h${i}`} x={pad.l + band * i} y={y0} width={band} height={hgt} fill="transparent" onMouseEnter={() => setActive(i)} />)
|
|
1072
|
+
return (
|
|
1073
|
+
<div className="grid gap-3">
|
|
1074
|
+
<div ref={wrap} className="relative grid w-full gap-1" onMouseLeave={() => setActive(null)}>
|
|
1075
|
+
{width > 0 && (
|
|
1076
|
+
<svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} className={svgCls}>
|
|
1077
|
+
{ticks.map((t) => (
|
|
1078
|
+
<g key={t}>
|
|
1079
|
+
<line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className="stroke-line" />
|
|
1080
|
+
{!(showLast && sy(t) - sy(lastC) > -12 && sy(t) - sy(lastC) < (lastNote ? 28 : 12)) && <text x={pad.l + w + 6} y={sy(t)} dy="0.32em" className="fill-ink-3 text-xs tabular">{format(t)}</text>}
|
|
1081
|
+
</g>
|
|
1082
|
+
))}
|
|
1083
|
+
{showLast && lastK && (
|
|
1084
|
+
// Drawn at 0 and moved with a transform, so it can glide to each new price.
|
|
1085
|
+
<g className={ease} style={{ transform: `translateY(${sy(lastC)}px)` }}>
|
|
1086
|
+
<line x1={pad.l} x2={pad.l + w} y1={0} y2={0} stroke={lastK.c >= lastK.o ? up : down} strokeDasharray="2 3" />
|
|
1087
|
+
<rect x={pad.l + w + 2} y={-9} width={axisW - 4} height={lastNote ? 32 : 18} rx={3} fill={lastK.c >= lastK.o ? up : down} />
|
|
1088
|
+
<text x={pad.l + w + 6} y={0} dy="0.32em" className="fill-bg text-xs font-medium tabular">{format(lastC)}</text>
|
|
1089
|
+
{lastNote && <text x={pad.l + w + 6} y={14} dy="0.32em" className="fill-bg text-xs tabular opacity-85">{lastNote}</text>}
|
|
1090
|
+
</g>
|
|
1091
|
+
)}
|
|
1092
|
+
{candles.map((k, i) => i % every === 0 && <text key={`x${i}`} x={cx(i)} y={height - 6} textAnchor={i === 0 ? "start" : "middle"} className="fill-ink-3 text-xs">{k.t}</text>)}
|
|
1093
|
+
{active !== null && <line x1={cx(active)} x2={cx(active)} y1={pad.t} y2={pad.t + h} className="stroke-line-strong" />}
|
|
1094
|
+
{candles.map((k, i) => {
|
|
1095
|
+
const isUp = k.c >= k.o, col = isUp ? up : down
|
|
1096
|
+
const yT = sy(Math.max(k.o, k.c)), yB = sy(Math.min(k.o, k.c))
|
|
1097
|
+
// The open candle eases to each new price when live; closed candles never move.
|
|
1098
|
+
const e = i === n - 1 ? ease : undefined
|
|
1099
|
+
return (
|
|
1100
|
+
<g key={k.t + i} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-55")}>
|
|
1101
|
+
<rect x={cx(i) - 0.5} y={sy(k.h)} width={1} height={Math.max(1, sy(k.l) - sy(k.h))} fill={col} className={e} />
|
|
1102
|
+
{isUp
|
|
1103
|
+
? <rect x={cx(i) - bw / 2 + 0.75} y={yT + 0.75} width={bw - 1.5} height={Math.max(1, yB - yT - 1.5)} rx={1} className={cn("fill-surface", e)} stroke={col} strokeWidth={1.5} />
|
|
1104
|
+
: <rect x={cx(i) - bw / 2} y={yT} width={bw} height={Math.max(1, yB - yT)} rx={1} fill={col} className={e} />}
|
|
1105
|
+
</g>
|
|
1106
|
+
)
|
|
1107
|
+
})}
|
|
1108
|
+
{hits(h, pad.t)}
|
|
1109
|
+
</svg>
|
|
1110
|
+
)}
|
|
1111
|
+
{width > 0 && showVol && (
|
|
1112
|
+
<svg width={width} height={volumeHeight} role="img" aria-label={`Volume per period, ${formatVolume(Math.min(...candles.map((k) => k.v ?? 0)))} to ${formatVolume(Math.max(...candles.map((k) => k.v ?? 0)))}. Values are in the table view.`} className="block overflow-visible">
|
|
1113
|
+
{vTicks.map((t) => (
|
|
1114
|
+
<g key={t}>
|
|
1115
|
+
<line x1={pad.l} x2={pad.l + w} y1={vy(t)} y2={vy(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} />
|
|
1116
|
+
<text x={pad.l + w + 6} y={vy(t)} dy="0.32em" className="fill-ink-3 text-xs tabular">{formatVolume(t)}</text>
|
|
1117
|
+
</g>
|
|
1118
|
+
))}
|
|
1119
|
+
<text x={pad.l + 2} y={12} strokeWidth={3} className={cn("fill-ink-3 text-xs", halo)}>{volumeLabel}</text>
|
|
1120
|
+
{candles.map((k, i) => k.v ? (i === n - 1 && live
|
|
1121
|
+
? <rect key={k.t + i} x={cx(i) - bw / 2} y={vy(k.v)} width={bw} height={Math.max(0, vy(0) - vy(k.v))} fill={NEUTRAL} fillOpacity={active === i ? 0.9 : 0.45} className={ease} />
|
|
1122
|
+
: <path key={k.t + i} d={barPath(cx(i) - bw / 2, vy(k.v), vy(0), bw, "top")} fill={NEUTRAL} fillOpacity={active === i ? 0.9 : 0.45} />) : null)}
|
|
1123
|
+
{hits(vh, 4)}
|
|
1124
|
+
</svg>
|
|
1125
|
+
)}
|
|
1126
|
+
{cur && active !== null && (
|
|
1127
|
+
<Tip x={cx(active)} y={4} width={width} title={cur.t} rows={[
|
|
1128
|
+
{ label: cur.c >= cur.o ? "Up" : "Down", value: `${signed(cur.c - cur.o, format)} (${signed(((cur.c - cur.o) / (cur.o || 1)) * 100, (v) => `${v.toFixed(2)}%`)})`, color: cur.c >= cur.o ? up : down },
|
|
1129
|
+
{ label: "Open", value: format(cur.o) },
|
|
1130
|
+
{ label: "High", value: format(cur.h) },
|
|
1131
|
+
{ label: "Low", value: format(cur.l) },
|
|
1132
|
+
{ label: "Close", value: format(cur.c) },
|
|
1133
|
+
...(cur.v !== undefined ? [{ label: "Volume", value: formatVolume(cur.v) }] : []),
|
|
1134
|
+
]} />
|
|
1135
|
+
)}
|
|
1136
|
+
</div>
|
|
1137
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
1138
|
+
<KeyLegend items={[{ label: "Up (close above open)", color: up, shape: "hollow" }, { label: "Down (close below open)", color: down, shape: "box" }]} />
|
|
1139
|
+
<p className="text-xs text-ink-3">Price axis starts at {format(lo)}, not zero</p>
|
|
1140
|
+
</div>
|
|
1141
|
+
</div>
|
|
1142
|
+
)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/* ---------------------------------------------------------------- donut */
|
|
1146
|
+
|
|
1147
|
+
export function DonutChart({ segments, total, totalLabel = "Total", format = fmt, "aria-label": label }: { segments: { id: string; label: string; value: number }[]; total?: number; totalLabel?: string; format?: (n: number) => string; "aria-label": string }) {
|
|
1148
|
+
const sum = segments.reduce((a, s) => a + s.value, 0) || 1
|
|
1149
|
+
const [active, setActive] = React.useState<number | null>(null)
|
|
1150
|
+
const R = 64, r = 44, C = 80
|
|
1151
|
+
let a0 = -Math.PI / 2
|
|
1152
|
+
const gap = 2 / R // 2px gap at the outer edge
|
|
1153
|
+
const arcs = segments.map((s, i) => {
|
|
1154
|
+
const a1 = a0 + (s.value / sum) * Math.PI * 2
|
|
1155
|
+
const [s0, s1] = [a0 + gap / 2, Math.max(a0 + gap / 2, a1 - gap / 2)]
|
|
1156
|
+
a0 = a1
|
|
1157
|
+
const p = (rad: number, ang: number) => `${C + rad * Math.cos(ang)},${C + rad * Math.sin(ang)}`
|
|
1158
|
+
const large = s1 - s0 > Math.PI ? 1 : 0
|
|
1159
|
+
return { s, i, d: `M${p(R, s0)}A${R},${R} 0 ${large} 1 ${p(R, s1)}L${p(r, s1)}A${r},${r} 0 ${large} 0 ${p(r, s0)}Z` }
|
|
1160
|
+
})
|
|
1161
|
+
return (
|
|
1162
|
+
<div className="flex flex-wrap items-center gap-6">
|
|
1163
|
+
<svg viewBox="0 0 160 160" className="size-40 shrink-0" role="img" aria-label={`${label}. ${segments.map((s) => `${s.label} ${format(s.value)} (${Math.round((s.value / sum) * 100)}%)`).join(", ")}.`}>
|
|
1164
|
+
{arcs.map(({ s, i, d }) => <path key={s.id} d={d} fill={seriesColor(i)} opacity={active === null || active === i ? 1 : 0.45} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)} />)}
|
|
1165
|
+
<text x={C} y={C - 4} textAnchor="middle" className="fill-ink text-lg font-semibold tabular">{format(total ?? sum)}</text>
|
|
1166
|
+
<text x={C} y={C + 14} textAnchor="middle" className="fill-ink-3 text-xs">{totalLabel}</text>
|
|
1167
|
+
</svg>
|
|
1168
|
+
<ul className="grid min-w-44 gap-2" aria-label="Legend">
|
|
1169
|
+
{segments.map((s, i) => (
|
|
1170
|
+
<li key={s.id} className={cn("flex items-center gap-2 text-sm", active !== null && active !== i && "opacity-60")} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)}>
|
|
1171
|
+
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-xs bg-(--c)" style={{ "--c": seriesColor(i) } as React.CSSProperties} />
|
|
1172
|
+
<span className="text-ink-2">{s.label}</span>
|
|
1173
|
+
<span className="tabular ml-auto pl-4 font-medium text-ink">{format(s.value)}</span>
|
|
1174
|
+
<span className="tabular w-10 text-right text-ink-3">{Math.round((s.value / sum) * 100)}%</span>
|
|
1175
|
+
</li>
|
|
1176
|
+
))}
|
|
1177
|
+
</ul>
|
|
1178
|
+
</div>
|
|
1179
|
+
)
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/* ---------------------------------------------------------------- heatmap, funnel and scatter */
|
|
1183
|
+
|
|
1184
|
+
export function Heatmap({ rows, columns, values, format = fmt, "aria-label": label }: { rows: string[]; columns: string[]; values: number[][]; format?: (n: number) => string; "aria-label": string }) {
|
|
1185
|
+
const max = Math.max(...values.flat(), 1)
|
|
1186
|
+
const step = (v: number) => (v <= 0 ? 0 : Math.min(5, Math.ceil((v / max) * 5)))
|
|
1187
|
+
return (
|
|
1188
|
+
<div className="overflow-x-auto">
|
|
1189
|
+
<table className="border-separate border-spacing-0.5 text-sm" aria-label={label}>
|
|
1190
|
+
<thead><tr><td />{columns.map((c) => <th key={c} scope="col" className="px-1 pb-1 text-xs font-medium text-ink-3">{c}</th>)}</tr></thead>
|
|
1191
|
+
<tbody>
|
|
1192
|
+
{rows.map((r, ri) => (
|
|
1193
|
+
<tr key={r}>
|
|
1194
|
+
<th scope="row" className="pr-2 text-left text-xs font-medium whitespace-nowrap text-ink-2">{r}</th>
|
|
1195
|
+
{columns.map((c, ci) => {
|
|
1196
|
+
const v = values[ri][ci], s = step(v)
|
|
1197
|
+
return (
|
|
1198
|
+
<td key={c} title={`${r}, ${c}: ${format(v)}`} className={cn("h-7 w-9 rounded-xs text-center text-xs tabular", s === 0 ? "bg-hover text-ink-3" : ["", "bg-q1 text-white", "bg-q2 text-white", "bg-q3 text-white", "bg-q4 text-white", "bg-q5 text-white"][s])}>
|
|
1199
|
+
<span className="sr-only">{format(v)}</span>
|
|
1200
|
+
</td>
|
|
1201
|
+
)
|
|
1202
|
+
})}
|
|
1203
|
+
</tr>
|
|
1204
|
+
))}
|
|
1205
|
+
</tbody>
|
|
1206
|
+
</table>
|
|
1207
|
+
<div className="mt-2 flex items-center gap-1 text-xs text-ink-3" aria-hidden="true">
|
|
1208
|
+
Fewer {["bg-hover", "bg-q1", "bg-q2", "bg-q3", "bg-q4", "bg-q5"].map((c) => <span key={c} className={cn("size-3 rounded-xs", c)} />)} More
|
|
1209
|
+
</div>
|
|
1210
|
+
</div>
|
|
1211
|
+
)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
export function Funnel({ stages, format = fmt, "aria-label": label }: { stages: { label: string; value: number }[]; format?: (n: number) => string; "aria-label": string }) {
|
|
1215
|
+
const first = stages[0]?.value || 1
|
|
1216
|
+
return (
|
|
1217
|
+
<ol className="grid gap-2" aria-label={label}>
|
|
1218
|
+
{stages.map((s, i) => {
|
|
1219
|
+
const prev = i ? stages[i - 1].value : null
|
|
1220
|
+
return (
|
|
1221
|
+
<li key={s.label} className="grid grid-cols-[96px_1fr_96px] items-center gap-3 text-sm">
|
|
1222
|
+
<span className="text-ink-2">{s.label}</span>
|
|
1223
|
+
<span className="h-6 overflow-hidden rounded-xs bg-hover"><span className="block h-full w-(--w) rounded-xs bg-c1" style={{ "--w": `${(s.value / first) * 100}%` } as React.CSSProperties} /></span>
|
|
1224
|
+
<span className="tabular text-right"><span className="font-medium text-ink">{format(s.value)}</span>{prev !== null && <span className="text-ink-3"> · {Math.round((s.value / prev) * 100)}%</span>}</span>
|
|
1225
|
+
</li>
|
|
1226
|
+
)
|
|
1227
|
+
})}
|
|
1228
|
+
</ol>
|
|
1229
|
+
)
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/* ---------------------------------------------------------------- scatter */
|
|
1233
|
+
|
|
1234
|
+
export type ScatterPoint = { id: string; label: string; x: number; y: number; group: string; href?: string }
|
|
1235
|
+
|
|
1236
|
+
const SHAPES = ["circle", "square", "triangle"] as const
|
|
1237
|
+
function Mark({ shape, x, y, color, active }: { shape: (typeof SHAPES)[number]; x: number; y: number; color: string; active: boolean }) {
|
|
1238
|
+
const r = active ? 5 : 3.5
|
|
1239
|
+
const common = { fill: color, className: "stroke-surface", strokeWidth: 1 }
|
|
1240
|
+
if (shape === "square") return <rect x={x - r} y={y - r} width={r * 2} height={r * 2} rx={1} {...common} />
|
|
1241
|
+
if (shape === "triangle") return <path d={`M${x},${y - r * 1.2}L${x + r * 1.1},${y + r * 0.8}L${x - r * 1.1},${y + r * 0.8}Z`} {...common} />
|
|
1242
|
+
return <circle cx={x} cy={y} r={r} {...common} />
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Two measures per record. Up to three groups, each with its own colour (c1–c3) and marker shape. */
|
|
1246
|
+
export function ScatterChart({
|
|
1247
|
+
points,
|
|
1248
|
+
xTitle,
|
|
1249
|
+
yTitle,
|
|
1250
|
+
height = 260,
|
|
1251
|
+
formatX = fmt,
|
|
1252
|
+
formatY = fmt,
|
|
1253
|
+
"aria-label": label,
|
|
1254
|
+
}: {
|
|
1255
|
+
points: ScatterPoint[]
|
|
1256
|
+
xTitle: string
|
|
1257
|
+
yTitle: string
|
|
1258
|
+
height?: number
|
|
1259
|
+
formatX?: (n: number) => string
|
|
1260
|
+
formatY?: (n: number) => string
|
|
1261
|
+
"aria-label": string
|
|
1262
|
+
}) {
|
|
1263
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
1264
|
+
const [active, setActive] = React.useState<number | null>(null)
|
|
1265
|
+
const [pinned, setPinned] = React.useState(false)
|
|
1266
|
+
const groups = [...new Set(points.map((p) => p.group))].slice(0, 3)
|
|
1267
|
+
const sorted = [...points].filter((p) => groups.includes(p.group)).sort((a, b) => a.x - b.x)
|
|
1268
|
+
const pad = { t: 10, r: 12, b: 40, l: 52 }
|
|
1269
|
+
const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
|
|
1270
|
+
const xt = niceTicks(Math.max(...sorted.map((p) => p.x), 1)), yt = niceTicks(Math.max(...sorted.map((p) => p.y), 1))
|
|
1271
|
+
const sx = (v: number) => pad.l + (v / xt[xt.length - 1]) * w
|
|
1272
|
+
const sy = (v: number) => pad.t + h - (v / yt[yt.length - 1]) * h
|
|
1273
|
+
const cur = active === null ? null : sorted[active]
|
|
1274
|
+
const onKey = (e: React.KeyboardEvent) => {
|
|
1275
|
+
if (e.key === "ArrowRight") { e.preventDefault(); setPinned(false); setActive((a) => Math.min(sorted.length - 1, a === null ? 0 : a + 1)) }
|
|
1276
|
+
else if (e.key === "ArrowLeft") { e.preventDefault(); setPinned(false); setActive((a) => Math.max(0, a === null ? 0 : a - 1)) }
|
|
1277
|
+
else if (e.key === "Enter" && active !== null) { e.preventDefault(); setPinned(true) }
|
|
1278
|
+
else if (e.key === "Escape") { setPinned(false); setActive(null) }
|
|
1279
|
+
}
|
|
1280
|
+
return (
|
|
1281
|
+
<div className="grid gap-3">
|
|
1282
|
+
<div ref={wrap} className="relative w-full">
|
|
1283
|
+
{width > 0 && (
|
|
1284
|
+
<svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={`${label}. ${sorted.length} records. ${xTitle} against ${yTitle}.`} onKeyDown={onKey} onMouseLeave={() => !pinned && setActive(null)} className="block overflow-visible rounded-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
|
|
1285
|
+
{yt.map((t) => (
|
|
1286
|
+
<g key={`y${t}`}>
|
|
1287
|
+
<line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} />
|
|
1288
|
+
<text x={pad.l - 6} y={sy(t)} dy="0.32em" textAnchor="end" className="fill-ink-3 text-xs tabular">{formatY(t)}</text>
|
|
1289
|
+
</g>
|
|
1290
|
+
))}
|
|
1291
|
+
{xt.map((t) => <text key={`x${t}`} x={sx(t)} y={pad.t + h + 14} textAnchor="middle" className="fill-ink-3 text-xs tabular">{formatX(t)}</text>)}
|
|
1292
|
+
<text x={pad.l + w / 2} y={height - 4} textAnchor="middle" className="fill-ink-2 text-xs">{xTitle}</text>
|
|
1293
|
+
<text transform={`translate(12 ${pad.t + h / 2}) rotate(-90)`} textAnchor="middle" className="fill-ink-2 text-xs">{yTitle}</text>
|
|
1294
|
+
{sorted.map((p, i) => {
|
|
1295
|
+
const g = groups.indexOf(p.group)
|
|
1296
|
+
return (
|
|
1297
|
+
<g key={p.id} onMouseEnter={() => !pinned && setActive(i)} onClick={() => { setActive(i); setPinned(true) }} className="cursor-pointer">
|
|
1298
|
+
<circle cx={sx(p.x)} cy={sy(p.y)} r={10} fill="transparent" />
|
|
1299
|
+
<Mark shape={SHAPES[g]} x={sx(p.x)} y={sy(p.y)} color={seriesColor(g)} active={active === i} />
|
|
1300
|
+
</g>
|
|
1301
|
+
)
|
|
1302
|
+
})}
|
|
1303
|
+
</svg>
|
|
1304
|
+
)}
|
|
1305
|
+
{cur && width > 0 && (
|
|
1306
|
+
<div
|
|
1307
|
+
role={pinned ? "dialog" : "status"}
|
|
1308
|
+
aria-label={pinned ? cur.label : undefined}
|
|
1309
|
+
className={cn("absolute z-10 grid min-w-44 gap-1 rounded-md border border-line bg-surface px-2 py-2 text-sm shadow-pop left-(--x) top-(--y)", !pinned && "pointer-events-none")}
|
|
1310
|
+
style={{ "--x": `${Math.min(sx(cur.x) + 10, width - 190)}px`, "--y": `${Math.max(0, sy(cur.y) - 70)}px` } as React.CSSProperties}
|
|
1311
|
+
>
|
|
1312
|
+
<span className="font-medium text-ink">{cur.label}</span>
|
|
1313
|
+
<span className="flex justify-between gap-3 text-ink-2">{xTitle}<span className="tabular font-medium text-ink">{formatX(cur.x)}</span></span>
|
|
1314
|
+
<span className="flex justify-between gap-3 text-ink-2">{yTitle}<span className="tabular font-medium text-ink">{formatY(cur.y)}</span></span>
|
|
1315
|
+
{pinned && cur.href && <a href={cur.href} className="font-medium text-primary-ink hover:underline" autoFocus>Open {cur.label}</a>}
|
|
1316
|
+
</div>
|
|
1317
|
+
)}
|
|
1318
|
+
</div>
|
|
1319
|
+
{groups.length > 1 && (
|
|
1320
|
+
<ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label="Legend">
|
|
1321
|
+
{groups.map((g, i) => (
|
|
1322
|
+
<li key={g} className="inline-flex items-center gap-control text-sm text-ink-2">
|
|
1323
|
+
<svg width="10" height="10" aria-hidden="true"><Mark shape={SHAPES[i]} x={5} y={5} color={seriesColor(i)} active={false} /></svg>
|
|
1324
|
+
{g}
|
|
1325
|
+
</li>
|
|
1326
|
+
))}
|
|
1327
|
+
</ul>
|
|
1328
|
+
)}
|
|
1329
|
+
</div>
|
|
1330
|
+
)
|
|
1331
|
+
}
|