@keyfunc/keybricks-ds 1.0.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/DEPLOY.md +204 -0
- package/DESIGN.md +269 -0
- package/INTEGRATION.md +417 -0
- package/SKILL.md +32 -0
- package/_adherence.oxlintrc.json +1469 -0
- package/_scripts/build-index.mjs +80 -0
- package/_scripts/build-tokens.mjs +135 -0
- package/assets/icons/icon-index.js +1 -0
- package/assets/icons/icon-index.json +1 -0
- package/assets/icons/unicons-index.js +1 -0
- package/assets/icons/unicons-index.json +1 -0
- package/assets/keybricks-logo.png +0 -0
- package/components/_cards/accordion.card.html +32 -0
- package/components/_cards/action-chip.card.html +22 -0
- package/components/_cards/activity.card.html +34 -0
- package/components/_cards/alert.card.html +32 -0
- package/components/_cards/app-chrome.card.html +36 -0
- package/components/_cards/banner.card.html +21 -0
- package/components/_cards/brand-visual.card.html +43 -0
- package/components/_cards/breadcrumb.card.html +23 -0
- package/components/_cards/button-1-base.card.html +39 -0
- package/components/_cards/button-2-states.card.html +56 -0
- package/components/_cards/button-3-sizes-icons.card.html +32 -0
- package/components/_cards/button-4-group.card.html +47 -0
- package/components/_cards/canvas-zoom-controls.card.html +19 -0
- package/components/_cards/cards-indicators.card.html +59 -0
- package/components/_cards/charts-1-sparkline.card.html +55 -0
- package/components/_cards/charts-10-gauge.card.html +31 -0
- package/components/_cards/charts-11-funnel.card.html +28 -0
- package/components/_cards/charts-12-treemap.card.html +23 -0
- package/components/_cards/charts-14-radar.card.html +31 -0
- package/components/_cards/charts-15-distribution.card.html +32 -0
- package/components/_cards/charts-16-pareto.card.html +25 -0
- package/components/_cards/charts-17-candlestick.card.html +34 -0
- package/components/_cards/charts-18-sankey.card.html +29 -0
- package/components/_cards/charts-19-chord.card.html +31 -0
- package/components/_cards/charts-2-line.card.html +46 -0
- package/components/_cards/charts-20-sunburst.card.html +38 -0
- package/components/_cards/charts-21-worldmap.card.html +33 -0
- package/components/_cards/charts-22-gantt.card.html +37 -0
- package/components/_cards/charts-23-bar3d.card.html +26 -0
- package/components/_cards/charts-24-pie3d.card.html +32 -0
- package/components/_cards/charts-25-surface3d.card.html +24 -0
- package/components/_cards/charts-26-scatter3d.card.html +24 -0
- package/components/_cards/charts-27-ribbon3d.card.html +23 -0
- package/components/_cards/charts-3-bar.card.html +71 -0
- package/components/_cards/charts-4-barlist.card.html +30 -0
- package/components/_cards/charts-5-donut.card.html +35 -0
- package/components/_cards/charts-6-scatter.card.html +26 -0
- package/components/_cards/charts-7-heatmap.card.html +28 -0
- package/components/_cards/charts-8-waterfall.card.html +25 -0
- package/components/_cards/charts-9-bullet.card.html +31 -0
- package/components/_cards/charts-99-fullpage.card.html +194 -0
- package/components/_cards/checkbox.card.html +34 -0
- package/components/_cards/codeblock.card.html +19 -0
- package/components/_cards/combobox.card.html +25 -0
- package/components/_cards/context-menu.card.html +21 -0
- package/components/_cards/datatable.card.html +48 -0
- package/components/_cards/date-picker.card.html +31 -0
- package/components/_cards/diffvalue.card.html +21 -0
- package/components/_cards/divider.card.html +24 -0
- package/components/_cards/drawer.card.html +30 -0
- package/components/_cards/dropdown-menu.card.html +38 -0
- package/components/_cards/dropzone.card.html +22 -0
- package/components/_cards/empty-state.card.html +29 -0
- package/components/_cards/filter-bar.card.html +34 -0
- package/components/_cards/icon-button.card.html +27 -0
- package/components/_cards/icons-1-core.card.html +49 -0
- package/components/_cards/icons-2-feather.card.html +39 -0
- package/components/_cards/icons-3-unicons.card.html +44 -0
- package/components/_cards/input.card.html +25 -0
- package/components/_cards/kbd.card.html +20 -0
- package/components/_cards/keyvalue.card.html +23 -0
- package/components/_cards/listrow.card.html +30 -0
- package/components/_cards/loading.card.html +33 -0
- package/components/_cards/log-viewer.card.html +23 -0
- package/components/_cards/modal.card.html +35 -0
- package/components/_cards/multi-select.card.html +26 -0
- package/components/_cards/number-stepper.card.html +24 -0
- package/components/_cards/pagination.card.html +25 -0
- package/components/_cards/people.card.html +46 -0
- package/components/_cards/pipeline-node.card.html +76 -0
- package/components/_cards/pipeline-palette.card.html +54 -0
- package/components/_cards/search.card.html +23 -0
- package/components/_cards/select.card.html +23 -0
- package/components/_cards/slider.card.html +25 -0
- package/components/_cards/smart-table.card.html +37 -0
- package/components/_cards/spreadsheet.card.html +31 -0
- package/components/_cards/stepper.card.html +25 -0
- package/components/_cards/tabs.card.html +25 -0
- package/components/_cards/tag.card.html +26 -0
- package/components/_cards/textarea.card.html +27 -0
- package/components/_cards/timeline.card.html +32 -0
- package/components/_cards/toast.card.html +33 -0
- package/components/_cards/toggles.card.html +68 -0
- package/components/_cards/tooltip.card.html +22 -0
- package/components/_cards/tree.card.html +150 -0
- package/components/_shared/frame-theme.js +16 -0
- package/components/_shared/src-brand.js +1 -0
- package/components/_shared/src-buttons.js +1 -0
- package/components/_shared/src-charts.js +1 -0
- package/components/_shared/src-data.js +1 -0
- package/components/_shared/src-display.js +1 -0
- package/components/_shared/src-feedback.js +1 -0
- package/components/_shared/src-forms.js +1 -0
- package/components/_shared/src-icons.js +1 -0
- package/components/_shared/src-navigation.js +1 -0
- package/components/_shared/src-pipeline.js +1 -0
- package/components/_shared/src-tabs.js +1 -0
- package/components/_shared/svg-download.js +19 -0
- package/components/_shared/view-source.js +185 -0
- package/components/_vue/index.mjs +114 -0
- package/components/brand/BrandVisual.d.ts +17 -0
- package/components/brand/BrandVisual.jsx +29 -0
- package/components/brand/BrandVisual.vue +34 -0
- package/components/brand/_dir.card.html +13 -0
- package/components/brand/brandMotifs.js +210 -0
- package/components/buttons/ActionChip.d.ts +10 -0
- package/components/buttons/ActionChip.jsx +20 -0
- package/components/buttons/ActionChip.vue +18 -0
- package/components/buttons/Button.d.ts +40 -0
- package/components/buttons/Button.jsx +101 -0
- package/components/buttons/Button.vue +87 -0
- package/components/buttons/IconButton.d.ts +26 -0
- package/components/buttons/IconButton.jsx +28 -0
- package/components/buttons/IconButton.vue +21 -0
- package/components/buttons/_dir.card.html +13 -0
- package/components/buttons/buttons.prompt.md +19 -0
- package/components/charts/Bar3D.d.ts +15 -0
- package/components/charts/Bar3D.jsx +93 -0
- package/components/charts/Bar3D.vue +79 -0
- package/components/charts/BarChart.d.ts +23 -0
- package/components/charts/BarChart.jsx +135 -0
- package/components/charts/BarChart.vue +139 -0
- package/components/charts/BarList.d.ts +13 -0
- package/components/charts/BarList.jsx +31 -0
- package/components/charts/BarList.vue +25 -0
- package/components/charts/BoxPlot.d.ts +18 -0
- package/components/charts/BoxPlot.jsx +62 -0
- package/components/charts/BoxPlot.vue +58 -0
- package/components/charts/BulletChart.d.ts +12 -0
- package/components/charts/BulletChart.jsx +41 -0
- package/components/charts/BulletChart.vue +33 -0
- package/components/charts/CandlestickChart.d.ts +15 -0
- package/components/charts/CandlestickChart.jsx +63 -0
- package/components/charts/CandlestickChart.vue +62 -0
- package/components/charts/ChartKit.d.ts +8 -0
- package/components/charts/ChartKit.jsx +76 -0
- package/components/charts/ChartKit.vue +47 -0
- package/components/charts/ChordChart.d.ts +13 -0
- package/components/charts/ChordChart.jsx +55 -0
- package/components/charts/ChordChart.vue +54 -0
- package/components/charts/DonutChart.d.ts +15 -0
- package/components/charts/DonutChart.jsx +73 -0
- package/components/charts/DonutChart.vue +61 -0
- package/components/charts/FunnelChart.d.ts +11 -0
- package/components/charts/FunnelChart.jsx +37 -0
- package/components/charts/FunnelChart.vue +29 -0
- package/components/charts/GanttChart.d.ts +20 -0
- package/components/charts/GanttChart.jsx +80 -0
- package/components/charts/GanttChart.vue +60 -0
- package/components/charts/GaugeChart.d.ts +13 -0
- package/components/charts/GaugeChart.jsx +33 -0
- package/components/charts/GaugeChart.vue +29 -0
- package/components/charts/HeatmapChart.d.ts +13 -0
- package/components/charts/HeatmapChart.jsx +62 -0
- package/components/charts/HeatmapChart.vue +38 -0
- package/components/charts/Histogram.d.ts +16 -0
- package/components/charts/Histogram.jsx +56 -0
- package/components/charts/Histogram.vue +53 -0
- package/components/charts/LineChart.d.ts +17 -0
- package/components/charts/LineChart.jsx +84 -0
- package/components/charts/LineChart.vue +86 -0
- package/components/charts/ParetoChart.d.ts +13 -0
- package/components/charts/ParetoChart.jsx +54 -0
- package/components/charts/ParetoChart.vue +55 -0
- package/components/charts/Pie3D.d.ts +17 -0
- package/components/charts/Pie3D.jsx +145 -0
- package/components/charts/Pie3D.vue +115 -0
- package/components/charts/RadarChart.d.ts +14 -0
- package/components/charts/RadarChart.jsx +79 -0
- package/components/charts/RadarChart.vue +73 -0
- package/components/charts/Ribbon3D.d.ts +13 -0
- package/components/charts/Ribbon3D.jsx +89 -0
- package/components/charts/Ribbon3D.vue +79 -0
- package/components/charts/SankeyChart.d.ts +15 -0
- package/components/charts/SankeyChart.jsx +59 -0
- package/components/charts/SankeyChart.vue +53 -0
- package/components/charts/Scatter3D.d.ts +12 -0
- package/components/charts/Scatter3D.jsx +69 -0
- package/components/charts/Scatter3D.vue +59 -0
- package/components/charts/ScatterChart.d.ts +12 -0
- package/components/charts/ScatterChart.jsx +53 -0
- package/components/charts/ScatterChart.vue +57 -0
- package/components/charts/Sparkline.d.ts +19 -0
- package/components/charts/Sparkline.jsx +63 -0
- package/components/charts/Sparkline.vue +54 -0
- package/components/charts/SunburstChart.d.ts +12 -0
- package/components/charts/SunburstChart.jsx +61 -0
- package/components/charts/SunburstChart.vue +60 -0
- package/components/charts/Surface3D.d.ts +13 -0
- package/components/charts/Surface3D.jsx +70 -0
- package/components/charts/Surface3D.vue +63 -0
- package/components/charts/TreemapChart.d.ts +11 -0
- package/components/charts/TreemapChart.jsx +35 -0
- package/components/charts/TreemapChart.vue +27 -0
- package/components/charts/WaterfallChart.d.ts +11 -0
- package/components/charts/WaterfallChart.jsx +59 -0
- package/components/charts/WaterfallChart.vue +46 -0
- package/components/charts/WorldMap.d.ts +21 -0
- package/components/charts/WorldMap.jsx +107 -0
- package/components/charts/WorldMap.vue +73 -0
- package/components/charts/_dir.card.html +7 -0
- package/components/charts/chart-3d.js +84 -0
- package/components/charts/chart-geometry.js +223 -0
- package/components/charts/charts.prompt.md +24 -0
- package/components/data/CodeBlock.d.ts +9 -0
- package/components/data/CodeBlock.jsx +19 -0
- package/components/data/CodeBlock.vue +22 -0
- package/components/data/DataTable.d.ts +28 -0
- package/components/data/DataTable.jsx +32 -0
- package/components/data/DataTable.vue +25 -0
- package/components/data/DiffValue.d.ts +11 -0
- package/components/data/DiffValue.jsx +15 -0
- package/components/data/DiffValue.vue +13 -0
- package/components/data/FilterBar.d.ts +16 -0
- package/components/data/FilterBar.jsx +22 -0
- package/components/data/FilterBar.vue +23 -0
- package/components/data/KeyValue.d.ts +16 -0
- package/components/data/KeyValue.jsx +25 -0
- package/components/data/KeyValue.vue +26 -0
- package/components/data/ListRow.d.ts +14 -0
- package/components/data/ListRow.jsx +25 -0
- package/components/data/ListRow.vue +25 -0
- package/components/data/Pagination.d.ts +12 -0
- package/components/data/Pagination.jsx +27 -0
- package/components/data/Pagination.vue +24 -0
- package/components/data/SmartTable.d.ts +30 -0
- package/components/data/SmartTable.jsx +73 -0
- package/components/data/SmartTable.vue +74 -0
- package/components/data/Spreadsheet.d.ts +33 -0
- package/components/data/Spreadsheet.jsx +110 -0
- package/components/data/Spreadsheet.vue +95 -0
- package/components/data/_dir.card.html +13 -0
- package/components/data/data.prompt.md +33 -0
- package/components/display/Avatar.d.ts +21 -0
- package/components/display/Avatar.jsx +31 -0
- package/components/display/Avatar.vue +34 -0
- package/components/display/Badge.d.ts +27 -0
- package/components/display/Badge.jsx +40 -0
- package/components/display/Badge.vue +47 -0
- package/components/display/Card.d.ts +27 -0
- package/components/display/Card.jsx +27 -0
- package/components/display/Card.vue +28 -0
- package/components/display/Divider.d.ts +8 -0
- package/components/display/Divider.jsx +14 -0
- package/components/display/Divider.vue +13 -0
- package/components/display/Kbd.d.ts +7 -0
- package/components/display/Kbd.jsx +12 -0
- package/components/display/Kbd.vue +9 -0
- package/components/display/ProgressBar.d.ts +42 -0
- package/components/display/ProgressBar.jsx +70 -0
- package/components/display/ProgressBar.vue +69 -0
- package/components/display/Tag.d.ts +9 -0
- package/components/display/Tag.jsx +15 -0
- package/components/display/Tag.vue +15 -0
- package/components/display/_dir.card.html +13 -0
- package/components/display/display.prompt.md +30 -0
- package/components/feedback/Accordion.d.ts +13 -0
- package/components/feedback/Accordion.jsx +21 -0
- package/components/feedback/Accordion.vue +22 -0
- package/components/feedback/ActivityItem.d.ts +25 -0
- package/components/feedback/ActivityItem.jsx +39 -0
- package/components/feedback/ActivityItem.vue +46 -0
- package/components/feedback/Alert.d.ts +36 -0
- package/components/feedback/Alert.jsx +48 -0
- package/components/feedback/Alert.vue +59 -0
- package/components/feedback/Banner.d.ts +15 -0
- package/components/feedback/Banner.jsx +25 -0
- package/components/feedback/Banner.vue +25 -0
- package/components/feedback/ContextMenu.d.ts +8 -0
- package/components/feedback/ContextMenu.jsx +18 -0
- package/components/feedback/ContextMenu.vue +19 -0
- package/components/feedback/Drawer.d.ts +18 -0
- package/components/feedback/Drawer.jsx +26 -0
- package/components/feedback/Drawer.vue +24 -0
- package/components/feedback/Menu.d.ts +10 -0
- package/components/feedback/Menu.jsx +24 -0
- package/components/feedback/Menu.vue +21 -0
- package/components/feedback/Modal.d.ts +38 -0
- package/components/feedback/Modal.jsx +69 -0
- package/components/feedback/Modal.vue +65 -0
- package/components/feedback/Stepper.d.ts +9 -0
- package/components/feedback/Stepper.jsx +24 -0
- package/components/feedback/Stepper.vue +18 -0
- package/components/feedback/Timeline.d.ts +15 -0
- package/components/feedback/Timeline.jsx +27 -0
- package/components/feedback/Timeline.vue +21 -0
- package/components/feedback/_dir.card.html +13 -0
- package/components/feedback/feedback.prompt.md +35 -0
- package/components/forms/Checkbox.d.ts +13 -0
- package/components/forms/Checkbox.jsx +17 -0
- package/components/forms/Checkbox.vue +18 -0
- package/components/forms/Combobox.d.ts +13 -0
- package/components/forms/Combobox.jsx +34 -0
- package/components/forms/Combobox.vue +35 -0
- package/components/forms/DatePicker.d.ts +12 -0
- package/components/forms/DatePicker.jsx +48 -0
- package/components/forms/DatePicker.vue +46 -0
- package/components/forms/Dropzone.d.ts +20 -0
- package/components/forms/Dropzone.jsx +36 -0
- package/components/forms/Dropzone.vue +28 -0
- package/components/forms/Field.d.ts +13 -0
- package/components/forms/Field.jsx +13 -0
- package/components/forms/Field.vue +12 -0
- package/components/forms/Input.d.ts +18 -0
- package/components/forms/Input.jsx +35 -0
- package/components/forms/Input.vue +38 -0
- package/components/forms/MultiSelect.d.ts +14 -0
- package/components/forms/MultiSelect.jsx +33 -0
- package/components/forms/MultiSelect.vue +36 -0
- package/components/forms/NumberStepper.d.ts +13 -0
- package/components/forms/NumberStepper.jsx +15 -0
- package/components/forms/NumberStepper.vue +17 -0
- package/components/forms/Radio.vue +16 -0
- package/components/forms/RadioGroup.vue +11 -0
- package/components/forms/SearchInput.d.ts +12 -0
- package/components/forms/SearchInput.jsx +17 -0
- package/components/forms/SearchInput.vue +14 -0
- package/components/forms/Select.d.ts +14 -0
- package/components/forms/Select.jsx +17 -0
- package/components/forms/Select.vue +16 -0
- package/components/forms/Slider.d.ts +15 -0
- package/components/forms/Slider.jsx +21 -0
- package/components/forms/Slider.vue +23 -0
- package/components/forms/Switch.d.ts +31 -0
- package/components/forms/Switch.jsx +40 -0
- package/components/forms/Switch.vue +16 -0
- package/components/forms/_dir.card.html +13 -0
- package/components/forms/forms.prompt.md +23 -0
- package/components/icons/Icon.d.ts +19 -0
- package/components/icons/Icon.jsx +93 -0
- package/components/icons/Icon.prompt.md +8 -0
- package/components/icons/Icon.vue +12 -0
- package/components/icons/Unicon.d.ts +12 -0
- package/components/icons/Unicon.jsx +16 -0
- package/components/icons/Unicon.vue +15 -0
- package/components/icons/_dir.card.html +13 -0
- package/components/icons/feather-paths.js +2 -0
- package/components/icons/icon-paths.js +73 -0
- package/components/index.d.ts +87 -0
- package/components/index.js +110 -0
- package/components/navigation/Breadcrumb.d.ts +24 -0
- package/components/navigation/Breadcrumb.jsx +120 -0
- package/components/navigation/Breadcrumb.vue +94 -0
- package/components/navigation/DocTree.d.ts +18 -0
- package/components/navigation/DocTree.jsx +37 -0
- package/components/navigation/DocTree.vue +25 -0
- package/components/navigation/Sidebar.d.ts +29 -0
- package/components/navigation/Sidebar.jsx +75 -0
- package/components/navigation/Sidebar.vue +54 -0
- package/components/navigation/TopBar.d.ts +27 -0
- package/components/navigation/TopBar.jsx +44 -0
- package/components/navigation/TopBar.vue +51 -0
- package/components/navigation/Tree.d.ts +83 -0
- package/components/navigation/Tree.jsx +347 -0
- package/components/navigation/Tree.vue +258 -0
- package/components/navigation/_dir.card.html +13 -0
- package/components/navigation/navigation.prompt.md +22 -0
- package/components/pipeline/ConfigPanel.d.ts +18 -0
- package/components/pipeline/ConfigPanel.jsx +30 -0
- package/components/pipeline/ConfigPanel.vue +32 -0
- package/components/pipeline/DataPreview.d.ts +14 -0
- package/components/pipeline/DataPreview.jsx +24 -0
- package/components/pipeline/DataPreview.vue +21 -0
- package/components/pipeline/NodeCard.d.ts +47 -0
- package/components/pipeline/NodeCard.jsx +97 -0
- package/components/pipeline/NodeCard.vue +75 -0
- package/components/pipeline/PaletteItem.vue +15 -0
- package/components/pipeline/PaletteListItem.vue +15 -0
- package/components/pipeline/PalettePanel.d.ts +20 -0
- package/components/pipeline/PalettePanel.jsx +74 -0
- package/components/pipeline/PalettePanel.vue +45 -0
- package/components/pipeline/PipelineCanvas.d.ts +25 -0
- package/components/pipeline/PipelineCanvas.jsx +94 -0
- package/components/pipeline/PipelineCanvas.vue +73 -0
- package/components/pipeline/ZoomControls.d.ts +9 -0
- package/components/pipeline/ZoomControls.jsx +15 -0
- package/components/pipeline/ZoomControls.vue +15 -0
- package/components/pipeline/_dir.card.html +13 -0
- package/components/pipeline/pipeline.prompt.md +22 -0
- package/components/tabs/SegmentedControl.d.ts +11 -0
- package/components/tabs/SegmentedControl.jsx +21 -0
- package/components/tabs/SegmentedControl.vue +15 -0
- package/components/tabs/Tabs.d.ts +13 -0
- package/components/tabs/Tabs.jsx +52 -0
- package/components/tabs/Tabs.vue +39 -0
- package/components/tabs/ViewToggle.d.ts +8 -0
- package/components/tabs/ViewToggle.jsx +12 -0
- package/components/tabs/ViewToggle.vue +12 -0
- package/components/tabs/_dir.card.html +13 -0
- package/components/tabs/tabs.prompt.md +14 -0
- package/package.json +76 -0
- package/readme.md +150 -0
- package/styles.css +10 -0
- package/tokens/alpha.css +27 -0
- package/tokens/charts.css +55 -0
- package/tokens/colors-extended.css +40 -0
- package/tokens/colors.css +39 -0
- package/tokens/dark.css +96 -0
- package/tokens/fonts.css +4 -0
- package/tokens/interaction.css +48 -0
- package/tokens/layout.css +30 -0
- package/tokens/motion.css +22 -0
- package/tokens/tokens.d.ts +680 -0
- package/tokens/tokens.js +1018 -0
- package/tokens/tokens.json +2928 -0
- package/tokens/typography.css +22 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fmtNum, niceTicks, scaleLin } from "./chart-geometry.js";
|
|
3
|
+
import { ChartTooltip, PlotGrid, wrapStyle } from "./ChartKit.jsx";
|
|
4
|
+
|
|
5
|
+
const bucketize = (values, bins) => {
|
|
6
|
+
const lo = Math.min(...values), hi = Math.max(...values);
|
|
7
|
+
const w = (hi - lo) / bins || 1;
|
|
8
|
+
const out = [...Array(bins)].map((_, i) => ({ from: lo + i * w, to: lo + (i + 1) * w, n: 0 }));
|
|
9
|
+
values.forEach((v) => { out[Math.max(0, Math.min(bins - 1, Math.floor((v - lo) / w)))].n++; });
|
|
10
|
+
return out;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Histogram — the distribution of one measure. Pass raw `values` (auto-bucketed into
|
|
15
|
+
* `bins`) or pre-computed `buckets: [{from, to, n}]`. mean/median guides are optional.
|
|
16
|
+
*/
|
|
17
|
+
export function Histogram({ values = [], buckets, bins = 12, width = 560, height = 240, axes = true, showMean = false, valueFormat = fmtNum, countFormat = fmtNum, interactive = true, color = "var(--series-1)", style }) {
|
|
18
|
+
const [hover, setHover] = React.useState(null);
|
|
19
|
+
const bkt = buckets || bucketize(values, bins);
|
|
20
|
+
const pad = { l: axes ? 40 : 4, r: 8, t: showMean ? 22 : 10, b: axes ? 26 : 4 };
|
|
21
|
+
const { ticks, lo, hi } = niceTicks(0, Math.max(1, ...bkt.map((b) => b.n)), 4);
|
|
22
|
+
const sy = scaleLin(lo, hi, height - pad.b, pad.t);
|
|
23
|
+
const slot = (width - pad.l - pad.r) / bkt.length;
|
|
24
|
+
const mean = showMean && values.length ? values.reduce((s, v) => s + v, 0) / values.length : null;
|
|
25
|
+
const sxVal = scaleLin(bkt[0].from, bkt[bkt.length - 1].to, pad.l, width - pad.r);
|
|
26
|
+
const move = (e) => {
|
|
27
|
+
if (!interactive) return;
|
|
28
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
29
|
+
const i = Math.floor((e.clientX - r.left - pad.l) / slot);
|
|
30
|
+
setHover(i >= 0 && i < bkt.length ? i : null);
|
|
31
|
+
};
|
|
32
|
+
const total = bkt.reduce((s, b) => s + b.n, 0) || 1;
|
|
33
|
+
return (
|
|
34
|
+
<div style={{ ...wrapStyle, ...style }}>
|
|
35
|
+
<svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role="img">
|
|
36
|
+
{axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={[]} sxLabel={() => 0} yFormat={countFormat} /> : null}
|
|
37
|
+
{bkt.map((b, i) => (
|
|
38
|
+
<rect key={i} x={pad.l + slot * i + 0.5} y={sy(b.n)} width={Math.max(1, slot - 1.5)} height={Math.max(1, sy(0) - sy(b.n))} rx="2"
|
|
39
|
+
fill={color} opacity={hover == null ? 0.9 : hover === i ? 1 : 0.4} style={{ transition: "opacity var(--dur-fast) var(--ease-out)" }} />
|
|
40
|
+
))}
|
|
41
|
+
{mean != null ? <g>
|
|
42
|
+
<line x1={sxVal(mean)} x2={sxVal(mean)} y1={pad.t} y2={height - pad.b} stroke="var(--chart-guide)" strokeWidth="1" strokeDasharray="3 3" />
|
|
43
|
+
<text x={Math.min(sxVal(mean) + 4, width - 60)} y={pad.t - 8} style={{ fontFamily: "var(--font-mono)", fontSize: 10, fill: "var(--chart-label)" }}>mean {valueFormat(mean)}</text>
|
|
44
|
+
</g> : null}
|
|
45
|
+
{axes ? [0, Math.floor(bkt.length / 2), bkt.length - 1].map((i) => (
|
|
46
|
+
<text key={i} x={pad.l + slot * i + slot / 2} y={height - pad.b + 16} textAnchor="middle" style={{ fontFamily: "var(--font-mono)", fontSize: 10, fill: "var(--chart-label)" }}>{valueFormat(bkt[i].from)}</text>
|
|
47
|
+
)) : null}
|
|
48
|
+
</svg>
|
|
49
|
+
{hover != null && interactive ? (
|
|
50
|
+
<ChartTooltip x={pad.l + slot * hover + slot / 2} y={height / 2 - 10} flip={pad.l + slot * hover > width * 0.62}
|
|
51
|
+
title={valueFormat(bkt[hover].from) + " – " + valueFormat(bkt[hover].to)}
|
|
52
|
+
rows={[{ label: "count", value: countFormat(bkt[hover].n), color }, { label: "share", value: ((bkt[hover].n / total) * 100).toFixed(1) + "%" }]} />
|
|
53
|
+
) : null}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { fmtNum, niceTicks, scaleLin } from './chart-geometry.js'
|
|
4
|
+
// Distribution of one measure — raw values are bucketed for you.
|
|
5
|
+
const props = defineProps({ values:{type:Array,default:()=>[]}, buckets:Array, bins:{type:Number,default:12}, width:{type:Number,default:560}, height:{type:Number,default:240}, axes:{type:Boolean,default:true}, showMean:Boolean, valueFormat:{type:Function,default:fmtNum}, countFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, color:{type:String,default:'var(--series-1)'}, style:Object })
|
|
6
|
+
const hover = ref(null)
|
|
7
|
+
const g = computed(() => {
|
|
8
|
+
let bkt = props.buckets
|
|
9
|
+
if (!bkt) {
|
|
10
|
+
const lo = Math.min(...props.values), hi = Math.max(...props.values)
|
|
11
|
+
const w = (hi-lo)/props.bins || 1
|
|
12
|
+
bkt = [...Array(props.bins)].map((_,i)=>({ from: lo+i*w, to: lo+(i+1)*w, n: 0 }))
|
|
13
|
+
props.values.forEach(v=>{ bkt[Math.max(0,Math.min(props.bins-1,Math.floor((v-lo)/w)))].n++ })
|
|
14
|
+
}
|
|
15
|
+
const pad = { l: props.axes?40:4, r:8, t: props.showMean?22:10, b: props.axes?26:4 }
|
|
16
|
+
const { ticks, lo, hi } = niceTicks(0, Math.max(1,...bkt.map(b=>b.n)), 4)
|
|
17
|
+
const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)
|
|
18
|
+
const slot = (props.width-pad.l-pad.r)/bkt.length
|
|
19
|
+
const mean = props.showMean && props.values.length ? props.values.reduce((s,v)=>s+v,0)/props.values.length : null
|
|
20
|
+
const sxVal = scaleLin(bkt[0].from, bkt[bkt.length-1].to, pad.l, props.width-pad.r)
|
|
21
|
+
return { bkt, pad, ticks, sy, slot, mean, meanX: mean!=null ? Math.min(sxVal(mean)+4, props.width-60) : 0, meanLine: mean!=null ? sxVal(mean) : 0,
|
|
22
|
+
total: bkt.reduce((s,b)=>s+b.n,0)||1, labels:[0, Math.floor(bkt.length/2), bkt.length-1] }
|
|
23
|
+
})
|
|
24
|
+
function move(e){
|
|
25
|
+
if(!props.interactive) return
|
|
26
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
27
|
+
const i = Math.floor((e.clientX-r.left-g.value.pad.l)/g.value.slot)
|
|
28
|
+
hover.value = i>=0 && i<g.value.bkt.length ? i : null
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
<template>
|
|
32
|
+
<div :style="{position:'relative',display:'inline-block',lineHeight:0,...style}">
|
|
33
|
+
<svg :width="width" :height="height" @mousemove="move" @mouseleave="hover=null" role="img">
|
|
34
|
+
<template v-if="axes">
|
|
35
|
+
<g v-for="t in g.ticks" :key="t">
|
|
36
|
+
<line :x1="g.pad.l" :x2="width-g.pad.r" :y1="g.sy(t)" :y2="g.sy(t)" :stroke="t===0?'var(--chart-axis)':'var(--chart-grid)'" stroke-width="1"/>
|
|
37
|
+
<text :x="g.pad.l-8" :y="g.sy(t)+3" text-anchor="end" style="font-family:var(--font-mono);font-size:10px;fill:var(--chart-label)">{{ countFormat(t) }}</text>
|
|
38
|
+
</g>
|
|
39
|
+
</template>
|
|
40
|
+
<rect v-for="(b,i) in g.bkt" :key="i" :x="g.pad.l+g.slot*i+0.5" :y="g.sy(b.n)" :width="Math.max(1,g.slot-1.5)" :height="Math.max(1,g.sy(0)-g.sy(b.n))" rx="2"
|
|
41
|
+
:fill="color" :opacity="hover==null?0.9:(hover===i?1:0.4)" style="transition:opacity var(--dur-fast) var(--ease-out)"/>
|
|
42
|
+
<template v-if="g.mean!=null">
|
|
43
|
+
<line :x1="g.meanLine" :x2="g.meanLine" :y1="g.pad.t" :y2="height-g.pad.b" stroke="var(--chart-guide)" stroke-width="1" stroke-dasharray="3 3"/>
|
|
44
|
+
<text :x="g.meanX" :y="g.pad.t-8" style="font-family:var(--font-mono);font-size:10px;fill:var(--chart-label)">mean {{ valueFormat(g.mean) }}</text>
|
|
45
|
+
</template>
|
|
46
|
+
<text v-if="axes" v-for="i in g.labels" :key="'l'+i" :x="g.pad.l+g.slot*i+g.slot/2" :y="height-g.pad.b+16" text-anchor="middle" style="font-family:var(--font-mono);font-size:10px;fill:var(--chart-label)">{{ valueFormat(g.bkt[i].from) }}</text>
|
|
47
|
+
</svg>
|
|
48
|
+
<div v-if="hover!=null && interactive" :style="{position:'absolute',left:(g.pad.l+g.slot*hover+g.slot/2)+'px',top:(height/2-10)+'px',transform:(g.pad.l+g.slot*hover)>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}">
|
|
49
|
+
<div style="font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px">{{ valueFormat(g.bkt[hover].from) }} – {{ valueFormat(g.bkt[hover].to) }}</div>
|
|
50
|
+
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-heading)">{{ countFormat(g.bkt[hover].n) }} · {{ ((g.bkt[hover].n/g.total)*100).toFixed(1) }}%</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface LineSeries { name: string; values: number[]; color?: string; }
|
|
3
|
+
/** Line / area / stacked-area over shared x labels. Hover guideline + tooltip; legend click mutes a series. */
|
|
4
|
+
export interface LineChartProps {
|
|
5
|
+
series?: LineSeries[];
|
|
6
|
+
xLabels?: string[];
|
|
7
|
+
width?: number; height?: number;
|
|
8
|
+
/** Fill under each line */ area?: boolean;
|
|
9
|
+
/** Stack series (implies area). Bands fill to the baseline so the gradients overlap. */ stacked?: boolean;
|
|
10
|
+
/** Line shape: straight, monotone-cubic smooth, or stepped */ curve?: "linear" | "smooth" | "step";
|
|
11
|
+
/** false = sm tile tier: no grid, no tick labels */ axes?: boolean;
|
|
12
|
+
legend?: boolean; interactive?: boolean;
|
|
13
|
+
yFormat?: (v: number) => string;
|
|
14
|
+
/** Dashed reference line */ target?: number; targetLabel?: string;
|
|
15
|
+
style?: React.CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
export declare function LineChart(props: LineChartProps): JSX.Element;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fmtNum, extent, scaleLin, shapePath, closeToBaseline, stack, niceTicks, seriesColor } from "./chart-geometry.js";
|
|
3
|
+
import { ChartLegend, ChartTooltip, PlotGrid, thinLabels, wrapStyle } from "./ChartKit.jsx";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Line / area / stacked-area chart. series: [{name, values, color?}] over shared xLabels.
|
|
7
|
+
* curve: "linear" (default) | "smooth" (monotone cubic, never overshoots a real value) | "step".
|
|
8
|
+
* Interactive by default at every size: hover guideline + tooltip, click legend to mute a series.
|
|
9
|
+
* sm tier: axes={false} legend={false} for a tile-sized trend — the tooltip stays.
|
|
10
|
+
*/
|
|
11
|
+
export function LineChart({ series = [], xLabels = [], width = 560, height = 240, area = false, stacked = false, curve = "linear", axes = true, legend = true, interactive = true, yFormat = fmtNum, target, targetLabel, style }) {
|
|
12
|
+
const [hidden, setHidden] = React.useState([]);
|
|
13
|
+
const [hover, setHover] = React.useState(null);
|
|
14
|
+
const shown = series.filter((s) => !hidden.includes(s.name));
|
|
15
|
+
const pad = { l: axes ? 44 : 4, r: 8, t: 10, b: axes ? 24 : 4 };
|
|
16
|
+
const vals = shown.map((s) => s.values);
|
|
17
|
+
const bands = stacked && shown.length ? stack(vals) : null;
|
|
18
|
+
const [lo0, hi0] = stacked ? [0, Math.max(1, ...(bands[bands.length - 1] || []).map((b) => b[1]))] : extent(vals.concat(target != null ? [[target]] : []));
|
|
19
|
+
const { ticks, lo, hi } = niceTicks(lo0, hi0, 4);
|
|
20
|
+
const n = Math.max(...series.map((s) => s.values.length), 2);
|
|
21
|
+
const sx = scaleLin(0, n - 1, pad.l + 4, width - pad.r - 4);
|
|
22
|
+
const sy = scaleLin(lo, hi, height - pad.b, pad.t);
|
|
23
|
+
const colorOf = (s) => s.color || seriesColor(series.indexOf(s));
|
|
24
|
+
const base = sy(Math.max(lo, 0));
|
|
25
|
+
const move = (e) => {
|
|
26
|
+
if (!interactive) return;
|
|
27
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
28
|
+
const x = e.clientX - r.left;
|
|
29
|
+
setHover(Math.max(0, Math.min(n - 1, Math.round(((x - pad.l - 4) / (width - pad.l - pad.r - 8)) * (n - 1)))));
|
|
30
|
+
};
|
|
31
|
+
const gid = React.useId ? React.useId().replace(/:/g, "") : "kbline";
|
|
32
|
+
/* Stacked bands are closed against the band below, not the baseline: overlapping
|
|
33
|
+
translucent fills stack up into mud. Each band gets its own gradient, scaled to that
|
|
34
|
+
band's own box (objectBoundingBox), so the fade reads the same at any thickness. */
|
|
35
|
+
const layers = shown.map((s, si) => {
|
|
36
|
+
const pts = s.values.map((v, i) => [sx(i), sy(stacked ? bands[si][i][1] : v)]);
|
|
37
|
+
const d = shapePath(pts, curve);
|
|
38
|
+
const under = stacked
|
|
39
|
+
? [...s.values.keys()].reverse().map((i) => sx(i).toFixed(1) + " " + sy(bands[si][i][0]).toFixed(1)).join(" L")
|
|
40
|
+
: null;
|
|
41
|
+
return { s, si, pts, d, color: colorOf(s), fill: stacked ? d + " L" + under + " Z" : closeToBaseline(d, pts, base) };
|
|
42
|
+
});
|
|
43
|
+
const painted = layers;
|
|
44
|
+
return (
|
|
45
|
+
<div style={{ ...wrapStyle, ...style }}>
|
|
46
|
+
<svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role="img">
|
|
47
|
+
<defs>
|
|
48
|
+
{series.map((s, i) => {
|
|
49
|
+
const c = s.color || seriesColor(i);
|
|
50
|
+
return (
|
|
51
|
+
<linearGradient key={i} id={gid + "-" + i} x1="0" y1="0" x2="0" y2="1">
|
|
52
|
+
<stop offset="0%" stopColor={c} stopOpacity={stacked ? "0.30" : "0.34"} />
|
|
53
|
+
<stop offset="100%" stopColor={c} stopOpacity={stacked ? "0.10" : "0.02"} />
|
|
54
|
+
</linearGradient>
|
|
55
|
+
);
|
|
56
|
+
})}
|
|
57
|
+
</defs>
|
|
58
|
+
{axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={thinLabels(xLabels)} sxLabel={sx} yFormat={yFormat} /> : null}
|
|
59
|
+
{target != null ? <g>
|
|
60
|
+
<line x1={pad.l} x2={width - pad.r} y1={sy(target)} y2={sy(target)} stroke="var(--chart-guide)" strokeWidth="1" strokeDasharray="3 3" />
|
|
61
|
+
<text x={width - pad.r} y={sy(target) - 4} textAnchor="end" style={{ fontFamily: "var(--font-sans)", fontSize: 10, fill: "var(--chart-label)" }}>{targetLabel || "Target " + yFormat(target)}</text>
|
|
62
|
+
</g> : null}
|
|
63
|
+
{painted.map((L) => (
|
|
64
|
+
<g key={L.s.name}>
|
|
65
|
+
{(area || stacked) ? <path d={L.fill} fill={"url(#" + gid + "-" + series.indexOf(L.s) + ")"} /> : null}
|
|
66
|
+
<path d={L.d} fill="none" stroke={L.color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
|
67
|
+
</g>
|
|
68
|
+
))}
|
|
69
|
+
{hover != null && interactive ? <line x1={sx(hover)} x2={sx(hover)} y1={pad.t} y2={height - pad.b} stroke="var(--chart-guide)" strokeWidth="1" strokeDasharray="2 3" /> : null}
|
|
70
|
+
{hover != null ? layers.map((L) => (L.s.values[hover] == null ? null : (
|
|
71
|
+
<circle key={L.s.name} cx={sx(hover)} cy={sy(stacked ? bands[L.si][hover][1] : L.s.values[hover])} r="3" fill={L.color} stroke="var(--bg-surface)" strokeWidth="1.5" />
|
|
72
|
+
))) : null}
|
|
73
|
+
</svg>
|
|
74
|
+
{hover != null && interactive ? (
|
|
75
|
+
<ChartTooltip x={sx(hover)} y={height / 2 - 10} flip={sx(hover) > width * 0.62} title={xLabels[hover]}
|
|
76
|
+
rows={shown.map((s) => ({ label: s.name, value: yFormat(s.values[hover]), color: colorOf(s) }))} />
|
|
77
|
+
) : null}
|
|
78
|
+
{legend && series.length > 1 ? (
|
|
79
|
+
<ChartLegend style={{ paddingTop: 8 }} items={series.map((s) => ({ label: s.name, color: colorOf(s) }))} hidden={hidden}
|
|
80
|
+
onToggle={(l) => setHidden(hidden.includes(l) ? hidden.filter((x) => x !== l) : hidden.length + 1 < series.length ? [...hidden, l] : hidden)} />
|
|
81
|
+
) : null}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { fmtNum, extent, scaleLin, shapePath, closeToBaseline, stack, niceTicks, SERIES } from './chart-geometry.js'
|
|
4
|
+
// Line / area / stacked-area over shared x labels. Hover guideline + tooltip; legend click mutes.
|
|
5
|
+
const props = defineProps({ series:{type:Array,default:()=>[]}, xLabels:{type:Array,default:()=>[]}, width:{type:Number,default:560}, height:{type:Number,default:240}, area:Boolean, stacked:Boolean, axes:{type:Boolean,default:true}, legend:{type:Boolean,default:true}, interactive:{type:Boolean,default:true}, yFormat:{type:Function,default:fmtNum}, target:Number, targetLabel:String, style:Object })
|
|
6
|
+
const hidden = ref([]); const hover = ref(null)
|
|
7
|
+
const uid = 'kbline-' + Math.random().toString(36).slice(2, 8)
|
|
8
|
+
const g = computed(() => {
|
|
9
|
+
const shown = props.series.filter(s=>!hidden.value.includes(s.name))
|
|
10
|
+
const pad = { l: props.axes?44:4, r:8, t:10, b: props.axes?24:4 }
|
|
11
|
+
const vals = shown.map(s=>s.values)
|
|
12
|
+
const bands = props.stacked && shown.length ? stack(vals) : null
|
|
13
|
+
const [lo0,hi0] = props.stacked ? [0, Math.max(1,...(bands[bands.length-1]||[]).map(b=>b[1]))] : extent(vals.concat(props.target!=null?[[props.target]]:[]))
|
|
14
|
+
const { ticks, lo, hi } = niceTicks(lo0, hi0, 4)
|
|
15
|
+
const n = Math.max(...props.series.map(s=>s.values.length), 2)
|
|
16
|
+
const sx = scaleLin(0, n-1, pad.l+4, props.width-pad.r-4)
|
|
17
|
+
const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)
|
|
18
|
+
const colorOf = s => s.color || SERIES[props.series.indexOf(s)%SERIES.length]
|
|
19
|
+
/* Stacked bands close against the band below, not the baseline — overlapping
|
|
20
|
+
translucent fills stack up into mud. */
|
|
21
|
+
const base = sy(Math.max(lo, 0))
|
|
22
|
+
const lines = shown.map((s,si)=>{
|
|
23
|
+
const pts = s.values.map((v,i)=>[sx(i), sy(props.stacked?bands[si][i][1]:v)])
|
|
24
|
+
const d = shapePath(pts, props.curve)
|
|
25
|
+
const fill = props.stacked
|
|
26
|
+
? d + ' L' + [...s.values.keys()].reverse().map(i=>sx(i).toFixed(1)+' '+sy(bands[si][i][0]).toFixed(1)).join(' L') + ' Z'
|
|
27
|
+
: closeToBaseline(d, pts, base)
|
|
28
|
+
return { s, color: colorOf(s), path: d, fill, pts, at:(i)=>[sx(i), sy(props.stacked?bands[si][i][1]:s.values[i])] }
|
|
29
|
+
})
|
|
30
|
+
return { pad, ticks, sx, sy, n, lines, shown, colorOf }
|
|
31
|
+
})
|
|
32
|
+
function move(e){
|
|
33
|
+
if (!props.interactive) return
|
|
34
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
35
|
+
const gg = g.value
|
|
36
|
+
hover.value = Math.max(0, Math.min(gg.n-1, Math.round(((e.clientX-r.left-gg.pad.l-4)/(props.width-gg.pad.l-gg.pad.r-8))*(gg.n-1))))
|
|
37
|
+
}
|
|
38
|
+
function toggle(name){
|
|
39
|
+
if (hidden.value.includes(name)) hidden.value = hidden.value.filter(x=>x!==name)
|
|
40
|
+
else if (hidden.value.length+1 < props.series.length) hidden.value = [...hidden.value, name]
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
<template>
|
|
44
|
+
<div :style="{position:'relative',display:'inline-block',lineHeight:0,...style}">
|
|
45
|
+
<svg :width="width" :height="height" @mousemove="move" @mouseleave="hover=null" role="img">
|
|
46
|
+
<defs>
|
|
47
|
+
<linearGradient v-for="(s,i) in series" :key="'grad'+i" :id="uid+'-'+i" x1="0" y1="0" x2="0" y2="1">
|
|
48
|
+
<stop offset="0%" :stop-color="s.color||SERIES[i%SERIES.length]" :stop-opacity="stacked ? 0.30 : 0.34"/>
|
|
49
|
+
<stop offset="100%" :stop-color="s.color||SERIES[i%SERIES.length]" :stop-opacity="stacked ? 0.10 : 0.02"/>
|
|
50
|
+
</linearGradient>
|
|
51
|
+
</defs>
|
|
52
|
+
<template v-if="axes">
|
|
53
|
+
<g v-for="t in g.ticks" :key="t">
|
|
54
|
+
<line :x1="g.pad.l" :x2="width-g.pad.r" :y1="g.sy(t)" :y2="g.sy(t)" :stroke="t===0?'var(--chart-axis)':'var(--chart-grid)'" stroke-width="1"/>
|
|
55
|
+
<text :x="g.pad.l-8" :y="g.sy(t)+3" text-anchor="end" style="font-family:var(--font-mono);font-size:10px;fill:var(--chart-label)">{{ yFormat(t) }}</text>
|
|
56
|
+
</g>
|
|
57
|
+
<text v-for="(lb,i) in xLabels" :key="'x'+i" :x="g.sx(i)" :y="height-g.pad.b+16" text-anchor="middle" style="font-family:var(--font-sans);font-size:10px;fill:var(--chart-label)">{{ lb }}</text>
|
|
58
|
+
</template>
|
|
59
|
+
<template v-if="target!=null">
|
|
60
|
+
<line :x1="g.pad.l" :x2="width-g.pad.r" :y1="g.sy(target)" :y2="g.sy(target)" stroke="var(--chart-guide)" stroke-width="1" stroke-dasharray="3 3"/>
|
|
61
|
+
<text :x="width-g.pad.r" :y="g.sy(target)-4" text-anchor="end" style="font-family:var(--font-sans);font-size:10px;fill:var(--chart-label)">{{ targetLabel || 'Target '+yFormat(target) }}</text>
|
|
62
|
+
</template>
|
|
63
|
+
<g v-for="ln in g.lines" :key="ln.s.name">
|
|
64
|
+
<path v-if="area||stacked" :d="ln.fill" :fill="'url(#'+uid+'-'+series.indexOf(ln.s)+')'"/>
|
|
65
|
+
<path :d="ln.path" fill="none" :stroke="ln.color" stroke-width="1.5" stroke-linejoin="round"/>
|
|
66
|
+
<circle v-if="hover!=null && ln.s.values[hover]!=null" :cx="ln.at(hover)[0]" :cy="ln.at(hover)[1]" r="3" :fill="ln.color" stroke="var(--bg-surface)" stroke-width="1.5"/>
|
|
67
|
+
</g>
|
|
68
|
+
<line v-if="hover!=null && interactive" :x1="g.sx(hover)" :x2="g.sx(hover)" :y1="g.pad.t" :y2="height-g.pad.b" stroke="var(--chart-guide)" stroke-width="1" stroke-dasharray="2 3"/>
|
|
69
|
+
</svg>
|
|
70
|
+
<div v-if="hover!=null && interactive" :style="{position:'absolute',left:g.sx(hover)+'px',top:(height/2-10)+'px',transform:g.sx(hover)>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',minWidth:'90px',lineHeight:'normal'}">
|
|
71
|
+
<div style="font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:4px;white-space:nowrap">{{ xLabels[hover] }}</div>
|
|
72
|
+
<div v-for="ln in g.lines" :key="ln.s.name" style="display:flex;align-items:center;gap:6px;padding:1px 0">
|
|
73
|
+
<span :style="{width:'7px',height:'7px',borderRadius:'2px',background:ln.color,flexShrink:0}"/>
|
|
74
|
+
<span style="font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary);white-space:nowrap">{{ ln.s.name }}</span>
|
|
75
|
+
<span style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-heading);margin-left:auto;padding-left:10px">{{ yFormat(ln.s.values[hover]) }}</span>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div v-if="legend && series.length>1" style="display:flex;flex-wrap:wrap;gap:4px 14px;padding-top:8px;line-height:normal">
|
|
79
|
+
<span v-for="(s,i) in series" :key="s.name" role="button" tabindex="0" @click="toggle(s.name)"
|
|
80
|
+
:style="{display:'inline-flex',alignItems:'center',gap:'6px',cursor:'pointer',opacity:hidden.includes(s.name)?0.4:1}">
|
|
81
|
+
<span :style="{width:'8px',height:'8px',borderRadius:'2px',background:s.color||SERIES[i%SERIES.length],flexShrink:0}"/>
|
|
82
|
+
<span :style="{fontFamily:'var(--font-sans)',fontSize:'var(--text-xs)',color:'var(--text-secondary)',textDecoration:hidden.includes(s.name)?'line-through':'none'}">{{ s.name }}</span>
|
|
83
|
+
</span>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface ParetoItem { label: string; value: number; }
|
|
3
|
+
/** Sorted bars + cumulative % line and the 80% guide — the "vital few" chart. */
|
|
4
|
+
export interface ParetoChartProps {
|
|
5
|
+
items?: ParetoItem[];
|
|
6
|
+
width?: number; height?: number;
|
|
7
|
+
axes?: boolean;
|
|
8
|
+
/** Cumulative cutoff that separates vital few from trivial many. Default 80 */ threshold?: number;
|
|
9
|
+
valueFormat?: (v: number) => string;
|
|
10
|
+
interactive?: boolean;
|
|
11
|
+
style?: React.CSSProperties;
|
|
12
|
+
}
|
|
13
|
+
export declare function ParetoChart(props: ParetoChartProps): JSX.Element;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fmtNum, niceTicks, scaleLin, linePath } from "./chart-geometry.js";
|
|
3
|
+
import { ChartTooltip, PlotGrid, wrapStyle } from "./ChartKit.jsx";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pareto — bars sorted descending with a cumulative % line on the right axis, plus the
|
|
7
|
+
* 80% guide. items: [{label, value}]; the component sorts and accumulates for you.
|
|
8
|
+
*/
|
|
9
|
+
export function ParetoChart({ items = [], width = 560, height = 260, axes = true, threshold = 80, valueFormat = fmtNum, interactive = true, style }) {
|
|
10
|
+
const [hover, setHover] = React.useState(null);
|
|
11
|
+
const sorted = items.slice().sort((a, b) => b.value - a.value);
|
|
12
|
+
const total = sorted.reduce((s, i) => s + i.value, 0) || 1;
|
|
13
|
+
let run = 0;
|
|
14
|
+
const rows = sorted.map((it) => { run += it.value; return { ...it, cum: (run / total) * 100 }; });
|
|
15
|
+
const pad = { l: axes ? 44 : 4, r: axes ? 38 : 4, t: 12, b: 30 };
|
|
16
|
+
const { ticks, lo, hi } = niceTicks(0, Math.max(...rows.map((r) => r.value)), 4);
|
|
17
|
+
const sy = scaleLin(lo, hi, height - pad.b, pad.t);
|
|
18
|
+
const sc = scaleLin(0, 100, height - pad.b, pad.t);
|
|
19
|
+
const slot = (width - pad.l - pad.r) / rows.length;
|
|
20
|
+
const bw = Math.min(slot * 0.62, 40);
|
|
21
|
+
const cx = (i) => pad.l + slot * i + slot / 2;
|
|
22
|
+
const move = (e) => {
|
|
23
|
+
if (!interactive) return;
|
|
24
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
25
|
+
const i = Math.floor((e.clientX - r.left - pad.l) / slot);
|
|
26
|
+
setHover(i >= 0 && i < rows.length ? i : null);
|
|
27
|
+
};
|
|
28
|
+
const vital = rows.findIndex((r) => r.cum >= threshold);
|
|
29
|
+
return (
|
|
30
|
+
<div style={{ ...wrapStyle, ...style }}>
|
|
31
|
+
<svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role="img">
|
|
32
|
+
{axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={[]} sxLabel={() => 0} yFormat={valueFormat} /> : null}
|
|
33
|
+
{hover != null ? <rect x={pad.l + slot * hover} y={pad.t} width={slot} height={height - pad.t - pad.b} fill="var(--bg-hover)" /> : null}
|
|
34
|
+
<line x1={pad.l} x2={width - pad.r} y1={sc(threshold)} y2={sc(threshold)} stroke="var(--chart-guide)" strokeWidth="1" strokeDasharray="3 3" />
|
|
35
|
+
<text x={width - pad.r - 2} y={sc(threshold) - 4} textAnchor="end" style={{ fontFamily: "var(--font-sans)", fontSize: 10, fill: "var(--chart-label)" }}>{threshold}%</text>
|
|
36
|
+
{rows.map((r, i) => (
|
|
37
|
+
<rect key={r.label} x={cx(i) - bw / 2} y={sy(r.value)} width={bw} height={Math.max(1, sy(0) - sy(r.value))} rx="2"
|
|
38
|
+
fill={i <= vital ? "var(--series-1)" : "var(--chart-track)"} stroke={i > vital ? "var(--border-control)" : "none"}
|
|
39
|
+
opacity={hover == null || hover === i ? 1 : 0.5} style={{ transition: "opacity var(--dur-fast) var(--ease-out)" }} />
|
|
40
|
+
))}
|
|
41
|
+
<path d={linePath(rows.map((r, i) => [cx(i), sc(r.cum)]))} fill="none" stroke="var(--chart-total)" strokeWidth="1.5" />
|
|
42
|
+
{rows.map((r, i) => <circle key={i} cx={cx(i)} cy={sc(r.cum)} r={hover === i ? 3.4 : 2.4} fill="var(--chart-total)" />)}
|
|
43
|
+
{axes ? [0, 25, 50, 75, 100].map((t) => <text key={t} x={width - pad.r + 6} y={sc(t) + 3} style={{ fontFamily: "var(--font-mono)", fontSize: 10, fill: "var(--chart-label)" }}>{t}%</text>) : null}
|
|
44
|
+
{rows.map((r, i) => <text key={"l" + i} x={cx(i)} y={height - pad.b + 15} textAnchor="middle" style={{ fontFamily: "var(--font-sans)", fontSize: 10, fill: "var(--chart-label)" }}>{r.label}</text>)}
|
|
45
|
+
</svg>
|
|
46
|
+
{hover != null && interactive ? (
|
|
47
|
+
<ChartTooltip x={cx(hover)} y={height / 2 - 10} flip={cx(hover) > width * 0.62} title={rows[hover].label}
|
|
48
|
+
rows={[{ label: "value", value: valueFormat(rows[hover].value), color: "var(--series-1)" },
|
|
49
|
+
{ label: "share", value: ((rows[hover].value / total) * 100).toFixed(1) + "%" },
|
|
50
|
+
{ label: "cumulative", value: rows[hover].cum.toFixed(1) + "%", color: "var(--chart-total)" }]} />
|
|
51
|
+
) : null}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { fmtNum, niceTicks, scaleLin, linePath } from './chart-geometry.js'
|
|
4
|
+
// Sorted bars + cumulative % line and the 80% guide — the "vital few" chart.
|
|
5
|
+
const props = defineProps({ items:{type:Array,default:()=>[]}, width:{type:Number,default:560}, height:{type:Number,default:260}, axes:{type:Boolean,default:true}, threshold:{type:Number,default:80}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })
|
|
6
|
+
const hover = ref(null)
|
|
7
|
+
const g = computed(() => {
|
|
8
|
+
const sorted = props.items.slice().sort((a,b)=>b.value-a.value)
|
|
9
|
+
const total = sorted.reduce((s,i)=>s+i.value,0)||1
|
|
10
|
+
let run = 0
|
|
11
|
+
const rows = sorted.map(it=>{ run+=it.value; return Object.assign({}, it, { cum:(run/total)*100 }) })
|
|
12
|
+
const pad = { l: props.axes?44:4, r: props.axes?38:4, t:12, b:30 }
|
|
13
|
+
const { ticks, lo, hi } = niceTicks(0, Math.max(...rows.map(r=>r.value)), 4)
|
|
14
|
+
const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)
|
|
15
|
+
const sc = scaleLin(0, 100, props.height-pad.b, pad.t)
|
|
16
|
+
const slot = (props.width-pad.l-pad.r)/rows.length
|
|
17
|
+
const bw = Math.min(slot*0.62, 40)
|
|
18
|
+
const cx = i => pad.l+slot*i+slot/2
|
|
19
|
+
const vital = rows.findIndex(r=>r.cum>=props.threshold)
|
|
20
|
+
return { rows, total, pad, ticks, sy, sc, slot, bw, cx, vital,
|
|
21
|
+
line: linePath(rows.map((r,i)=>[cx(i), sc(r.cum)])), pct:[0,25,50,75,100] }
|
|
22
|
+
})
|
|
23
|
+
function move(e){
|
|
24
|
+
if(!props.interactive) return
|
|
25
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
26
|
+
const i = Math.floor((e.clientX-r.left-g.value.pad.l)/g.value.slot)
|
|
27
|
+
hover.value = i>=0 && i<g.value.rows.length ? i : null
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
<template>
|
|
31
|
+
<div :style="{position:'relative',display:'inline-block',lineHeight:0,...style}">
|
|
32
|
+
<svg :width="width" :height="height" @mousemove="move" @mouseleave="hover=null" role="img">
|
|
33
|
+
<template v-if="axes">
|
|
34
|
+
<g v-for="t in g.ticks" :key="t">
|
|
35
|
+
<line :x1="g.pad.l" :x2="width-g.pad.r" :y1="g.sy(t)" :y2="g.sy(t)" :stroke="t===0?'var(--chart-axis)':'var(--chart-grid)'" stroke-width="1"/>
|
|
36
|
+
<text :x="g.pad.l-8" :y="g.sy(t)+3" text-anchor="end" style="font-family:var(--font-mono);font-size:10px;fill:var(--chart-label)">{{ valueFormat(t) }}</text>
|
|
37
|
+
</g>
|
|
38
|
+
</template>
|
|
39
|
+
<rect v-if="hover!=null" :x="g.pad.l+g.slot*hover" :y="g.pad.t" :width="g.slot" :height="height-g.pad.t-g.pad.b" fill="var(--bg-hover)"/>
|
|
40
|
+
<line :x1="g.pad.l" :x2="width-g.pad.r" :y1="g.sc(threshold)" :y2="g.sc(threshold)" stroke="var(--chart-guide)" stroke-width="1" stroke-dasharray="3 3"/>
|
|
41
|
+
<text :x="width-g.pad.r-2" :y="g.sc(threshold)-4" text-anchor="end" style="font-family:var(--font-sans);font-size:10px;fill:var(--chart-label)">{{ threshold }}%</text>
|
|
42
|
+
<rect v-for="(r,i) in g.rows" :key="r.label" :x="g.cx(i)-g.bw/2" :y="g.sy(r.value)" :width="g.bw" :height="Math.max(1,g.sy(0)-g.sy(r.value))" rx="2"
|
|
43
|
+
:fill="i<=g.vital?'var(--series-1)':'var(--chart-track)'" :stroke="i>g.vital?'var(--border-control)':'none'"
|
|
44
|
+
:opacity="hover==null||hover===i?1:0.5" style="transition:opacity var(--dur-fast) var(--ease-out)"/>
|
|
45
|
+
<path :d="g.line" fill="none" stroke="var(--chart-total)" stroke-width="1.5"/>
|
|
46
|
+
<circle v-for="(r,i) in g.rows" :key="'c'+i" :cx="g.cx(i)" :cy="g.sc(r.cum)" :r="hover===i?3.4:2.4" fill="var(--chart-total)"/>
|
|
47
|
+
<text v-if="axes" v-for="t in g.pct" :key="'p'+t" :x="width-g.pad.r+6" :y="g.sc(t)+3" style="font-family:var(--font-mono);font-size:10px;fill:var(--chart-label)">{{ t }}%</text>
|
|
48
|
+
<text v-for="(r,i) in g.rows" :key="'l'+i" :x="g.cx(i)" :y="height-g.pad.b+15" text-anchor="middle" style="font-family:var(--font-sans);font-size:10px;fill:var(--chart-label)">{{ r.label }}</text>
|
|
49
|
+
</svg>
|
|
50
|
+
<div v-if="hover!=null && interactive" :style="{position:'absolute',left:g.cx(hover)+'px',top:(height/2-10)+'px',transform:g.cx(hover)>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}">
|
|
51
|
+
<div style="font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px">{{ g.rows[hover].label }}</div>
|
|
52
|
+
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-heading)">{{ valueFormat(g.rows[hover].value) }} · {{ ((g.rows[hover].value/g.total)*100).toFixed(1) }}% · cum {{ g.rows[hover].cum.toFixed(1) }}%</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface Pie3DSegment { label: string; value: number; color?: string; }
|
|
3
|
+
/** Extruded pie/donut. Drag spins it in place (tilt fixed); hover lifts a slice + tooltip. */
|
|
4
|
+
export interface Pie3DProps {
|
|
5
|
+
segments?: Pie3DSegment[];
|
|
6
|
+
size?: number;
|
|
7
|
+
/** Extrusion depth in px */ thickness?: number;
|
|
8
|
+
/** 0 = pie, 0.5+ = donut */ innerRatio?: number;
|
|
9
|
+
/** Vertical squash of the ellipse, 0–1 */ tilt?: number;
|
|
10
|
+
/** Hover lift distance in px */ explode?: number;
|
|
11
|
+
/** Each slice extrudes to its own height by value — the stepped "stairs" pie */ stairs?: boolean;
|
|
12
|
+
/** Initial rotation in degrees */ spin?: number;
|
|
13
|
+
valueFormat?: (v: number) => string;
|
|
14
|
+
legend?: boolean; interactive?: boolean;
|
|
15
|
+
style?: React.CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
export declare function Pie3D(props: Pie3DProps): JSX.Element;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fmtNum, seriesColor } from "./chart-geometry.js";
|
|
3
|
+
import { shade } from "./chart-3d.js";
|
|
4
|
+
import { ChartTooltip, wrapStyle } from "./ChartKit.jsx";
|
|
5
|
+
|
|
6
|
+
/* Intersect the arc [a0,a1] (degrees, a1 > a0) with the window [w0,w1]; returns visible
|
|
7
|
+
sub-arcs. Handles arcs that spin past 360 by testing the window at ±360. */
|
|
8
|
+
function clipArc(a0, a1, w0, w1) {
|
|
9
|
+
const A0 = ((a0 % 360) + 360) % 360, len = a1 - a0, A1 = A0 + len;
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const off of [-360, 0, 360]) {
|
|
12
|
+
const s = Math.max(A0, w0 + off), e = Math.min(A1, w1 + off);
|
|
13
|
+
if (e - s > 0.4) out.push([s, e]);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 3D pie / donut — extruded slices. Drag horizontally to spin in place (tilt stays fixed:
|
|
20
|
+
* free orbit only distorts an extruded pie). stairs={true} extrudes each slice to its own
|
|
21
|
+
* height by value — the stepped presentation pie. Hover lifts a slice and pins a tooltip.
|
|
22
|
+
*/
|
|
23
|
+
export function Pie3D({ segments = [], size = 300, thickness = 26, innerRatio = 0, tilt = 0.45, explode = 6, stairs = false, spin: spin0 = 0, valueFormat = fmtNum, legend = true, interactive = true, style }) {
|
|
24
|
+
const [hover, setHover] = React.useState(null);
|
|
25
|
+
const [spin, setSpin] = React.useState(spin0);
|
|
26
|
+
const drag = React.useRef(null);
|
|
27
|
+
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
|
28
|
+
const vmax = Math.max(...segments.map((s) => s.value), 1);
|
|
29
|
+
const maxTh = stairs ? Math.round(thickness * 2.1) : thickness;
|
|
30
|
+
const cx = size / 2, cy = size / 2 - maxTh / 2 + 6;
|
|
31
|
+
const rO = size / 2 - 10, rI = rO * innerRatio;
|
|
32
|
+
const pt = (r, deg, dz = 0) => [cx + r * Math.cos(((deg - 90) * Math.PI) / 180), cy + r * Math.sin(((deg - 90) * Math.PI) / 180) * tilt + dz];
|
|
33
|
+
let a = 0;
|
|
34
|
+
const slices = segments.map((s, i) => {
|
|
35
|
+
const a0 = a + spin, a1 = a + (s.value / total) * 360 + spin; a += (s.value / total) * 360;
|
|
36
|
+
const mid = ((((a0 + a1) / 2) % 360) + 360) % 360;
|
|
37
|
+
/* Stairs: bottoms share one plane, each top rises by value — the stepped pie. */
|
|
38
|
+
const h = stairs ? maxTh * (0.35 + 0.65 * (s.value / vmax)) : thickness;
|
|
39
|
+
return { s, i, a0, a1, mid, h, color: s.color || seriesColor(i) };
|
|
40
|
+
});
|
|
41
|
+
const arcPts = (r, a0, a1, dz) => {
|
|
42
|
+
const steps = Math.max(2, Math.ceil(Math.abs(a1 - a0) / 6));
|
|
43
|
+
return [...Array(steps + 1)].map((_, k) => pt(r, a0 + ((a1 - a0) * k) / steps, dz));
|
|
44
|
+
};
|
|
45
|
+
const path = (pts) => "M" + pts.map((p) => p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" L") + " Z";
|
|
46
|
+
const off = (sl) => {
|
|
47
|
+
if (hover !== sl.i || !interactive) return [0, 0];
|
|
48
|
+
return [Math.cos(((sl.mid - 90) * Math.PI) / 180) * explode, Math.sin(((sl.mid - 90) * Math.PI) / 180) * explode * tilt];
|
|
49
|
+
};
|
|
50
|
+
const hoverProps = (sl) => interactive ? {
|
|
51
|
+
onMouseEnter: () => !drag.current && setHover(sl.i),
|
|
52
|
+
onMouseLeave: () => setHover(null),
|
|
53
|
+
} : {};
|
|
54
|
+
const dim = (sl) => ({ opacity: hover == null || hover === sl.i ? 1 : 0.55, style: { transition: "opacity var(--dur-fast) var(--ease-out)" } });
|
|
55
|
+
/* deg 180 is the ellipse edge nearest the viewer. An outer wall is visible only where
|
|
56
|
+
its arc crosses the near window (90–270); an inner wall only where it crosses the far
|
|
57
|
+
window, seen through the hole. Clipping each wall to its visible window means the
|
|
58
|
+
visible pieces never overlap in angle — paint order between them stops mattering,
|
|
59
|
+
and no slice can ever show a gap between its top and its wall. */
|
|
60
|
+
const nearness = (sl) => -Math.cos((sl.mid * Math.PI) / 180);
|
|
61
|
+
const sorted = [...slices].sort((p, q) => nearness(p) - nearness(q));
|
|
62
|
+
/* Bottoms first: any sliver the walls miss reads as dark interior, never as a hole in
|
|
63
|
+
the render. */
|
|
64
|
+
const bottoms = slices.map((sl) => {
|
|
65
|
+
const [dx, dy] = off(sl);
|
|
66
|
+
const d = rI > 0
|
|
67
|
+
? path([...arcPts(rO, sl.a0, sl.a1, maxTh), ...arcPts(rI, sl.a1, sl.a0, maxTh)].map(([x, y]) => [x + dx, y + dy]))
|
|
68
|
+
: path([[cx + dx, cy + dy + maxTh], ...arcPts(rO, sl.a0, sl.a1, maxTh).map(([x, y]) => [x + dx, y + dy])]);
|
|
69
|
+
return <path key={"b" + sl.i} d={d} fill={shade(sl.color, -0.45)} {...hoverProps(sl)} {...dim(sl)} />;
|
|
70
|
+
});
|
|
71
|
+
/* Each slice is a closed solid: two radial faces, then its full outer wall. Solids paint
|
|
72
|
+
far-to-near, so a taller near slice covers the interior faces behind it and the side
|
|
73
|
+
of the pie is never open at any spin angle. */
|
|
74
|
+
const solids = sorted.map((sl) => {
|
|
75
|
+
const [dx, dy] = off(sl);
|
|
76
|
+
const zTop = maxTh - sl.h;
|
|
77
|
+
const radial = (deg, k) => {
|
|
78
|
+
const pts = [pt(rI || 0.01, deg, zTop), pt(rO, deg, zTop), pt(rO, deg, maxTh), pt(rI || 0.01, deg, maxTh)].map(([x, y]) => [x + dx, y + dy]);
|
|
79
|
+
return <path key={"r" + k + sl.i} d={path(pts)} fill={shade(sl.color, -0.18)} />;
|
|
80
|
+
};
|
|
81
|
+
const top = arcPts(rO, sl.a0, sl.a1, zTop).map(([x, y]) => [x + dx, y + dy]);
|
|
82
|
+
const bot = arcPts(rO, sl.a0, sl.a1, maxTh).map(([x, y]) => [x + dx, y + dy]);
|
|
83
|
+
return (
|
|
84
|
+
<g key={"solid" + sl.i} {...hoverProps(sl)} {...dim(sl)}>
|
|
85
|
+
{radial(sl.a0, "s")}
|
|
86
|
+
{radial(sl.a1, "e")}
|
|
87
|
+
<path d={path([...top, ...bot.reverse()])} fill={shade(sl.color, -0.28)} />
|
|
88
|
+
</g>
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
const innerWalls = rI ? slices.flatMap((sl) => clipArc(sl.a0, sl.a1, -90, 90).map(([s, e], k) => {
|
|
92
|
+
const [dx, dy] = off(sl);
|
|
93
|
+
const top = arcPts(rI, s, e, maxTh - sl.h).map(([x, y]) => [x + dx, y + dy]);
|
|
94
|
+
const bot = arcPts(rI, e, s, maxTh).map(([x, y]) => [x + dx, y + dy]);
|
|
95
|
+
return <path key={"iw" + sl.i + "-" + k} d={path([...top, ...bot])} fill={shade(sl.color, -0.38)} {...hoverProps(sl)} {...dim(sl)} />;
|
|
96
|
+
})) : [];
|
|
97
|
+
const tops = sorted.map((sl) => {
|
|
98
|
+
const [dx, dy] = off(sl);
|
|
99
|
+
const zTop = maxTh - sl.h;
|
|
100
|
+
const d = rI > 0
|
|
101
|
+
? path([...arcPts(rO, sl.a0, sl.a1, zTop), ...arcPts(rI, sl.a1, sl.a0, zTop)].map(([x, y]) => [x + dx, y + dy]))
|
|
102
|
+
: path([[cx + dx, cy + dy + zTop], ...arcPts(rO, sl.a0, sl.a1, zTop).map(([x, y]) => [x + dx, y + dy])]);
|
|
103
|
+
return <path key={"t" + sl.i} d={d} fill={sl.color} stroke="var(--bg-surface)" strokeWidth="1" {...hoverProps(sl)} {...dim(sl)} />;
|
|
104
|
+
});
|
|
105
|
+
const spinHandlers = interactive ? {
|
|
106
|
+
onPointerDown: (e) => { drag.current = { x: e.clientX, s: spin }; e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId); },
|
|
107
|
+
onPointerMove: (e) => { if (drag.current) setSpin(drag.current.s + (e.clientX - drag.current.x) * 0.5); },
|
|
108
|
+
onPointerUp: () => { drag.current = null; },
|
|
109
|
+
onPointerLeave: () => { drag.current = null; },
|
|
110
|
+
} : {};
|
|
111
|
+
const h = hover != null ? slices.find((sl) => sl.i === hover) : null;
|
|
112
|
+
const tipAt = h ? pt(rO * 0.8, (h.a0 + h.a1) / 2, (maxTh - h.h) / 2) : null;
|
|
113
|
+
return (
|
|
114
|
+
<div style={{ ...wrapStyle, ...style }}>
|
|
115
|
+
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
|
116
|
+
<div style={{ position: "relative" }}>
|
|
117
|
+
<svg width={size} height={size * 0.72 + maxTh} {...spinHandlers} role="img" style={{ cursor: interactive ? "grab" : "default", touchAction: "none" }}>
|
|
118
|
+
{bottoms}
|
|
119
|
+
{innerWalls}
|
|
120
|
+
{solids}
|
|
121
|
+
{tops}
|
|
122
|
+
</svg>
|
|
123
|
+
{h && interactive ? (
|
|
124
|
+
<ChartTooltip x={tipAt[0]} y={tipAt[1]} flip={tipAt[0] > size * 0.55} title={h.s.label}
|
|
125
|
+
rows={[{ label: "value", value: valueFormat(h.s.value), color: h.color }, { label: "share", value: ((h.s.value / total) * 100).toFixed(1) + "%" }]} />
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
{legend ? (
|
|
129
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6, lineHeight: "normal" }}>
|
|
130
|
+
{slices.map(({ s, i, color }) => (
|
|
131
|
+
<span key={s.label} onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}
|
|
132
|
+
style={{ display: "inline-flex", alignItems: "center", gap: 7, opacity: hover == null || hover === i ? 1 : 0.45 }}>
|
|
133
|
+
<span style={{ width: 8, height: 8, borderRadius: 2, background: color, flexShrink: 0 }} />
|
|
134
|
+
<span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{s.label}</span>
|
|
135
|
+
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--text-heading)", marginLeft: 6 }}>
|
|
136
|
+
{hover === i ? valueFormat(s.value) : ((s.value / total) * 100).toFixed(0) + "%"}
|
|
137
|
+
</span>
|
|
138
|
+
</span>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
) : null}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|