banhaten 0.3.0 → 0.5.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/CHANGELOG.md +139 -73
- package/CONTRIBUTING.md +46 -44
- package/README.md +192 -190
- package/SECURITY.md +15 -15
- package/banhaten.config.example.json +11 -11
- package/docs/design-spacing.md +13 -0
- package/docs/design-system/README.md +22 -20
- package/docs/design-system/adoption-quality-remediation.md +87 -87
- package/docs/design-system/agent-usage.md +13 -13
- package/docs/design-system/appearance-presets.md +189 -189
- package/docs/design-system/appearances/default.md +94 -94
- package/docs/design-system/appearances/rounded.md +95 -95
- package/docs/design-system/appearances/sharp.md +95 -95
- package/docs/design-system/component-docs-pattern.md +81 -81
- package/docs/design-system/component-implementation-ownership-audit.md +245 -244
- package/docs/design-system/component-quality-audit-2026-08-20.md +225 -223
- package/docs/design-system/component-showcase-consistency-report.md +219 -219
- package/docs/design-system/component-token-consistency-audit.md +173 -173
- package/docs/design-system/components/README.md +90 -76
- package/docs/design-system/components/accordion.md +53 -53
- package/docs/design-system/components/activity-feed.md +94 -94
- package/docs/design-system/components/activity-heatmap.md +57 -57
- package/docs/design-system/components/agent-input.md +85 -0
- package/docs/design-system/components/alert-dialog.md +72 -72
- package/docs/design-system/components/alert.md +98 -98
- package/docs/design-system/components/approval-card.md +134 -0
- package/docs/design-system/components/aspect-ratio.md +45 -45
- package/docs/design-system/components/attribute.md +89 -89
- package/docs/design-system/components/autocomplete.md +75 -75
- package/docs/design-system/components/avatar.md +54 -54
- package/docs/design-system/components/badge.md +54 -54
- package/docs/design-system/components/banner.md +87 -87
- package/docs/design-system/components/breadcrumbs.md +177 -176
- package/docs/design-system/components/button-group.md +98 -98
- package/docs/design-system/components/button.md +83 -80
- package/docs/design-system/components/card.md +88 -88
- package/docs/design-system/components/carousel.md +46 -46
- package/docs/design-system/components/catalog-components.md +47 -47
- package/docs/design-system/components/chart.md +95 -24
- package/docs/design-system/components/checkbox.md +54 -54
- package/docs/design-system/components/collapsible.md +67 -53
- package/docs/design-system/components/command.md +62 -62
- package/docs/design-system/components/context-menu.md +46 -46
- package/docs/design-system/components/date-picker.md +81 -79
- package/docs/design-system/components/divider.md +102 -102
- package/docs/design-system/components/empty-state.md +56 -56
- package/docs/design-system/components/field.md +70 -70
- package/docs/design-system/components/file-upload.md +187 -187
- package/docs/design-system/components/hover-card.md +48 -48
- package/docs/design-system/components/icons.md +48 -48
- package/docs/design-system/components/inline-citations.md +93 -0
- package/docs/design-system/components/input-group.md +58 -58
- package/docs/design-system/components/input-otp.md +56 -56
- package/docs/design-system/components/input.md +117 -61
- package/docs/design-system/components/item.md +59 -57
- package/docs/design-system/components/kbd.md +54 -49
- package/docs/design-system/components/label.md +49 -49
- package/docs/design-system/components/menu.md +71 -61
- package/docs/design-system/components/menubar.md +47 -47
- package/docs/design-system/components/modal.md +105 -105
- package/docs/design-system/components/native-select.md +54 -54
- package/docs/design-system/components/navigation-menu.md +50 -50
- package/docs/design-system/components/onboarding-step-list-item.md +82 -82
- package/docs/design-system/components/page-header.md +93 -86
- package/docs/design-system/components/pagination.md +57 -57
- package/docs/design-system/components/popover.md +60 -60
- package/docs/design-system/components/progress-slider.md +50 -50
- package/docs/design-system/components/progress.md +76 -76
- package/docs/design-system/components/radio-card.md +51 -51
- package/docs/design-system/components/radio-group.md +57 -57
- package/docs/design-system/components/resizable.md +44 -44
- package/docs/design-system/components/rolling-count.md +61 -0
- package/docs/design-system/components/scroll-area.md +51 -51
- package/docs/design-system/components/segmented-control.md +54 -54
- package/docs/design-system/components/select.md +68 -62
- package/docs/design-system/components/sheet.md +67 -67
- package/docs/design-system/components/shimmer-text.md +67 -0
- package/docs/design-system/components/sidebar.md +127 -76
- package/docs/design-system/components/skeleton.md +75 -75
- package/docs/design-system/components/slideout.md +65 -65
- package/docs/design-system/components/slider.md +63 -63
- package/docs/design-system/components/social-button.md +50 -50
- package/docs/design-system/components/spinner.md +62 -62
- package/docs/design-system/components/steps.md +65 -65
- package/docs/design-system/components/streaming-text.md +82 -0
- package/docs/design-system/components/table.md +419 -414
- package/docs/design-system/components/tabs.md +59 -55
- package/docs/design-system/components/tag.md +80 -80
- package/docs/design-system/components/task-list.md +95 -0
- package/docs/design-system/components/textarea.md +50 -50
- package/docs/design-system/components/thinking-reasoning.md +72 -0
- package/docs/design-system/components/thinking-state.md +67 -0
- package/docs/design-system/components/timeline.md +83 -83
- package/docs/design-system/components/toast.md +58 -58
- package/docs/design-system/components/toggle.md +81 -81
- package/docs/design-system/components/toolbar.md +92 -87
- package/docs/design-system/components/tooltip.md +94 -92
- package/docs/design-system/components/typography.md +18 -18
- package/docs/design-system/density-and-elevation.md +65 -61
- package/docs/design-system/design-system-test-missing-items.md +368 -368
- package/docs/design-system/direction-and-localization.md +23 -23
- package/docs/design-system/form-recipes.md +73 -73
- package/docs/design-system/gap-promotion-policy.md +21 -21
- package/docs/design-system/icons.md +74 -69
- package/docs/design-system/installation.md +70 -68
- package/docs/design-system/launch-comparison-shadcn.md +98 -98
- package/docs/design-system/registry-and-cli.md +64 -64
- package/docs/design-system/release-policy.md +30 -30
- package/docs/design-system/roadmap.md +14 -14
- package/docs/design-system/support-matrix.md +23 -23
- package/docs/design-system/tabs.md +54 -53
- package/docs/design-system/template-gap-audit.md +32 -0
- package/docs/design-system/templates.md +59 -0
- package/docs/design-system/token-governance.md +72 -60
- package/package.json +98 -98
- package/registry/component-contract.json +40 -40
- package/registry/components/accordion.tsx +155 -155
- package/registry/components/agent-input-parts.tsx +552 -0
- package/registry/components/agent-input-state.ts +156 -0
- package/registry/components/agent-input.css +307 -0
- package/registry/components/agent-input.tsx +382 -0
- package/registry/components/alert-dialog.tsx +297 -297
- package/registry/components/alert.tsx +293 -293
- package/registry/components/approval-card.css +409 -0
- package/registry/components/approval-card.tsx +401 -0
- package/registry/components/approval-frame.tsx +108 -0
- package/registry/components/approval-questions.tsx +559 -0
- package/registry/components/aspect-ratio.tsx +30 -30
- package/registry/components/attribute.tsx +454 -448
- package/registry/components/autocomplete.tsx +598 -651
- package/registry/components/avatar.tsx +385 -381
- package/registry/components/badge.tsx +629 -629
- package/registry/components/button-group.tsx +565 -565
- package/registry/components/button.tsx +127 -123
- package/registry/components/card.tsx +638 -638
- package/registry/components/carousel.tsx +296 -296
- package/registry/components/chart-block-data.ts +177 -0
- package/registry/components/chart-block.css +171 -0
- package/registry/components/chart-block.tsx +260 -0
- package/registry/components/chart-interactions.ts +123 -0
- package/registry/components/chart-options.cartesian.ts +162 -0
- package/registry/components/chart-options.paint.ts +99 -0
- package/registry/components/chart-options.parts.ts +72 -0
- package/registry/components/chart-options.radial.ts +109 -0
- package/registry/components/chart-options.ts +44 -0
- package/registry/components/chart-options.types.ts +67 -0
- package/registry/components/chart.tsx +203 -203
- package/registry/components/checkbox.tsx +247 -247
- package/registry/components/collapsible.tsx +109 -69
- package/registry/components/command.tsx +208 -208
- package/registry/components/context-menu.tsx +236 -236
- package/registry/components/data-chart.tsx +40 -0
- package/registry/components/date-picker.tsx +1289 -1277
- package/registry/components/echart-theme.ts +246 -0
- package/registry/components/echart.css +49 -0
- package/registry/components/echart.tsx +178 -0
- package/registry/components/expanded/ActivityFeed.tsx +227 -227
- package/registry/components/expanded/ActivityHeatmap.tsx +64 -64
- package/registry/components/expanded/Banner.tsx +179 -179
- package/registry/components/expanded/Breadcrumbs.tsx +131 -131
- package/registry/components/expanded/CatalogComponentsShowcase.tsx +205 -205
- package/registry/components/expanded/CatalogDivider.tsx +49 -49
- package/registry/components/expanded/EmptyState.tsx +155 -155
- package/registry/components/expanded/FileUpload.tsx +543 -543
- package/registry/components/expanded/OnboardingStepListItem.tsx +60 -59
- package/registry/components/expanded/PageHeader.tsx +179 -166
- package/registry/components/expanded/Slideout.tsx +108 -108
- package/registry/components/expanded/Steps.tsx +402 -402
- package/registry/components/expanded/Table.tsx +1092 -1071
- package/registry/components/expanded/Timeline.tsx +203 -203
- package/registry/components/expanded/activityFeed.css +248 -248
- package/registry/components/expanded/activityHeatmap.css +66 -66
- package/registry/components/expanded/banner.css +260 -255
- package/registry/components/expanded/breadcrumbs.css +140 -140
- package/registry/components/expanded/catalogComponentsShowcase.css +87 -87
- package/registry/components/expanded/divider.css +75 -75
- package/registry/components/expanded/emptyState.css +111 -111
- package/registry/components/expanded/fileUpload.css +457 -457
- package/registry/components/expanded/onboardingStepListItem.css +135 -126
- package/registry/components/expanded/pageHeader.css +248 -290
- package/registry/components/expanded/slideout.css +276 -276
- package/registry/components/expanded/steps.css +356 -356
- package/registry/components/expanded/table.css +606 -603
- package/registry/components/expanded/timeline.css +286 -286
- package/registry/components/field.tsx +283 -283
- package/registry/components/hover-card.tsx +48 -48
- package/registry/components/inline-citations.css +126 -0
- package/registry/components/inline-citations.tsx +292 -0
- package/registry/components/input-content.ts +107 -107
- package/registry/components/input-group.tsx +130 -130
- package/registry/components/input-otp.tsx +584 -584
- package/registry/components/input.tsx +990 -972
- package/registry/components/item.tsx +189 -189
- package/registry/components/kbd.tsx +246 -151
- package/registry/components/label.tsx +78 -78
- package/registry/components/menu.tsx +978 -986
- package/registry/components/menubar.tsx +226 -226
- package/registry/components/modal.tsx +872 -872
- package/registry/components/native-select.tsx +205 -205
- package/registry/components/navigation-menu.tsx +171 -171
- package/registry/components/pagination.css +36 -36
- package/registry/components/pagination.tsx +560 -555
- package/registry/components/popover.tsx +338 -338
- package/registry/components/progress-slider.tsx +216 -216
- package/registry/components/radio-card.tsx +676 -677
- package/registry/components/radio-group.tsx +570 -570
- package/registry/components/resizable.tsx +108 -108
- package/registry/components/rolling-count.css +37 -0
- package/registry/components/rolling-count.tsx +110 -0
- package/registry/components/scroll-area.tsx +91 -91
- package/registry/components/segmented-control.tsx +87 -87
- package/registry/components/select-content.tsx +218 -218
- package/registry/components/select.tsx +937 -932
- package/registry/components/sheet.tsx +305 -305
- package/registry/components/shimmer-text.css +69 -0
- package/registry/components/shimmer-text.tsx +43 -0
- package/registry/components/sidebar.tsx +1433 -358
- package/registry/components/skeleton.css +54 -54
- package/registry/components/skeleton.tsx +482 -482
- package/registry/components/slider.tsx +380 -380
- package/registry/components/social-button.tsx +387 -387
- package/registry/components/spinner.tsx +78 -78
- package/registry/components/streaming-text.css +45 -0
- package/registry/components/streaming-text.tsx +165 -0
- package/registry/components/table-elements.tsx +128 -128
- package/registry/components/tabs.css +222 -218
- package/registry/components/tabs.tsx +241 -241
- package/registry/components/tag.tsx +423 -423
- package/registry/components/task-list.css +328 -0
- package/registry/components/task-list.tsx +429 -0
- package/registry/components/textarea.tsx +610 -610
- package/registry/components/thinking-reasoning.css +178 -0
- package/registry/components/thinking-reasoning.tsx +253 -0
- package/registry/components/thinking-state.css +8 -0
- package/registry/components/thinking-state.tsx +46 -0
- package/registry/components/toast.tsx +136 -136
- package/registry/components/toggle.tsx +256 -256
- package/registry/components/toolbar.tsx +539 -539
- package/registry/components/tooltip.tsx +433 -431
- package/registry/examples/agent-input-demo.tsx +90 -0
- package/registry/examples/alert-demo.tsx +34 -34
- package/registry/examples/alert-dialog-demo.tsx +42 -42
- package/registry/examples/approval-card-demo.tsx +83 -0
- package/registry/examples/aspect-ratio-demo.tsx +11 -11
- package/registry/examples/attribute-demo.tsx +65 -65
- package/registry/examples/autocomplete-demo.tsx +109 -109
- package/registry/examples/avatar-demo.tsx +132 -132
- package/registry/examples/badge-demo.tsx +69 -69
- package/registry/examples/button-demo.tsx +86 -83
- package/registry/examples/button-group-demo.tsx +63 -63
- package/registry/examples/card-demo.tsx +86 -86
- package/registry/examples/carousel-demo.tsx +43 -43
- package/registry/examples/chart-block-demo.tsx +5 -0
- package/registry/examples/chart-demo.tsx +18 -33
- package/registry/examples/checkbox-demo.tsx +62 -62
- package/registry/examples/collapsible-demo.tsx +16 -16
- package/registry/examples/collection-filter-bar-demo.tsx +135 -135
- package/registry/examples/command-demo.tsx +42 -42
- package/registry/examples/command-palette-demo.tsx +235 -236
- package/registry/examples/context-menu-demo.tsx +29 -29
- package/registry/examples/control-density-contract-demo.tsx +65 -61
- package/registry/examples/date-picker-demo.tsx +127 -127
- package/registry/examples/divider-demo.tsx +17 -17
- package/registry/examples/expanded/activity-heatmap-demo.tsx +29 -29
- package/registry/examples/expanded/banner-demo.tsx +48 -48
- package/registry/examples/expanded/breadcrumbs-demo.tsx +59 -59
- package/registry/examples/expanded/empty-state-demo.tsx +39 -39
- package/registry/examples/expanded/file-upload-demo.tsx +60 -60
- package/registry/examples/expanded/onboarding-step-demo.tsx +11 -11
- package/registry/examples/expanded/slideout-demo.tsx +15 -15
- package/registry/examples/expanded/steps-demo.tsx +29 -29
- package/registry/examples/expanded/tabs-demo.tsx +13 -13
- package/registry/examples/expanded/timeline-demo.tsx +18 -18
- package/registry/examples/field-demo.tsx +51 -51
- package/registry/examples/hover-card-demo.tsx +23 -23
- package/registry/examples/inline-citations-demo.tsx +31 -0
- package/registry/examples/input-actions-demo.tsx +36 -0
- package/registry/examples/input-demo.tsx +87 -87
- package/registry/examples/input-group-demo.tsx +16 -16
- package/registry/examples/input-otp-demo.tsx +72 -72
- package/registry/examples/item-demo.tsx +46 -46
- package/registry/examples/kbd-demo.tsx +12 -11
- package/registry/examples/label-demo.tsx +20 -20
- package/registry/examples/menu-demo.tsx +122 -122
- package/registry/examples/menubar-demo.tsx +34 -34
- package/registry/examples/modal-demo.tsx +74 -74
- package/registry/examples/native-select-demo.tsx +16 -16
- package/registry/examples/navigation-menu-demo.tsx +29 -29
- package/registry/examples/pagination-demo.tsx +20 -20
- package/registry/examples/popover-demo.tsx +547 -546
- package/registry/examples/progress-demo.tsx +37 -37
- package/registry/examples/progress-slider-demo.tsx +29 -29
- package/registry/examples/radio-card-demo.tsx +51 -51
- package/registry/examples/radio-group-demo.tsx +62 -62
- package/registry/examples/resizable-demo.tsx +22 -22
- package/registry/examples/results-toolbar-demo.tsx +44 -44
- package/registry/examples/rolling-count-demo.tsx +39 -0
- package/registry/examples/scroll-area-demo.tsx +15 -15
- package/registry/examples/segmented-control-demo.tsx +39 -39
- package/registry/examples/select-demo.tsx +87 -87
- package/registry/examples/sheet-demo.tsx +47 -47
- package/registry/examples/shimmer-text-demo.tsx +20 -0
- package/registry/examples/sidebar-demo.tsx +101 -57
- package/registry/examples/slider-demo.tsx +31 -31
- package/registry/examples/social-button-demo.tsx +61 -61
- package/registry/examples/spinner-demo.tsx +59 -59
- package/registry/examples/streaming-text-demo.tsx +28 -0
- package/registry/examples/tabs-demo.tsx +13 -13
- package/registry/examples/tag-demo.tsx +29 -29
- package/registry/examples/task-list-demo.tsx +74 -0
- package/registry/examples/textarea-demo.tsx +79 -79
- package/registry/examples/thinking-reasoning-demo.tsx +74 -0
- package/registry/examples/thinking-state-demo.tsx +13 -0
- package/registry/examples/toast-demo.tsx +35 -35
- package/registry/examples/toggle-demo.tsx +59 -59
- package/registry/examples/tooltip-demo.tsx +115 -115
- package/registry/guidance/USAGE.md +27 -27
- package/registry/guidance/charts.LICENSE.txt +24 -0
- package/registry/hooks/use-controllable-state.ts +33 -0
- package/registry/hooks/use-direction.ts +207 -207
- package/registry/hooks/use-listbox-navigation.ts +73 -0
- package/registry/hooks/use-motion-end.ts +55 -0
- package/registry/hooks/use-reduced-motion.ts +28 -0
- package/registry/index.json +1634 -184
- package/registry/styles/globals.css +1462 -1570
- package/registry.json +364 -10
- package/schema/config.schema.json +51 -51
- package/schema/registry.schema.json +194 -190
- package/schema/tokens.schema.json +167 -163
- package/src/cli/command-line.js +194 -194
- package/src/cli/config-editors.js +9 -9
- package/src/cli/doctor.js +399 -399
- package/src/cli/file-transaction.js +302 -295
- package/src/cli/global-css.js +153 -153
- package/src/cli/index.js +1002 -1009
- package/src/cli/install-plan.js +592 -592
- package/src/cli/install-receipt.js +171 -171
- package/src/cli/jsonc.js +203 -203
- package/src/cli/package-install-state.js +254 -254
- package/src/cli/package-manager.js +238 -238
- package/src/cli/package-plan.js +185 -185
- package/src/cli/package-version.js +97 -97
- package/src/cli/registry-query.js +207 -207
- package/src/cli/reporting.js +55 -55
- package/src/cli/schema-validation.js +19 -19
- package/src/cli/source-scanner.js +686 -686
- package/src/cli/vite-config.js +478 -478
- package/tokens/banhaten.tokens.json +458 -224
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { graphic, type ECharts } from "echarts/core"
|
|
2
|
+
import type { EChartsOption, SeriesOption } from "echarts"
|
|
3
|
+
import type { EChartTheme } from "./echart-theme"
|
|
4
|
+
import { createChartOption } from "./chart-options"
|
|
5
|
+
import { barPaint, chartAlpha, expandablePaint, seriesColors } from "./chart-options.paint"
|
|
6
|
+
import { chartSeries, type ChartSpec } from "./chart-options.types"
|
|
7
|
+
|
|
8
|
+
/** Attach optional interaction behavior. Every listener and animation has a cleanup. */
|
|
9
|
+
export function attachChartInteractions(chart: ECharts, theme: EChartTheme, spec: ChartSpec): () => void {
|
|
10
|
+
const entries = chartSeries(spec)
|
|
11
|
+
const animated = entries.filter((entry) => entry.stroke === "animated-dashed")
|
|
12
|
+
const expanding = entries.filter((entry) => (entry.variant ?? spec.variant) === "expandable")
|
|
13
|
+
const stripped = entries.filter((entry) => (entry.variant ?? spec.variant) === "stripped")
|
|
14
|
+
const reveal = Boolean(spec.hoverReveal && (spec.family === "line" || spec.family === "area"))
|
|
15
|
+
if (!animated.length && !expanding.length && !stripped.length && !reveal) return () => {}
|
|
16
|
+
const original = createChartOption(theme, spec)
|
|
17
|
+
const series = (Array.isArray(original.series) ? original.series : [original.series]).filter((item): item is SeriesOption => Boolean(item))
|
|
18
|
+
let frame: number | undefined
|
|
19
|
+
let hovered: number | null = null
|
|
20
|
+
let disposed = false
|
|
21
|
+
let dirty = false
|
|
22
|
+
const started = performance.now()
|
|
23
|
+
let capSignature = ""
|
|
24
|
+
|
|
25
|
+
// Keep the colored cap a token-sized strip after data changes, zoom, and resize.
|
|
26
|
+
const updateCaps = () => {
|
|
27
|
+
if (disposed || chart.isDisposed()) return
|
|
28
|
+
const ratios: number[] = []
|
|
29
|
+
const patches = stripped.flatMap((entry) => {
|
|
30
|
+
const originalSeries = series.find((item) => item.id === entry.key)
|
|
31
|
+
if (originalSeries?.type !== "bar") return []
|
|
32
|
+
const colors = seriesColors(theme, entry, entries.indexOf(entry))
|
|
33
|
+
const data = originalSeries.data?.map((datum, index) => {
|
|
34
|
+
if (!datum || typeof datum !== "object" || Array.isArray(datum) || !("value" in datum) || typeof datum.value !== "number") return datum
|
|
35
|
+
const point = chart.convertToPixel({ gridIndex: 0 }, spec.horizontal ? [datum.value, index] : [index, datum.value])
|
|
36
|
+
const origin = chart.convertToPixel({ gridIndex: 0 }, spec.horizontal ? [0, index] : [index, 0])
|
|
37
|
+
const axis = spec.horizontal ? 0 : 1
|
|
38
|
+
const span = Array.isArray(point) && Array.isArray(origin) ? Math.abs(point[axis] - origin[axis]) : 0
|
|
39
|
+
const ratio = span > 0 ? Math.min(1, theme.space.xs / span) : 0
|
|
40
|
+
ratios.push(ratio)
|
|
41
|
+
return { ...datum, itemStyle: { ...datum.itemStyle, color: barPaint(theme, colors, "stripped", Boolean(spec.horizontal), ratio) } }
|
|
42
|
+
})
|
|
43
|
+
return [{ id: entry.key, type: "bar" as const, data }]
|
|
44
|
+
})
|
|
45
|
+
const signature = ratios.join(",")
|
|
46
|
+
if (signature === capSignature) return
|
|
47
|
+
capSignature = signature
|
|
48
|
+
chart.setOption({ series: patches }, { silent: true, lazyUpdate: true })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const paint = (time: number) => {
|
|
52
|
+
frame = undefined
|
|
53
|
+
if (disposed || chart.isDisposed()) return
|
|
54
|
+
const patches: SeriesOption[] = []
|
|
55
|
+
entries.forEach((entry, seriesIndex) => {
|
|
56
|
+
const originalSeries = series.find((item) => item.id === entry.key)
|
|
57
|
+
if (!originalSeries) return
|
|
58
|
+
const colors = seriesColors(theme, entry, seriesIndex)
|
|
59
|
+
const patch: Record<string, unknown> = { id: entry.key }
|
|
60
|
+
let changed = false
|
|
61
|
+
if (entry.stroke === "animated-dashed" && !theme.reducedMotion) {
|
|
62
|
+
patch.lineStyle = { dashOffset: -((time - started) / Math.max(theme.duration, 1)) * theme.space.xl }
|
|
63
|
+
changed = true
|
|
64
|
+
}
|
|
65
|
+
if (reveal && dirty && originalSeries.type === "line") {
|
|
66
|
+
const cutoff = hovered === null ? 1 : hovered / Math.max(1, spec.data.length - 1)
|
|
67
|
+
const gradient = new graphic.LinearGradient(0, 0, 1, 0, [
|
|
68
|
+
{ offset: 0, color: colors[0] }, { offset: cutoff, color: colors.at(-1)! },
|
|
69
|
+
{ offset: cutoff, color: hovered === null ? colors.at(-1)! : theme.neutral },
|
|
70
|
+
{ offset: 1, color: hovered === null ? colors.at(-1)! : theme.neutral },
|
|
71
|
+
])
|
|
72
|
+
patch.lineStyle = { ...originalSeries.lineStyle, ...patch.lineStyle as object, color: gradient }
|
|
73
|
+
if (spec.family === "area") {
|
|
74
|
+
patch.areaStyle = hovered === null ? originalSeries.areaStyle : {
|
|
75
|
+
color: new graphic.LinearGradient(0, 0, 1, 0, gradient.colorStops.map((stop) => ({ ...stop, color: chartAlpha(stop.color, theme.opacity.area) }))),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
changed = true
|
|
79
|
+
}
|
|
80
|
+
if ((entry.variant ?? spec.variant) === "expandable" && dirty && originalSeries.type === "bar") {
|
|
81
|
+
patch.data = originalSeries.data?.map((datum, index) => ({
|
|
82
|
+
...(typeof datum === "object" && datum !== null && !Array.isArray(datum) ? datum : { value: datum }),
|
|
83
|
+
itemStyle: { color: expandablePaint(colors[0], hovered === index ? 1 : theme.opacity.soft, Boolean(spec.horizontal)) },
|
|
84
|
+
}))
|
|
85
|
+
changed = true
|
|
86
|
+
}
|
|
87
|
+
if (changed) patches.push(patch as SeriesOption)
|
|
88
|
+
})
|
|
89
|
+
if (patches.length) chart.setOption({ series: patches } satisfies EChartsOption, { silent: true, lazyUpdate: true })
|
|
90
|
+
dirty = false
|
|
91
|
+
if (animated.length && !theme.reducedMotion) frame = requestAnimationFrame(paint)
|
|
92
|
+
}
|
|
93
|
+
const schedule = () => { if (frame === undefined) frame = requestAnimationFrame(paint) }
|
|
94
|
+
const move = (event: { offsetX: number; offsetY: number }) => {
|
|
95
|
+
const point = [event.offsetX, event.offsetY]
|
|
96
|
+
const converted = chart.containPixel({ gridIndex: 0 }, point) ? chart.convertFromPixel({ gridIndex: 0 }, point) : null
|
|
97
|
+
const value = Array.isArray(converted) ? converted[spec.horizontal ? 1 : 0] : null
|
|
98
|
+
const next = typeof value === "number" ? Math.max(0, Math.min(spec.data.length - 1, Math.round(value))) : null
|
|
99
|
+
if (next === hovered) return
|
|
100
|
+
hovered = next
|
|
101
|
+
dirty = true
|
|
102
|
+
schedule()
|
|
103
|
+
}
|
|
104
|
+
const leave = () => {
|
|
105
|
+
hovered = null
|
|
106
|
+
dirty = true
|
|
107
|
+
schedule()
|
|
108
|
+
}
|
|
109
|
+
const renderer = chart.getZr()
|
|
110
|
+
if (expanding.length || reveal) {
|
|
111
|
+
renderer.on("mousemove", move)
|
|
112
|
+
renderer.on("globalout", leave)
|
|
113
|
+
}
|
|
114
|
+
if (animated.length && !theme.reducedMotion) schedule()
|
|
115
|
+
if (stripped.length) { updateCaps(); chart.on("finished", updateCaps) }
|
|
116
|
+
return () => {
|
|
117
|
+
disposed = true
|
|
118
|
+
if (frame !== undefined) cancelAnimationFrame(frame)
|
|
119
|
+
renderer.off("mousemove", move)
|
|
120
|
+
renderer.off("globalout", leave)
|
|
121
|
+
if (stripped.length) chart.off("finished", updateCaps)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { BarSeriesOption, LineSeriesOption, PictorialBarSeriesOption } from "echarts/charts"
|
|
2
|
+
import type { EChartsOption } from "echarts"
|
|
3
|
+
import type { EChartTheme } from "./echart-theme"
|
|
4
|
+
import { areaPaint, barPaint, chartDecal, chartGradient, seriesColors } from "./chart-options.paint"
|
|
5
|
+
import { chartNumber, chartSeries, type ChartCurve, type ChartSeries, type ChartSpec } from "./chart-options.types"
|
|
6
|
+
import { cartesianTooltip, dotItemStyle } from "./chart-options.parts"
|
|
7
|
+
|
|
8
|
+
function curveOption(curve: ChartCurve | undefined): Pick<LineSeriesOption, "smooth" | "smoothMonotone" | "step"> {
|
|
9
|
+
if (curve === "step") return { step: "middle" }
|
|
10
|
+
if (!curve || curve === "linear") return { smooth: false }
|
|
11
|
+
return { smooth: curve === "bump" ? 0.5 : true, smoothMonotone: curve === "monotoneY" ? "y" : "x" }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function valuesFor(spec: ChartSpec, series: ChartSeries, all: ChartSeries[]) {
|
|
15
|
+
const percent = spec.stack === "percent" || spec.stack === "expanded"
|
|
16
|
+
return spec.data.map((row) => {
|
|
17
|
+
const value = chartNumber(row[series.key])
|
|
18
|
+
if (value === null || !percent) return value
|
|
19
|
+
const sum = all.reduce((total, entry) => total + Math.max(0, chartNumber(row[entry.key]) ?? 0), 0)
|
|
20
|
+
return sum === 0 ? 0 : Math.max(0, value) / sum * 100
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isBarSeries(spec: ChartSpec, entry: ChartSeries) {
|
|
25
|
+
return entry.type === "bar" || (spec.family === "bar" && !entry.type)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function barEndRadius(theme: EChartTheme, spec: ChartSpec, all: ChartSeries[], index: number, point: number, value: number | null): number | number[] {
|
|
29
|
+
if (!value) return 0
|
|
30
|
+
// Positive and negative stacks have independent exposed ends; missing values leave no join.
|
|
31
|
+
if (spec.stack && spec.stack !== "default" && all.slice(index + 1).some((entry) => {
|
|
32
|
+
const next = chartNumber(spec.data[point][entry.key])
|
|
33
|
+
return isBarSeries(spec, entry) && next !== null && next * value > 0
|
|
34
|
+
})) return 0
|
|
35
|
+
const radius = theme.markRadius
|
|
36
|
+
if (spec.horizontal) return (value > 0) !== theme.rtl ? [0, radius, radius, 0] : [radius, 0, 0, radius]
|
|
37
|
+
return value > 0 ? [radius, radius, 0, 0] : [0, 0, radius, radius]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lineSeries(theme: EChartTheme, spec: ChartSpec, entry: ChartSeries, index: number, all: ChartSeries[]): LineSeriesOption[] {
|
|
41
|
+
const values = valuesFor(spec, entry, all)
|
|
42
|
+
const colors = seriesColors(theme, entry, index)
|
|
43
|
+
const isArea = entry.type === "area" || spec.family === "area"
|
|
44
|
+
// Forecast tails must not enter a stack as a duplicate series and change totals.
|
|
45
|
+
const hasBuffer = entry.buffer && values.length > 1 && (!spec.stack || spec.stack === "default")
|
|
46
|
+
const base: LineSeriesOption = {
|
|
47
|
+
id: entry.key,
|
|
48
|
+
name: entry.label ?? entry.key,
|
|
49
|
+
type: "line",
|
|
50
|
+
...curveOption(entry.curve ?? spec.curve),
|
|
51
|
+
data: hasBuffer ? values.map((value, point) => point === values.length - 1 ? null : value) : values,
|
|
52
|
+
stack: spec.stack && spec.stack !== "default" ? "total" : undefined,
|
|
53
|
+
connectNulls: false,
|
|
54
|
+
showSymbol: entry.dots ?? Boolean(entry.dotStyle),
|
|
55
|
+
symbol: "circle",
|
|
56
|
+
symbolSize: entry.dotStyle === "border" ? theme.space.md : theme.space.sm,
|
|
57
|
+
lineStyle: { color: chartGradient(colors), width: theme.lineWidth, type: entry.stroke === "solid" || !entry.stroke ? "solid" : "dashed", cap: "round" },
|
|
58
|
+
itemStyle: dotItemStyle(theme, colors[0], entry.dotStyle),
|
|
59
|
+
areaStyle: isArea ? { color: areaPaint(theme, colors, entry.variant ?? spec.variant ?? "gradient"), opacity: 1 } : undefined,
|
|
60
|
+
emphasis: { focus: spec.hoverHighlight ? "series" : "none", scale: true, lineStyle: { width: theme.lineWidth }, itemStyle: dotItemStyle(theme, colors[0], entry.activeDotStyle ?? entry.dotStyle) },
|
|
61
|
+
blur: { lineStyle: { opacity: theme.opacity.muted }, areaStyle: { opacity: theme.opacity.muted }, itemStyle: { opacity: theme.opacity.muted } },
|
|
62
|
+
selectedMode: "single",
|
|
63
|
+
select: { itemStyle: { color: colors[0] } },
|
|
64
|
+
}
|
|
65
|
+
if (!hasBuffer) return [base]
|
|
66
|
+
return [base, {
|
|
67
|
+
...base, id: `${entry.key}-buffer`,
|
|
68
|
+
data: values.map((value, point) => point >= values.length - 2 ? value : null),
|
|
69
|
+
stack: undefined, silent: true,
|
|
70
|
+
lineStyle: { ...base.lineStyle, type: "dashed" },
|
|
71
|
+
}]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function barSeries(theme: EChartTheme, spec: ChartSpec, entry: ChartSeries, index: number, all: ChartSeries[]): (BarSeriesOption | PictorialBarSeriesOption)[] {
|
|
75
|
+
const values = valuesFor(spec, entry, all)
|
|
76
|
+
const colors = seriesColors(theme, entry, index)
|
|
77
|
+
const variant = entry.variant ?? spec.variant ?? "default"
|
|
78
|
+
const totals = spec.data.map((row) => all.reduce((total, series) => total + (chartNumber(row[series.key]) ?? 0), 0))
|
|
79
|
+
const maximumIndex = totals.indexOf(Math.max(...totals))
|
|
80
|
+
const fill = barPaint(theme, colors, variant, Boolean(spec.horizontal))
|
|
81
|
+
const data = values.map((value, point) => ({
|
|
82
|
+
value,
|
|
83
|
+
itemStyle: {
|
|
84
|
+
color: spec.highlightMax && point !== maximumIndex ? theme.neutral : fill,
|
|
85
|
+
decal: chartDecal(theme, colors[0], entry.buffer && point === values.length - 1 ? "hatched" : variant),
|
|
86
|
+
borderWidth: entry.buffer && point === values.length - 1 ? theme.borderWidth : 0,
|
|
87
|
+
borderColor: colors[0],
|
|
88
|
+
borderRadius: variant === "blocks" || variant === "expandable" ? 0 : barEndRadius(theme, spec, all, index, point, value),
|
|
89
|
+
},
|
|
90
|
+
}))
|
|
91
|
+
const base: BarSeriesOption = {
|
|
92
|
+
id: entry.key, name: entry.label ?? entry.key, type: "bar", data,
|
|
93
|
+
stack: spec.stack && spec.stack !== "default" ? "total" : undefined,
|
|
94
|
+
barMaxWidth: theme.space.xxl,
|
|
95
|
+
barCategoryGap: "35%",
|
|
96
|
+
itemStyle: { color: fill, borderRadius: 0 },
|
|
97
|
+
emphasis: { focus: spec.hoverHighlight ? "series" : "none", itemStyle: { color: colors[0] }, label: { show: variant === "expandable", position: spec.horizontal ? "right" : "top", color: theme.foreground, fontFamily: theme.fontFamily, fontSize: theme.fontSize, fontWeight: theme.mediumWeight } },
|
|
98
|
+
blur: { itemStyle: { opacity: theme.opacity.muted } },
|
|
99
|
+
selectedMode: "single",
|
|
100
|
+
select: { itemStyle: { color: colors[0] } },
|
|
101
|
+
}
|
|
102
|
+
if (variant !== "blocks") return [base]
|
|
103
|
+
const max = spec.max ?? Math.max(1, ...values.map((value) => value ?? 0))
|
|
104
|
+
const blocks: PictorialBarSeriesOption = {
|
|
105
|
+
id: entry.key, name: base.name, type: "pictorialBar", data,
|
|
106
|
+
symbol: "rect", symbolRepeat: true, symbolClip: true,
|
|
107
|
+
symbolSize: spec.horizontal ? [theme.space.sm, theme.space.lg] : [theme.space.lg, theme.space.sm],
|
|
108
|
+
symbolMargin: theme.borderWidth, symbolBoundingData: max,
|
|
109
|
+
itemStyle: { color: colors[0] }, z: 2,
|
|
110
|
+
emphasis: { itemStyle: { color: colors[0] } },
|
|
111
|
+
}
|
|
112
|
+
const background: PictorialBarSeriesOption = {
|
|
113
|
+
...blocks, id: `${entry.key}-track`, name: "", data: values.map(() => max),
|
|
114
|
+
symbolClip: false, silent: true, tooltip: { show: false },
|
|
115
|
+
itemStyle: { color: theme.neutral }, z: 0,
|
|
116
|
+
}
|
|
117
|
+
return [background, blocks]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function cartesianOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
|
|
121
|
+
const all = chartSeries(spec)
|
|
122
|
+
const percent = spec.stack === "percent" || spec.stack === "expanded"
|
|
123
|
+
const category = {
|
|
124
|
+
type: "category" as const,
|
|
125
|
+
data: spec.data.map((row) => String(row[spec.categoryKey ?? "name"] ?? "")),
|
|
126
|
+
boundaryGap: spec.family === "bar" || spec.family === "composed",
|
|
127
|
+
inverse: spec.horizontal ? true : theme.rtl,
|
|
128
|
+
position: spec.horizontal && theme.rtl ? "right" as const : undefined,
|
|
129
|
+
axisTick: { show: false }, axisLine: { show: false },
|
|
130
|
+
axisLabel: { color: theme.muted, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize, margin: theme.space.md },
|
|
131
|
+
}
|
|
132
|
+
const value = {
|
|
133
|
+
type: "value" as const,
|
|
134
|
+
inverse: Boolean(spec.horizontal && theme.rtl),
|
|
135
|
+
position: !spec.horizontal && theme.rtl ? "right" as const : undefined,
|
|
136
|
+
max: percent ? 100 : spec.max,
|
|
137
|
+
axisLine: { show: false }, axisTick: { show: false },
|
|
138
|
+
axisLabel: { color: theme.muted, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize, formatter: percent ? "{value}%" : undefined },
|
|
139
|
+
splitLine: { lineStyle: { color: theme.border, width: theme.borderWidth, type: "dashed" as const } },
|
|
140
|
+
}
|
|
141
|
+
const series = all.flatMap<LineSeriesOption | BarSeriesOption | PictorialBarSeriesOption>((entry, index) => isBarSeries(spec, entry)
|
|
142
|
+
? barSeries(theme, spec, entry, index, all)
|
|
143
|
+
: lineSeries(theme, spec, entry, index, all))
|
|
144
|
+
return {
|
|
145
|
+
grid: { left: theme.rtl ? theme.space.lg : theme.space.axis, right: theme.rtl ? theme.space.axis : theme.space.lg, top: spec.legend === false ? theme.space.lg : theme.space.axis, bottom: spec.brush ? theme.space.axis * 2 : theme.space.axis },
|
|
146
|
+
xAxis: spec.horizontal ? value : category,
|
|
147
|
+
yAxis: spec.horizontal ? category : value,
|
|
148
|
+
legend: { show: spec.legend !== false, data: all.map((entry) => entry.label ?? entry.key), top: 0, right: 0 },
|
|
149
|
+
tooltip: { trigger: "axis", formatter: cartesianTooltip(theme, spec), axisPointer: { type: "line", lineStyle: { color: theme.border, width: theme.borderWidth } } },
|
|
150
|
+
dataZoom: spec.brush ? [{
|
|
151
|
+
type: "slider", xAxisIndex: spec.horizontal ? undefined : 0, yAxisIndex: spec.horizontal ? 0 : undefined,
|
|
152
|
+
bottom: theme.space.xs, height: theme.space.xxl, start: 0, end: 100,
|
|
153
|
+
borderColor: theme.border, backgroundColor: theme.surface, fillerColor: theme.neutral,
|
|
154
|
+
handleStyle: { color: theme.surface, borderColor: theme.border },
|
|
155
|
+
moveHandleStyle: { color: theme.border },
|
|
156
|
+
textStyle: { color: theme.muted, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize },
|
|
157
|
+
dataBackground: { lineStyle: { color: theme.muted }, areaStyle: { color: theme.neutral } },
|
|
158
|
+
selectedDataBackground: { lineStyle: { color: theme.palette[0] }, areaStyle: { color: theme.neutral } },
|
|
159
|
+
}] : [],
|
|
160
|
+
series,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { color, graphic } from "echarts/core"
|
|
2
|
+
import type { EChartTheme } from "./echart-theme"
|
|
3
|
+
import { chartColor, type ChartSeries, type ChartVariant } from "./chart-options.types"
|
|
4
|
+
|
|
5
|
+
export function chartAlpha(paint: string, opacity: number): string {
|
|
6
|
+
return color.modifyAlpha(paint, opacity) ?? paint
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function seriesColors(theme: EChartTheme, series: ChartSeries, index: number): string[] {
|
|
10
|
+
return (series.colorIndices?.length ? series.colorIndices : [series.colorIndex ?? index]).map((slot) => chartColor(theme, slot))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function chartGradient(colors: string[], vertical = false) {
|
|
14
|
+
if (colors.length === 1) return colors[0]
|
|
15
|
+
return new graphic.LinearGradient(0, 0, vertical ? 0 : 1, vertical ? 1 : 0,
|
|
16
|
+
colors.map((paint, index) => ({ offset: index / (colors.length - 1), color: paint })))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function areaPaint(theme: EChartTheme, colors: string[], variant: ChartVariant) {
|
|
20
|
+
if (["dotted", "lines", "hatched"].includes(variant) && typeof document !== "undefined") {
|
|
21
|
+
const tile = document.createElement("canvas")
|
|
22
|
+
tile.width = Math.max(1, Math.round(theme.space.md))
|
|
23
|
+
tile.height = tile.width
|
|
24
|
+
const context = tile.getContext("2d")
|
|
25
|
+
if (context) {
|
|
26
|
+
context.fillStyle = chartAlpha(colors[0], theme.opacity.soft)
|
|
27
|
+
context.fillRect(0, 0, tile.width, tile.height)
|
|
28
|
+
context.fillStyle = chartAlpha(colors[0], theme.opacity.area)
|
|
29
|
+
context.strokeStyle = context.fillStyle
|
|
30
|
+
context.lineWidth = theme.borderWidth
|
|
31
|
+
if (variant === "dotted") {
|
|
32
|
+
context.beginPath()
|
|
33
|
+
context.arc(tile.width / 2, tile.height / 2, theme.borderWidth, 0, Math.PI * 2)
|
|
34
|
+
context.fill()
|
|
35
|
+
} else if (variant === "lines") {
|
|
36
|
+
context.fillRect(0, 0, tile.width, theme.borderWidth)
|
|
37
|
+
} else {
|
|
38
|
+
for (const offset of [-tile.width, 0, tile.width]) {
|
|
39
|
+
context.beginPath()
|
|
40
|
+
context.moveTo(offset, tile.height)
|
|
41
|
+
context.lineTo(offset + tile.width, 0)
|
|
42
|
+
context.stroke()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { image: tile, repeat: "repeat" as const }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (variant === "gradient" || variant === "gradient-reverse") {
|
|
49
|
+
const reverse = variant === "gradient-reverse"
|
|
50
|
+
return new graphic.LinearGradient(0, 0, 0, 1, [
|
|
51
|
+
{ offset: 0, color: chartAlpha(colors[0], reverse ? 0 : theme.opacity.area) },
|
|
52
|
+
{ offset: 1, color: chartAlpha(colors.at(-1)!, reverse ? theme.opacity.area : 0) },
|
|
53
|
+
])
|
|
54
|
+
}
|
|
55
|
+
return chartGradient(colors.map((paint) => chartAlpha(paint, theme.opacity.area)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function chartDecal(theme: EChartTheme, paint: string, variant: ChartVariant) {
|
|
59
|
+
if (!["hatched", "lines", "dotted"].includes(variant)) return undefined
|
|
60
|
+
return {
|
|
61
|
+
symbol: variant === "dotted" ? "circle" : "rect",
|
|
62
|
+
symbolSize: variant === "dotted" ? theme.opacity.area : 1,
|
|
63
|
+
color: paint,
|
|
64
|
+
backgroundColor: chartAlpha(paint, theme.opacity.soft),
|
|
65
|
+
dashArrayX: variant === "dotted" ? [theme.space.xs, theme.space.xs] : [1, 0],
|
|
66
|
+
dashArrayY: [theme.borderWidth, theme.space.xs],
|
|
67
|
+
rotation: variant === "hatched" ? -Math.PI / 4 : 0,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function expandablePaint(paint: string, fraction: number, horizontal: boolean) {
|
|
72
|
+
const low = (1 - fraction) / 2
|
|
73
|
+
const high = 1 - low
|
|
74
|
+
const clear = chartAlpha(paint, 0)
|
|
75
|
+
return new graphic.LinearGradient(0, 0, horizontal ? 0 : 1, horizontal ? 1 : 0, [
|
|
76
|
+
{ offset: 0, color: clear }, { offset: low, color: clear },
|
|
77
|
+
{ offset: low, color: paint }, { offset: high, color: paint },
|
|
78
|
+
{ offset: high, color: clear }, { offset: 1, color: clear },
|
|
79
|
+
])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function barPaint(theme: EChartTheme, colors: string[], variant: ChartVariant, horizontal: boolean, capFraction = theme.space.xs / theme.space.axis) {
|
|
83
|
+
if (variant === "expandable") return expandablePaint(colors[0], theme.opacity.soft, horizontal)
|
|
84
|
+
if (variant === "gradient") return new graphic.LinearGradient(0, 0, horizontal ? 1 : 0, horizontal ? 0 : 1, [
|
|
85
|
+
{ offset: 0, color: colors[0] }, { offset: 1, color: chartAlpha(colors.at(-1)!, theme.opacity.soft) },
|
|
86
|
+
])
|
|
87
|
+
if (variant === "duotone" || variant === "duotone-reverse" || variant === "stripped") {
|
|
88
|
+
const muted = chartAlpha(colors[0], theme.opacity.area)
|
|
89
|
+
const reverse = variant === "duotone-reverse" || (variant === "stripped" && horizontal)
|
|
90
|
+
const first = reverse ? muted : colors[0]
|
|
91
|
+
const second = reverse ? colors[0] : muted
|
|
92
|
+
const split = variant === "stripped" ? horizontal ? 1 - capFraction : capFraction : 0.5
|
|
93
|
+
return new graphic.LinearGradient(0, 0, horizontal ? 1 : 0, horizontal ? 0 : 1, [
|
|
94
|
+
{ offset: 0, color: first }, { offset: split, color: first },
|
|
95
|
+
{ offset: split, color: second }, { offset: 1, color: second },
|
|
96
|
+
])
|
|
97
|
+
}
|
|
98
|
+
return chartGradient(colors, !horizontal)
|
|
99
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { EChartsOption, LegendComponentOption, TooltipComponentOption } from "echarts"
|
|
2
|
+
import type { EChartTheme } from "./echart-theme"
|
|
3
|
+
import { chartAlpha } from "./chart-options.paint"
|
|
4
|
+
import { chartColor, chartNumber, chartSeries, type ChartDotStyle, type ChartSpec } from "./chart-options.types"
|
|
5
|
+
|
|
6
|
+
export function dotItemStyle(theme: EChartTheme, paint: string, style: ChartDotStyle = "default") {
|
|
7
|
+
return {
|
|
8
|
+
color: style === "colored-border" ? theme.surface : paint,
|
|
9
|
+
borderColor: style === "border" ? theme.surface : paint,
|
|
10
|
+
borderWidth: style === "border" ? theme.lineWidth : style === "colored-border" ? theme.borderWidth : 0,
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function escapeHtml(value: string) {
|
|
15
|
+
return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Read the original row so split forecast series don't duplicate or hide tooltip values. */
|
|
19
|
+
export function cartesianTooltip(theme: EChartTheme, spec: ChartSpec): NonNullable<TooltipComponentOption["formatter"]> {
|
|
20
|
+
const entries = chartSeries(spec)
|
|
21
|
+
return (params) => {
|
|
22
|
+
const point = Array.isArray(params) ? params[0] : params
|
|
23
|
+
const row = spec.data[point.dataIndex]
|
|
24
|
+
if (!row) return ""
|
|
25
|
+
const title = escapeHtml(String(row[spec.categoryKey ?? "name"] ?? ""))
|
|
26
|
+
const rows = entries.map((entry) => {
|
|
27
|
+
const value = chartNumber(row[entry.key])
|
|
28
|
+
return `<div>${escapeHtml(entry.label ?? entry.key)}: <span style="font-weight:${theme.mediumWeight}">${value === null ? "—" : value.toLocaleString()}</span></div>`
|
|
29
|
+
})
|
|
30
|
+
return `<div>${title}</div>${rows.join("")}`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function styleLegend(theme: EChartTheme, spec: ChartSpec, legend: LegendComponentOption): LegendComponentOption {
|
|
35
|
+
const variant = spec.legendIndicator ?? "rounded-square"
|
|
36
|
+
const all = chartSeries(spec)
|
|
37
|
+
const data = legend.data ?? (spec.family === "pie" || spec.family === "radial"
|
|
38
|
+
? spec.data.map((row) => String(row[spec.categoryKey ?? "name"] ?? ""))
|
|
39
|
+
: all.map((series) => series.label ?? series.key))
|
|
40
|
+
const outlined = variant.endsWith("outline")
|
|
41
|
+
const icon = variant.startsWith("circle") ? "circle" : variant.startsWith("rounded") ? "roundRect" : "rect"
|
|
42
|
+
return {
|
|
43
|
+
...legend, icon, align: theme.rtl ? "right" : "left",
|
|
44
|
+
itemWidth: variant === "vertical-bar" ? theme.space.xs : variant === "horizontal-bar" ? theme.space.lg : theme.space.md,
|
|
45
|
+
itemHeight: variant === "horizontal-bar" ? theme.space.xs : variant === "vertical-bar" ? theme.space.lg : theme.space.md,
|
|
46
|
+
data: data.map((item, index) => {
|
|
47
|
+
const paint = chartColor(theme, all[index]?.colorIndex ?? index)
|
|
48
|
+
return { ...(typeof item === "string" ? { name: item } : item), icon, itemStyle: { color: outlined ? theme.surface : paint, borderColor: paint, borderWidth: outlined ? theme.borderWidth : 0 } }
|
|
49
|
+
}),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function styleChartParts(theme: EChartTheme, spec: ChartSpec, option: EChartsOption): EChartsOption {
|
|
54
|
+
const originalTooltip = !Array.isArray(option.tooltip) ? option.tooltip ?? {} : {}
|
|
55
|
+
const tooltip: TooltipComponentOption = {
|
|
56
|
+
...originalTooltip,
|
|
57
|
+
backgroundColor: spec.tooltipSurface === "translucent" ? chartAlpha(theme.surface, theme.translucentOpacity ?? theme.opacity.muted) : theme.surface,
|
|
58
|
+
borderColor: theme.border, borderWidth: theme.borderWidth,
|
|
59
|
+
borderRadius: theme.radii?.[spec.tooltipRadius ?? "lg"] ?? theme.radius,
|
|
60
|
+
textStyle: { color: theme.foreground, fontFamily: theme.fontFamily, fontSize: theme.fontSize, fontWeight: theme.fontWeight, lineHeight: theme.lineHeight },
|
|
61
|
+
axisPointer: { ...originalTooltip.axisPointer, show: spec.tooltipCursor !== false },
|
|
62
|
+
}
|
|
63
|
+
if (spec.tooltipPosition === "fixed") {
|
|
64
|
+
tooltip.position = (point, _params, _element, _rect, size) => [Math.max(0, Math.min(point[0] - size.contentSize[0] / 2, size.viewSize[0] - size.contentSize[0])), theme.space.lg]
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
...option, tooltip,
|
|
68
|
+
legend: Array.isArray(option.legend)
|
|
69
|
+
? option.legend.map((legend) => styleLegend(theme, spec, legend))
|
|
70
|
+
: option.legend ? styleLegend(theme, spec, option.legend) : undefined,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { EChartsOption } from "echarts"
|
|
2
|
+
import type { BarSeriesOption, PieSeriesOption, RadarSeriesOption } from "echarts/charts"
|
|
3
|
+
import type { EChartTheme } from "./echart-theme"
|
|
4
|
+
import { chartAlpha, chartGradient, seriesColors } from "./chart-options.paint"
|
|
5
|
+
import { chartColor, chartNumber, chartSeries, type ChartSpec } from "./chart-options.types"
|
|
6
|
+
import { dotItemStyle } from "./chart-options.parts"
|
|
7
|
+
|
|
8
|
+
function labelStyle(theme: EChartTheme) {
|
|
9
|
+
return { color: theme.foreground, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize, fontWeight: theme.mediumWeight, lineHeight: theme.lineHeight }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function pieOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
|
|
13
|
+
const entry = chartSeries(spec)[0]
|
|
14
|
+
const category = spec.categoryKey ?? "name"
|
|
15
|
+
const donut = ["donut", "padded", "petal"].includes(spec.variant ?? "")
|
|
16
|
+
const data = spec.data.flatMap((row, index) => {
|
|
17
|
+
const value = chartNumber(row[entry.key])
|
|
18
|
+
if (value === null || value < 0) return []
|
|
19
|
+
const colors = entry.colorIndices?.length
|
|
20
|
+
? [chartColor(theme, index), chartColor(theme, index + 1)]
|
|
21
|
+
: [chartColor(theme, index + (entry.colorIndex ?? 0))]
|
|
22
|
+
return [{ name: String(row[category] ?? ""), value, itemStyle: { color: chartGradient(colors) } }]
|
|
23
|
+
})
|
|
24
|
+
const series: PieSeriesOption = {
|
|
25
|
+
id: entry.key, type: "pie", data,
|
|
26
|
+
radius: [donut ? "48%" : "0%", spec.labels === "outside" ? "58%" : "74%"],
|
|
27
|
+
center: ["50%", "50%"],
|
|
28
|
+
padAngle: spec.variant === "padded" ? theme.borderWidth * 2 : 0,
|
|
29
|
+
itemStyle: {
|
|
30
|
+
borderRadius: spec.variant === "petal" || spec.variant === "padded" ? theme.markRadius : 0,
|
|
31
|
+
borderColor: theme.surface,
|
|
32
|
+
borderWidth: spec.variant === "petal" ? theme.space.xs : spec.variant === "padded" ? theme.borderWidth : 0,
|
|
33
|
+
},
|
|
34
|
+
selectedMode: "single", selectedOffset: theme.space.md,
|
|
35
|
+
emphasis: { scale: true, scaleSize: theme.space.xs, label: labelStyle(theme) },
|
|
36
|
+
label: { ...labelStyle(theme), show: spec.labels === "inside" || spec.labels === "outside", position: spec.labels === "inside" ? "inside" : "outside", formatter: spec.labels === "inside" ? "{d}%" : "{b}", backgroundColor: spec.labels === "inside" ? theme.surface : undefined, padding: spec.labels === "inside" ? theme.space.xs : 0, borderRadius: theme.radius },
|
|
37
|
+
labelLine: { show: spec.labels === "outside", lineStyle: { color: theme.border, width: theme.borderWidth }, length: theme.space.md, length2: theme.space.md },
|
|
38
|
+
}
|
|
39
|
+
return { legend: { show: spec.legend !== false, bottom: 0 }, tooltip: { trigger: "item" }, series: [series] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function radialOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
|
|
43
|
+
const entry = chartSeries(spec)[0]
|
|
44
|
+
const rows = spec.data.filter((row) => chartNumber(row[entry.key]) !== null && Number(row[entry.key]) >= 0)
|
|
45
|
+
const categories = rows.map((row) => String(row[spec.categoryKey ?? "name"] ?? ""))
|
|
46
|
+
const max = spec.max && spec.max > 0 ? spec.max : Math.max(1, ...rows.map((row) => Number(row[entry.key])))
|
|
47
|
+
const semi = spec.variant === "semi"
|
|
48
|
+
const series: BarSeriesOption[] = rows.map((row, index) => ({
|
|
49
|
+
id: `${entry.key}-${index}`, name: categories[index], type: "bar", coordinateSystem: "polar", polarIndex: 0,
|
|
50
|
+
data: [{ name: categories[index], value: [Number(row[entry.key]), index] }],
|
|
51
|
+
encode: { angle: 0, radius: 1 },
|
|
52
|
+
// Every series occupies a different category. Sharing the stack keeps ECharts
|
|
53
|
+
// from allocating a progressively smaller grouped bar band to each ring.
|
|
54
|
+
stack: "rings", barWidth: theme.space.lg, roundCap: true,
|
|
55
|
+
showBackground: false, z: 3,
|
|
56
|
+
itemStyle: { color: chartGradient(entry.colorIndices?.length ? [chartColor(theme, index), chartColor(theme, index + 1)] : [chartColor(theme, index + (entry.colorIndex ?? 0))]) },
|
|
57
|
+
emphasis: { focus: "series" },
|
|
58
|
+
selectedMode: "single",
|
|
59
|
+
label: { ...labelStyle(theme), show: spec.labels === "inside", position: "middle", backgroundColor: theme.surface, padding: theme.space.xs, borderRadius: theme.radius },
|
|
60
|
+
}))
|
|
61
|
+
// Native polar backgrounds always draw 360 degrees. A second identical polar
|
|
62
|
+
// gives the explicit track its own bar band and respects the semi-circle sweep.
|
|
63
|
+
series.push({
|
|
64
|
+
id: "ring-tracks", type: "bar", coordinateSystem: "polar", polarIndex: 1,
|
|
65
|
+
data: rows.map((_row, index) => ({ value: [max, index] })), encode: { angle: 0, radius: 1 },
|
|
66
|
+
barWidth: theme.space.lg, roundCap: true, silent: true, animation: false,
|
|
67
|
+
itemStyle: { color: theme.neutral }, emphasis: { disabled: true },
|
|
68
|
+
tooltip: { show: false }, z: 1,
|
|
69
|
+
})
|
|
70
|
+
return {
|
|
71
|
+
polar: [0, 1].map(() => ({ radius: ["28%", "78%"], center: ["50%", semi ? "68%" : "46%"] })),
|
|
72
|
+
angleAxis: [0, 1].map((polarIndex) => ({ polarIndex, type: "value", min: 0, max, startAngle: semi ? 180 : 90, endAngle: semi ? 0 : -270, clockwise: true, show: false })),
|
|
73
|
+
radiusAxis: [0, 1].map((polarIndex) => ({ polarIndex, type: "category", data: categories, inverse: true, show: false })),
|
|
74
|
+
legend: { show: spec.legend !== false, bottom: 0, data: categories },
|
|
75
|
+
tooltip: { trigger: "item", valueFormatter: (value) => String(Array.isArray(value) ? value[0] : value) },
|
|
76
|
+
series,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function radarOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
|
|
81
|
+
const all = chartSeries(spec)
|
|
82
|
+
const max = spec.max && spec.max > 0 ? spec.max : Math.max(1, ...spec.data.flatMap((row) => all.map((entry) => chartNumber(row[entry.key]) ?? 0)))
|
|
83
|
+
const series: RadarSeriesOption[] = all.flatMap((entry, index) => {
|
|
84
|
+
const values = spec.data.map((row) => chartNumber(row[entry.key]))
|
|
85
|
+
if (values.some((value) => value === null)) return []
|
|
86
|
+
const colors = seriesColors(theme, entry, index)
|
|
87
|
+
return [{
|
|
88
|
+
id: entry.key, name: entry.label ?? entry.key, type: "radar",
|
|
89
|
+
data: [{ name: entry.label ?? entry.key, value: values as number[] }],
|
|
90
|
+
lineStyle: { color: chartGradient(colors), width: theme.lineWidth },
|
|
91
|
+
areaStyle: entry.variant === "lines" || spec.variant === "lines" ? { opacity: 0 } : { color: chartGradient(colors.map((paint) => chartAlpha(paint, theme.opacity.area))), opacity: 1 },
|
|
92
|
+
symbol: entry.dots || entry.dotStyle ? "circle" : "none", symbolSize: entry.dotStyle === "border" ? theme.space.md : theme.space.sm,
|
|
93
|
+
itemStyle: dotItemStyle(theme, colors[0], entry.dotStyle),
|
|
94
|
+
emphasis: { focus: "self", itemStyle: dotItemStyle(theme, colors[0], entry.activeDotStyle ?? entry.dotStyle) },
|
|
95
|
+
}]
|
|
96
|
+
})
|
|
97
|
+
return {
|
|
98
|
+
radar: {
|
|
99
|
+
indicator: spec.data.map((row) => ({ name: String(row[spec.categoryKey ?? "name"] ?? ""), max })),
|
|
100
|
+
shape: spec.grid === "circle" ? "circle" : "polygon", radius: "65%",
|
|
101
|
+
axisName: { color: theme.muted, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize },
|
|
102
|
+
axisLine: { lineStyle: { color: theme.border, width: theme.borderWidth } },
|
|
103
|
+
splitLine: { lineStyle: { color: theme.border, width: theme.borderWidth } },
|
|
104
|
+
splitArea: { show: false },
|
|
105
|
+
},
|
|
106
|
+
legend: { show: spec.legend !== false, bottom: 0, data: all.map((entry) => entry.label ?? entry.key) },
|
|
107
|
+
tooltip: { trigger: "item" }, series,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EChartsOption } from "echarts"
|
|
2
|
+
import type { EChartTheme } from "./echart-theme"
|
|
3
|
+
import { cartesianOption } from "./chart-options.cartesian"
|
|
4
|
+
import { pieOption, radarOption, radialOption } from "./chart-options.radial"
|
|
5
|
+
import { chartColor, chartNumber, type ChartSpec } from "./chart-options.types"
|
|
6
|
+
import { styleChartParts } from "./chart-options.parts"
|
|
7
|
+
|
|
8
|
+
export type { ChartCurve, ChartDatum, ChartDotStyle, ChartFamily, ChartLegendIndicator, ChartSeries, ChartSpec, ChartStroke, ChartVariant } from "./chart-options.types"
|
|
9
|
+
|
|
10
|
+
/** Build native ECharts options using only resolved Banhaten tokens. */
|
|
11
|
+
export function createChartOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
|
|
12
|
+
return styleChartParts(theme, spec, createFamilyOption(theme, spec))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createFamilyOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
|
|
16
|
+
if (["area", "line", "bar", "composed"].includes(spec.family)) return cartesianOption(theme, spec)
|
|
17
|
+
if (spec.family === "pie") return pieOption(theme, spec)
|
|
18
|
+
if (spec.family === "radial") return radialOption(theme, spec)
|
|
19
|
+
if (spec.family === "radar") return radarOption(theme, spec)
|
|
20
|
+
const nodes = spec.nodes ?? spec.data.map((row) => ({ name: String(row[spec.categoryKey ?? "name"] ?? "") }))
|
|
21
|
+
const names = new Set(nodes.map((node) => node.name))
|
|
22
|
+
const resolveName = (value: string | number) => typeof value === "number" ? nodes[value]?.name : value
|
|
23
|
+
const links = (spec.links ?? []).flatMap((link) => {
|
|
24
|
+
const source = resolveName(link.source)
|
|
25
|
+
const target = resolveName(link.target)
|
|
26
|
+
const value = chartNumber(link.value)
|
|
27
|
+
return source && target && names.has(source) && names.has(target) && value !== null && value > 0
|
|
28
|
+
? [{ source, target, value }]
|
|
29
|
+
: []
|
|
30
|
+
})
|
|
31
|
+
return {
|
|
32
|
+
tooltip: { trigger: "item" },
|
|
33
|
+
series: [{
|
|
34
|
+
id: "flow", type: "sankey", orient: spec.horizontal === false ? "vertical" : "horizontal",
|
|
35
|
+
left: theme.space.lg, right: theme.space.axis * 2, top: theme.space.lg, bottom: theme.space.lg,
|
|
36
|
+
data: nodes.map((node, index) => ({ name: node.name, itemStyle: { color: chartColor(theme, "colorIndex" in node ? Number(node.colorIndex) : index), borderWidth: 0 } })),
|
|
37
|
+
links, nodeWidth: spec.labels === "inside" ? theme.space.axis * 2 : theme.space.lg,
|
|
38
|
+
nodeGap: theme.space.xl, draggable: false,
|
|
39
|
+
lineStyle: { color: spec.variant === "solid" ? theme.palette[0] : spec.variant === "target" ? "target" : spec.variant === "gradient" ? "gradient" : "source", opacity: theme.opacity.area, curveness: 0.5 },
|
|
40
|
+
emphasis: { focus: "adjacency", lineStyle: { opacity: theme.opacity.muted } },
|
|
41
|
+
label: { show: spec.labels !== "none", position: spec.labels === "inside" ? "inside" : "right", color: theme.foreground, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize, fontWeight: theme.fontWeight, distance: theme.space.sm, backgroundColor: spec.labels === "inside" ? theme.surface : undefined, padding: spec.labels === "inside" ? theme.space.xs : 0, borderRadius: theme.radius },
|
|
42
|
+
}],
|
|
43
|
+
}
|
|
44
|
+
}
|