@twoux/ui 0.0.0-stage → 0.1.1
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/README.md +211 -2
- 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
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX map chart: regions shaded by one value (a choropleth), or circles sized by a count at points.
|
|
3
|
+
* Built on d3-geo and topojson-client; the app passes its own TopoJSON, so no map data ships in the package.
|
|
4
|
+
* Same rules as charts-more: own SVG, one tab stop, arrow keys between marks (here: regions or markers with
|
|
5
|
+
* data, highest value first), Enter pins the details, Esc clears, and mapTable() for the Table view.
|
|
6
|
+
* Colour is the five-step sequential scale (q1…q5) the Heatmap uses. Regions with no value are hatched and
|
|
7
|
+
* labelled "No data" in the legend, never drawn as zero. No zoom or pan: the map fits its width.
|
|
8
|
+
*/
|
|
9
|
+
import * as React from "react"
|
|
10
|
+
import { geoPath, geoEqualEarth, geoMercator, geoConicEqualArea, type GeoProjection, type ExtendedFeature, type GeoGeometryObjects } from "d3-geo"
|
|
11
|
+
import { feature } from "topojson-client"
|
|
12
|
+
import type { Topology, GeometryObject } from "topojson-specification"
|
|
13
|
+
import { cn } from "../lib/cn"
|
|
14
|
+
import { Button } from "./button"
|
|
15
|
+
import { fmt, pct, svgCls, useWidth, niceStep } from "../lib/chart-kit"
|
|
16
|
+
|
|
17
|
+
/* ---------------------------------------------------------------- types */
|
|
18
|
+
|
|
19
|
+
/** Any TopoJSON topology, such as world-atlas/countries-110m.json imported as JSON. */
|
|
20
|
+
export type MapTopology = { type: string; objects: Record<string, unknown>; arcs: unknown; transform?: unknown; bbox?: unknown }
|
|
21
|
+
/** One value per region, joined to the shape by feature id (world-atlas uses ISO 3166-1 numeric codes such as "826"). */
|
|
22
|
+
export type MapValue = { id: string; value: number; label?: string }
|
|
23
|
+
/** A point sized by its value: circle area is proportional to the value. */
|
|
24
|
+
export type MapMarker = { id: string; label: string; lon: number; lat: number; value: number }
|
|
25
|
+
export type MapProjection = "equal-earth" | "mercator" | "albers-uk" | (() => GeoProjection)
|
|
26
|
+
/** What onSelect receives: the region or marker the person chose. */
|
|
27
|
+
export type MapRegion = { id: string; label: string; value: number }
|
|
28
|
+
|
|
29
|
+
type Encoding = { values: MapValue[]; markers?: never } | { markers: MapMarker[]; values?: never }
|
|
30
|
+
|
|
31
|
+
export type MapChartProps = Encoding & {
|
|
32
|
+
topology: MapTopology
|
|
33
|
+
/** Name of the object in the topology, e.g. "countries". */
|
|
34
|
+
object: string
|
|
35
|
+
/** Feature ids to leave off the map and out of the fit, e.g. ["010"] for Antarctica. */
|
|
36
|
+
exclude?: string[]
|
|
37
|
+
projection?: MapProjection
|
|
38
|
+
/** Fixed height in px. By default the height follows the shape of the map at the current width. */
|
|
39
|
+
height?: number
|
|
40
|
+
format?: (n: number) => string
|
|
41
|
+
/** Four class breaks for the five colour steps. Default: equal, rounded steps from 0 to the highest value. */
|
|
42
|
+
thresholds?: [number, number, number, number]
|
|
43
|
+
/** Show share of total in the details and table. Turn off for rates and averages, where a share means nothing. */
|
|
44
|
+
share?: boolean
|
|
45
|
+
/** Noun for the value in the details, e.g. "Applicants" or "ARR". */
|
|
46
|
+
valueLabel?: string
|
|
47
|
+
/** Drill-down: a pinned region's details offer one button that calls this. */
|
|
48
|
+
onSelect?: (region: MapRegion) => void
|
|
49
|
+
/** Button text in the pinned details. Name the action and where it lands: "Filter page". */
|
|
50
|
+
selectLabel?: string
|
|
51
|
+
/** Field name shown in the pinned details before the drill-down: "Country is France". */
|
|
52
|
+
filterField?: string
|
|
53
|
+
"aria-label": string
|
|
54
|
+
className?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Feat = ExtendedFeature<GeoGeometryObjects | null, { name?: string } | null> & { id?: string | number }
|
|
58
|
+
type Shape = { id: string; name: string; d: string; at: [number, number] }
|
|
59
|
+
|
|
60
|
+
/* ---------------------------------------------------------------- helpers */
|
|
61
|
+
|
|
62
|
+
const STEPS = ["fill-q1", "fill-q2", "fill-q3", "fill-q4", "fill-q5"] as const
|
|
63
|
+
const SWATCH = ["bg-q1", "bg-q2", "bg-q3", "bg-q4", "bg-q5"] as const
|
|
64
|
+
|
|
65
|
+
function makeProjection(p: MapProjection): GeoProjection {
|
|
66
|
+
if (typeof p === "function") return p()
|
|
67
|
+
if (p === "mercator") return geoMercator()
|
|
68
|
+
// Equal-area conic centred on Great Britain and Ireland.
|
|
69
|
+
if (p === "albers-uk") return geoConicEqualArea().parallels([50, 60]).rotate([4.4, 0]).center([0, 55.4])
|
|
70
|
+
return geoEqualEarth()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Equal, rounded steps from 0: four breaks for five classes. */
|
|
74
|
+
export function mapThresholds(max: number): [number, number, number, number] {
|
|
75
|
+
const s = niceStep(Math.max(max, 1) / 5)
|
|
76
|
+
return [s, s * 2, s * 3, s * 4]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const classOf = (v: number, t: readonly number[]) => t.filter((b) => v >= b).length
|
|
80
|
+
|
|
81
|
+
/** Table view: region, value and share, highest first. */
|
|
82
|
+
export const mapTable = (values: MapValue[], { format = fmt, share = true, first = "Region", valueLabel = "Value" }: { format?: (n: number) => string; share?: boolean; first?: string; valueLabel?: string } = {}) => {
|
|
83
|
+
const sorted = [...values].sort((a, b) => b.value - a.value)
|
|
84
|
+
const total = values.reduce((a, v) => a + v.value, 0)
|
|
85
|
+
return {
|
|
86
|
+
columns: share ? [first, valueLabel, "Share"] : [first, valueLabel],
|
|
87
|
+
rows: sorted.map((v) => (share ? [v.label ?? v.id, format(v.value), pct(v.value, total)] : [v.label ?? v.id, format(v.value)])),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------------------------------------------------------------- map chart */
|
|
92
|
+
|
|
93
|
+
/** Where something is matters: regions shaded by a rate, or circles sized by a count. One measure per map. */
|
|
94
|
+
export function MapChart({
|
|
95
|
+
topology,
|
|
96
|
+
object,
|
|
97
|
+
values,
|
|
98
|
+
markers,
|
|
99
|
+
exclude = [],
|
|
100
|
+
projection = "equal-earth",
|
|
101
|
+
height: fixedHeight,
|
|
102
|
+
format = fmt,
|
|
103
|
+
thresholds,
|
|
104
|
+
share = true,
|
|
105
|
+
valueLabel = "Value",
|
|
106
|
+
onSelect,
|
|
107
|
+
selectLabel = "Filter page",
|
|
108
|
+
filterField = "Region",
|
|
109
|
+
"aria-label": label,
|
|
110
|
+
className,
|
|
111
|
+
}: MapChartProps) {
|
|
112
|
+
const [wrap, width] = useWidth<HTMLDivElement>()
|
|
113
|
+
const svgRef = React.useRef<SVGSVGElement>(null)
|
|
114
|
+
const hatch = `${React.useId().replace(/:/g, "")}-nodata`
|
|
115
|
+
const [active, setActive] = React.useState<number | null>(null)
|
|
116
|
+
const [pinned, setPinned] = React.useState(false)
|
|
117
|
+
const [hoverEmpty, setHoverEmpty] = React.useState<string | null>(null)
|
|
118
|
+
|
|
119
|
+
const features = React.useMemo(() => {
|
|
120
|
+
const topo = topology as unknown as Topology
|
|
121
|
+
const obj = topo.objects[object] as GeometryObject | undefined
|
|
122
|
+
if (!obj) return [] as Feat[]
|
|
123
|
+
const fc = feature(topo, obj) as unknown as { features: Feat[] }
|
|
124
|
+
return fc.features.filter((f) => !exclude.includes(String(f.id ?? f.properties?.name)))
|
|
125
|
+
}, [topology, object, exclude.join("|")])
|
|
126
|
+
|
|
127
|
+
const { shapes, height, project } = React.useMemo(() => {
|
|
128
|
+
if (!width || !features.length) return { shapes: [] as Shape[], height: fixedHeight ?? 0, project: null as GeoProjection | null }
|
|
129
|
+
const proj = makeProjection(projection)
|
|
130
|
+
const coll = { type: "FeatureCollection" as const, features }
|
|
131
|
+
let h = fixedHeight ?? 0
|
|
132
|
+
if (fixedHeight) proj.fitSize([width, fixedHeight], coll)
|
|
133
|
+
else {
|
|
134
|
+
proj.fitWidth(width, coll)
|
|
135
|
+
h = Math.ceil(geoPath(proj).bounds(coll)[1][1])
|
|
136
|
+
}
|
|
137
|
+
const path = geoPath(proj)
|
|
138
|
+
const out: Shape[] = features.map((f) => {
|
|
139
|
+
// Centre the details on the largest part, so France sits in Europe and not off French Guiana.
|
|
140
|
+
let at = path.centroid(f)
|
|
141
|
+
if (f.geometry?.type === "MultiPolygon") {
|
|
142
|
+
const parts = (f.geometry.coordinates as number[][][][]).map((c) => ({ type: "Polygon" as const, coordinates: c }))
|
|
143
|
+
const big = parts.reduce((a, p) => (path.area(p) > path.area(a) ? p : a), parts[0])
|
|
144
|
+
at = path.centroid(big)
|
|
145
|
+
}
|
|
146
|
+
return { id: String(f.id ?? f.properties?.name), name: f.properties?.name ?? String(f.id), d: path(f) ?? "", at }
|
|
147
|
+
})
|
|
148
|
+
return { shapes: out, height: h, project: proj }
|
|
149
|
+
}, [width, features, projection, fixedHeight])
|
|
150
|
+
|
|
151
|
+
const byId = React.useMemo(() => new Map(shapes.map((s) => [s.id, s])), [shapes])
|
|
152
|
+
const isMarkers = !!markers
|
|
153
|
+
// Keyboard order: highest value first, among items that are on the map.
|
|
154
|
+
const items: MapRegion[] = React.useMemo(() => {
|
|
155
|
+
const raw = isMarkers ? markers!.map((m) => ({ id: m.id, label: m.label, value: m.value })) : values!.map((v) => ({ id: v.id, label: v.label ?? byId.get(v.id)?.name ?? v.id, value: v.value }))
|
|
156
|
+
return raw.sort((a, b) => b.value - a.value)
|
|
157
|
+
}, [values, markers, byId, isMarkers])
|
|
158
|
+
const total = items.reduce((a, v) => a + v.value, 0)
|
|
159
|
+
const max = Math.max(0, ...items.map((i) => i.value))
|
|
160
|
+
const t = thresholds ?? mapThresholds(max)
|
|
161
|
+
const valueOf = React.useMemo(() => new Map(items.map((i) => [i.id, i.value])), [items])
|
|
162
|
+
const onMap = (i: MapRegion) => (isMarkers ? true : byId.has(i.id))
|
|
163
|
+
const navigable = items.filter(onMap)
|
|
164
|
+
const offMap = items.filter((i) => !onMap(i))
|
|
165
|
+
const noData = isMarkers ? 0 : shapes.filter((s) => !valueOf.has(s.id)).length
|
|
166
|
+
|
|
167
|
+
const markerGeo = React.useMemo(() => {
|
|
168
|
+
if (!isMarkers || !project) return new Map<string, { x: number; y: number; r: number }>()
|
|
169
|
+
const rMax = Math.max(6, Math.min(22, width / 28))
|
|
170
|
+
return new Map(markers!.map((m) => {
|
|
171
|
+
const [x, y] = project([m.lon, m.lat]) ?? [0, 0]
|
|
172
|
+
return [m.id, { x, y, r: Math.max(2, Math.sqrt(m.value / (max || 1)) * rMax) }]
|
|
173
|
+
}))
|
|
174
|
+
}, [isMarkers, markers, project, width, max])
|
|
175
|
+
|
|
176
|
+
const cur = active === null ? null : navigable[active] ?? null
|
|
177
|
+
const posOf = (id: string): [number, number] | null => {
|
|
178
|
+
if (isMarkers) { const g = markerGeo.get(id); return g ? [g.x, g.y] : null }
|
|
179
|
+
return byId.get(id)?.at ?? null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const clear = () => { setPinned(false); setActive(null) }
|
|
183
|
+
const onKey = (e: React.KeyboardEvent) => {
|
|
184
|
+
const n = navigable.length
|
|
185
|
+
if (!n) return
|
|
186
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); setPinned(false); setActive((a) => (a === null ? 0 : Math.min(n - 1, a + 1))) }
|
|
187
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setPinned(false); setActive((a) => (a === null ? 0 : Math.max(0, a - 1))) }
|
|
188
|
+
else if (e.key === "Home") { e.preventDefault(); setPinned(false); setActive(0) }
|
|
189
|
+
else if (e.key === "End") { e.preventDefault(); setPinned(false); setActive(n - 1) }
|
|
190
|
+
else if (e.key === "Enter" && active !== null) { e.preventDefault(); setPinned(true) }
|
|
191
|
+
else if (e.key === "Escape") clear()
|
|
192
|
+
}
|
|
193
|
+
const pick = (id: string) => {
|
|
194
|
+
const i = navigable.findIndex((r) => r.id === id)
|
|
195
|
+
if (i >= 0) { setActive(i); setPinned(true) }
|
|
196
|
+
}
|
|
197
|
+
const hover = (id: string) => {
|
|
198
|
+
if (pinned) return
|
|
199
|
+
const i = navigable.findIndex((r) => r.id === id)
|
|
200
|
+
setHoverEmpty(i < 0 ? id : null)
|
|
201
|
+
setActive(i < 0 ? null : i)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const top3 = navigable.slice(0, 3).map((r) => `${r.label} ${format(r.value)}`).join(", ")
|
|
205
|
+
const summary = `${label}. ${isMarkers ? `${navigable.length} places sized by value` : `${navigable.length} regions shaded by value`}. Highest: ${top3 || "none"}.${noData ? ` ${noData} regions have no data.` : ""} Arrow keys move from highest to lowest; Enter shows details.`
|
|
206
|
+
|
|
207
|
+
const ranges = [
|
|
208
|
+
`Under ${format(t[0])}`,
|
|
209
|
+
`${format(t[0])} to ${format(t[1])}`,
|
|
210
|
+
`${format(t[1])} to ${format(t[2])}`,
|
|
211
|
+
`${format(t[2])} to ${format(t[3])}`,
|
|
212
|
+
`${format(t[3])} and over`,
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
// Details box: kept inside the map's width and height, never covering the region it describes.
|
|
216
|
+
const box = (p: [number, number]) => {
|
|
217
|
+
const w = 208
|
|
218
|
+
const left = p[0] + 14 + w > width ? Math.max(0, p[0] - 14 - w) : p[0] + 14
|
|
219
|
+
return { "--x": `${left}px`, "--y": `${Math.max(0, Math.min(p[1] - 24, height - 96))}px` } as React.CSSProperties
|
|
220
|
+
}
|
|
221
|
+
const curPos = cur ? posOf(cur.id) : null
|
|
222
|
+
// On a narrow map the details would cover it, so they sit under the map instead.
|
|
223
|
+
const narrow = width < 480
|
|
224
|
+
const boxCls = (float: boolean) => cn("z-10 grid gap-1 rounded-md border border-line bg-surface px-2 py-2 text-sm shadow-pop", narrow ? "mt-2 w-full" : "absolute w-52 left-(--x) top-(--y)", !float && "pointer-events-none")
|
|
225
|
+
const emptyShape = hoverEmpty ? byId.get(hoverEmpty) : null
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<div className={cn("grid gap-3", className)}>
|
|
229
|
+
<div ref={wrap} className="relative w-full">
|
|
230
|
+
{width > 0 && height > 0 && (
|
|
231
|
+
<svg
|
|
232
|
+
ref={svgRef}
|
|
233
|
+
width={width}
|
|
234
|
+
height={height}
|
|
235
|
+
role="group"
|
|
236
|
+
tabIndex={0}
|
|
237
|
+
aria-roledescription="map"
|
|
238
|
+
aria-label={summary}
|
|
239
|
+
onKeyDown={onKey}
|
|
240
|
+
onMouseLeave={() => { setHoverEmpty(null); if (!pinned) setActive(null) }}
|
|
241
|
+
className={svgCls}
|
|
242
|
+
>
|
|
243
|
+
<defs>
|
|
244
|
+
<pattern id={hatch} width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
245
|
+
<rect width="5" height="5" className="fill-surface-2" />
|
|
246
|
+
<line x1="0" y1="0" x2="0" y2="5" strokeWidth="1.25" strokeOpacity={0.45} className="stroke-ink-3" />
|
|
247
|
+
</pattern>
|
|
248
|
+
</defs>
|
|
249
|
+
{shapes.map((s) => {
|
|
250
|
+
const v = valueOf.get(s.id)
|
|
251
|
+
const has = !isMarkers && v !== undefined
|
|
252
|
+
return (
|
|
253
|
+
<path
|
|
254
|
+
key={s.id}
|
|
255
|
+
d={s.d}
|
|
256
|
+
fill={isMarkers ? "var(--color-hover)" : has ? undefined : `url(#${hatch})`}
|
|
257
|
+
strokeWidth={0.5}
|
|
258
|
+
className={cn("stroke-surface", has && STEPS[classOf(v!, t)], !isMarkers && "cursor-pointer")}
|
|
259
|
+
onMouseEnter={isMarkers ? undefined : () => hover(s.id)}
|
|
260
|
+
onClick={isMarkers ? undefined : () => pick(s.id)}
|
|
261
|
+
/>
|
|
262
|
+
)
|
|
263
|
+
})}
|
|
264
|
+
{!isMarkers && cur && byId.get(cur.id) && <path d={byId.get(cur.id)!.d} fill="none" strokeWidth={1.5} className="pointer-events-none stroke-ink" />}
|
|
265
|
+
{isMarkers && [...markers!].sort((a, b) => b.value - a.value).map((m) => {
|
|
266
|
+
const g = markerGeo.get(m.id)!
|
|
267
|
+
const on = cur?.id === m.id
|
|
268
|
+
return (
|
|
269
|
+
<circle
|
|
270
|
+
key={m.id}
|
|
271
|
+
cx={g.x}
|
|
272
|
+
cy={g.y}
|
|
273
|
+
r={g.r}
|
|
274
|
+
fill="var(--color-c1)"
|
|
275
|
+
fillOpacity={on ? 0.85 : 0.5}
|
|
276
|
+
strokeWidth={on ? 1.5 : 1}
|
|
277
|
+
className={cn("cursor-pointer", on ? "stroke-ink" : "stroke-surface")}
|
|
278
|
+
onMouseEnter={() => hover(m.id)}
|
|
279
|
+
onClick={() => pick(m.id)}
|
|
280
|
+
/>
|
|
281
|
+
)
|
|
282
|
+
})}
|
|
283
|
+
</svg>
|
|
284
|
+
)}
|
|
285
|
+
{cur && curPos && (
|
|
286
|
+
<div
|
|
287
|
+
role={pinned ? "dialog" : "status"}
|
|
288
|
+
aria-label={pinned ? cur.label : undefined}
|
|
289
|
+
onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); clear(); svgRef.current?.focus() } }}
|
|
290
|
+
className={boxCls(pinned)}
|
|
291
|
+
style={narrow ? undefined : box(curPos)}
|
|
292
|
+
>
|
|
293
|
+
<span className="font-medium text-ink">{cur.label}</span>
|
|
294
|
+
<span className="flex justify-between gap-3 text-ink-2">{valueLabel}<span className="tabular font-medium text-ink">{format(cur.value)}</span></span>
|
|
295
|
+
{share && <span className="flex justify-between gap-3 text-ink-2">Share of total<span className="tabular font-medium text-ink">{pct(cur.value, total)}</span></span>}
|
|
296
|
+
<span className="flex justify-between gap-3 text-ink-2">Rank<span className="tabular font-medium text-ink">{items.indexOf(cur) + 1} of {items.length}</span></span>
|
|
297
|
+
{pinned && onSelect && (
|
|
298
|
+
<div className="mt-1 grid gap-2 border-t border-line pt-2">
|
|
299
|
+
<span className="text-xs text-ink-3">{filterField} is {cur.label}</span>
|
|
300
|
+
<Button size="xs" variant="outline" autoFocus onClick={() => { onSelect(cur); clear(); svgRef.current?.focus() }}>{selectLabel}</Button>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
{!cur && emptyShape && (
|
|
306
|
+
<div role="status" className={boxCls(false)} style={narrow ? undefined : box(emptyShape.at)}>
|
|
307
|
+
<span className="font-medium text-ink">{emptyShape.name}</span>
|
|
308
|
+
<span className="text-ink-2">No data</span>
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
{!isMarkers && (
|
|
313
|
+
<ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label={`Legend: ${valueLabel}`}>
|
|
314
|
+
{ranges.map((r, i) => (
|
|
315
|
+
<li key={r} className="inline-flex items-center gap-control text-sm text-ink-2">
|
|
316
|
+
<span aria-hidden="true" className={cn("size-2.5 shrink-0 rounded-xs", SWATCH[i])} />
|
|
317
|
+
<span className="tabular">{r}</span>
|
|
318
|
+
</li>
|
|
319
|
+
))}
|
|
320
|
+
{noData > 0 && (
|
|
321
|
+
<li className="inline-flex items-center gap-control text-sm text-ink-2">
|
|
322
|
+
<svg aria-hidden="true" width="10" height="10" className="shrink-0 overflow-hidden rounded-xs"><rect width="10" height="10" fill={`url(#${hatch})`} /></svg>
|
|
323
|
+
No data
|
|
324
|
+
</li>
|
|
325
|
+
)}
|
|
326
|
+
</ul>
|
|
327
|
+
)}
|
|
328
|
+
{isMarkers && max > 0 && (
|
|
329
|
+
<p className="text-sm text-ink-2">Circle area shows {valueLabel}. Largest: {navigable[0]?.label} {format(max)}.</p>
|
|
330
|
+
)}
|
|
331
|
+
{offMap.length > 0 && (
|
|
332
|
+
<p className="text-sm text-ink-3">Not on this map: {offMap.map((r) => r.label).join(", ")}. See the table view.</p>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
)
|
|
336
|
+
}
|