diff --git a/.gitignore b/.gitignore index 82e85c083..47dc82408 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ Claude.md .claude/ CLAUDE.local.md +ARCHITECTURE.md +TECH-DEBT.md # Dependencies node_modules diff --git a/docs/.gitignore b/docs/.gitignore index 8a11fd9b8..8764a6f66 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -11,6 +11,9 @@ /build *.tsbuildinfo +# Ai Planning +/adr + # misc .DS_Store *.pem diff --git a/eslint.config.cjs b/eslint.config.cjs index 1b1e1f02c..5cf8c091a 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -71,6 +71,7 @@ module.exports = [ allow: ["error", "warn", "info"], }, ], + "no-restricted-imports": "off", ...eslintPluginPrettier.configs.recommended.rules, "react-hooks/exhaustive-deps": "warn", }, diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index e95de180b..7db658caf 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -85,6 +85,10 @@ "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.7", "clsx": "^2.1.1", + "d3": "^7.9.0", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-shape": "^3.2.0", "date-fns": "^4.1.0", "lodash-es": "^4.17.21", "lucide-react": "^0.562.0", @@ -113,6 +117,10 @@ "@storybook/react-vite": "^8.5.3", "@storybook/test": "^8.5.3", "@storybook/theming": "^8.5.3", + "@types/d3": "^7.4.3", + "@types/d3-scale": "^4.0.9", + "@types/d3-selection": "^3.0.11", + "@types/d3-shape": "^3.1.8", "@types/lodash-es": "^4.17.12", "@types/node": "^22.12.0", "@types/node-fetch": "2.6.11", diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx new file mode 100644 index 000000000..a21de94b1 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx @@ -0,0 +1,22 @@ +import clsx from "clsx"; + +import { D3AreaChartCondensed } from "./D3AreaChartCondensed"; +import { D3AreaChartScrollable } from "./D3AreaChartScrollable"; + +import type { D3AreaChartData, D3AreaChartProps } from "./types"; + +export function D3AreaChart(props: D3AreaChartProps) { + if (!props.data || props.data.length === 0) { + return ( +
+ No data available +
+ ); + } + if (props.condensed) { + return ; + } + return ; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx new file mode 100644 index 000000000..a47c5edb7 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx @@ -0,0 +1,143 @@ +import { useCallback } from "react"; + +import { + useChartCondensedOrchestrator, + usePrintContext, + useStackedData, + useXScale, + useYScale, +} from "../hooks"; +import { AngledXAxis } from "../shared/cartesian/axes/AngledXAxis"; +import { CondensedChartLayout } from "../shared/cartesian/layouts/CondensedChartLayout"; +import { LineDotCrosshair } from "../shared/cartesian/LineDotCrosshair"; +import { findNearestDataIndex } from "../utils/mouseUtils"; +import { AreaSeries } from "./parts/AreaSeries"; +import { GradientDefs } from "./parts/GradientDefs"; + +import type { D3AreaChartData, D3AreaChartProps } from "./types"; + +export function D3AreaChartCondensed({ + data, + categoryKey, + theme: chartThemeName = "ocean", + customPalette, + variant = "natural", + stacked = true, + grid = true, + legend: showLegend = true, + icons, + isAnimationActive = false, + showYAxis = true, + xAxisLabel, + yAxisLabel, + className, + height, + width: fixedWidth, + fitLegendInHeight, + onClick, +}: D3AreaChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartCondensedOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + chartIdPrefix: "d3ac", + icons, + onClick, + }); + + const xScale = useXScale( + data, + orch.data.catKey, + orch.dimensions.chartAreaWidth, + orch.dimensions.widthPerDataPoint, + ); + const stackedData = useStackedData(data, orch.data.dataKeys, stacked); + const yScale = useYScale(data, orch.data.dataKeys, orch.dimensions.chartInnerHeight, stackedData); + + const getYValue = useCallback( + (_row: Record, key: string, seriesIndex: number) => { + if (stackedData && orch.hover.hoveredIndex !== null) { + const series = stackedData[seriesIndex]; + const point = series?.[orch.hover.hoveredIndex]; + return point ? point[1] : 0; + } + return Number(_row[key]) || 0; + }, + [stackedData, orch.hover.hoveredIndex], + ); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); + + return ( + + } + series={ + <> + + + + } + xAxis={ + + } + /> + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx new file mode 100644 index 000000000..1ff1e7ff8 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx @@ -0,0 +1,153 @@ +import { useCallback } from "react"; + +import { + useChartScrollableOrchestrator, + usePrintContext, + useStackedData, + useXScale, + useYScale, +} from "../hooks"; +import { LineDotCrosshair } from "../shared/cartesian/LineDotCrosshair"; +import { XAxis } from "../shared/cartesian/axes/XAxis"; +import { ScrollableChartLayout } from "../shared/cartesian/layouts/ScrollableChartLayout"; +import { findNearestDataIndex } from "../utils/mouseUtils"; +import { AreaSeries } from "./parts/AreaSeries"; +import { GradientDefs } from "./parts/GradientDefs"; + +import type { D3AreaChartData, D3AreaChartProps } from "./types"; + +export function D3AreaChartScrollable({ + data, + categoryKey, + theme: chartThemeName = "ocean", + customPalette, + variant = "natural", + tickVariant: tickVariantProp = "multiLine", + stacked = true, + grid = true, + legend: showLegend = true, + icons, + isAnimationActive = false, + showYAxis = true, + xAxisLabel, + yAxisLabel, + className, + height, + width: fixedWidth, + fitLegendInHeight, + condensed = false, + density, + onClick, +}: D3AreaChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartScrollableOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3ac", + icons, + onClick, + condensed, + density, + }); + + const xScale = useXScale( + data, + orch.data.catKey, + orch.dimensions.svgWidth, + orch.dimensions.widthOfGroup, + ); + const stackedData = useStackedData(data, orch.data.dataKeys, stacked); + const yScale = useYScale(data, orch.data.dataKeys, orch.dimensions.chartInnerHeight, stackedData); + + const getYValue = useCallback( + (_row: Record, key: string, seriesIndex: number) => { + if (stackedData && orch.hover.hoveredIndex !== null) { + const series = stackedData[seriesIndex]; + const point = series?.[orch.hover.hoveredIndex]; + return point ? point[1] : 0; + } + return Number(_row[key]) || 0; + }, + [stackedData, orch.hover.hoveredIndex], + ); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); + + return ( + + } + series={ + <> + + + + } + xAxis={ + + } + /> + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md b/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md new file mode 100644 index 000000000..6e4fc382f --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md @@ -0,0 +1,871 @@ +# D3 AreaChart — Design Document + +> **Status**: Draft +> **Package**: `@openuidev/react-ui` — `ChartsV2/D3AreaChart` > **Depends on**: `d3@^7`, React 19, existing OpenUI design tokens + +--- + +## 1. Why D3 Instead of Recharts + +The existing `Charts/AreaChart` wraps Recharts. This creates several pain points: + +- **Limited SVG control** — Recharts owns the entire SVG tree; customizing individual elements (gradients, crosshairs, clipping) requires hacks. +- **Bundle weight** — Recharts pulls in its own layout engine; D3 sub-packages are tree-shakeable and already installed as transitive deps. +- **Stacking flexibility** — Recharts `stackId` is all-or-nothing; D3's `d3.stack()` can be toggled per-render. +- **Animation control** — CSS transitions on D3-computed `d` attributes are simpler than Recharts' internal animation system. + +The V2 approach: **React owns the DOM (JSX ``), D3 computes data (scales, paths, ticks, stacking).** + +--- + +## 2. Directory Structure + +``` +ChartsV2/ +├── D3AreaChart/ +│ ├── DESIGN.md ← this document +│ ├── D3AreaChart.tsx ← main component +│ ├── d3AreaChart.scss ← component styles +│ ├── types.ts ← props + data types +│ ├── index.ts ← public exports +│ ├── stories/ +│ │ └── d3AreaChart.stories.tsx +│ └── parts/ +│ ├── AreaSeries.tsx ← elements for each series +│ ├── XAxis.tsx ← D3-computed X-axis ticks + labels +│ ├── YAxis.tsx ← D3-computed Y-axis ticks + labels +│ ├── Grid.tsx ← horizontal grid lines +│ ├── Crosshair.tsx ← vertical hover line + active dots +│ └── GradientDefs.tsx ← with per series +│ +├── shared/ ← copied & adapted from Charts/shared +│ ├── DefaultLegend/ ← legend with expand/collapse +│ │ ├── DefaultLegend.tsx +│ │ ├── defaultLegend.scss +│ │ └── hooks/ +│ │ └── useDefaultLegend.ts +│ ├── PortalTooltip/ ← @floating-ui tooltip +│ │ ├── ChartTooltip.tsx ← virtual-element tooltip (replaces CustomTooltipContent + FloatingUIPortal) +│ │ ├── portalTooltip.scss +│ │ └── utils/index.ts +│ ├── ScrollButtonsHorizontal/ +│ │ ├── ScrollButtonsHorizontal.tsx +│ │ └── scrollButtonsHorizontal.scss +│ ├── LabelTooltip/ +│ │ └── LabelTooltip.tsx +│ └── index.ts +│ +├── utils/ +│ ├── paletteUtils.ts ← palette definitions + useChartPalette +│ ├── dataUtils.ts ← getDataKeys, getLegendItems, etc. +│ ├── styleUtils.ts ← numberTickFormatter +│ ├── scrollUtils.ts ← snap positions, findNearestSnap +│ └── index.ts +│ +├── hooks/ +│ ├── useContainerSize.ts ← ResizeObserver wrapper +│ ├── useYAxisWidth.ts ← measure max Y-axis label width +│ ├── useXAxisHeight.ts ← measure max X-axis label height +│ ├── useTransformedKeys.ts ← stable UUID mapping for CSS vars +│ └── index.ts +│ +├── types/ +│ ├── common.ts ← LegendItem, XAxisTickVariant, etc. +│ └── index.ts +│ +├── chartsV2.scss ← forwards all V2 chart SCSS +└── index.ts ← public ChartsV2 barrel export +``` + +**Key principle**: everything is local to `ChartsV2`. No import paths reach back into `Charts/`. When we copy shared pieces, they become V2's own code to evolve independently. + +--- + +## 3. Props Interface + +```typescript +// D3AreaChart/types.ts + +export type D3AreaChartData = Array>; + +export type D3AreaChartVariant = "linear" | "natural" | "step"; + +export type XAxisTickVariant = "singleLine" | "multiLine"; + +export interface D3AreaChartProps { + /** Array of data objects. Each must have a category field + numeric fields. */ + data: T; + + /** Key in data objects to use for X-axis categories. */ + categoryKey: keyof T[number]; + + /** Color palette theme. Ignored when customPalette is provided. */ + theme?: PaletteName; // "ocean" | "orchid" | "emerald" | "sunset" | "spectrum" | "vivid" + + /** Custom color array. Overrides theme. */ + customPalette?: string[]; + + /** Curve interpolation. Default: "natural" */ + variant?: D3AreaChartVariant; + + /** X-axis label display mode. Default: "multiLine" */ + tickVariant?: XAxisTickVariant; + + /** Whether areas are stacked or overlapping. Default: true */ + stacked?: boolean; + + /** Show cartesian grid lines. Default: true */ + grid?: boolean; + + /** Show legend. Default: true */ + legend?: boolean; + + /** Icon map for legend items. */ + icons?: Partial>; + + /** Enable initial draw animation. Default: false */ + isAnimationActive?: boolean; + + /** Show Y-axis. Default: true */ + showYAxis?: boolean; + + /** X-axis descriptive label shown in legend footer. */ + xAxisLabel?: React.ReactNode; + + /** Y-axis descriptive label shown in legend footer. */ + yAxisLabel?: React.ReactNode; + + /** Additional CSS class. */ + className?: string; + + /** Chart height. Number = px, string = CSS value (e.g. "100%", "50vh"). Defaults to 296. */ + height?: number | string; + + /** Chart width. Number = px, string = CSS value (e.g. "100%", "500px"). Defaults to fill container. */ + width?: number | string; +} +``` + +The API is intentionally a superset of the existing `AreaChartProps` — the only addition is `stacked?: boolean`. + +--- + +## 4. D3 Rendering Strategy + +### 4.1 Scales + +``` +X-axis (categorical): d3.scalePoint() + .domain(data.map(d => d[categoryKey])) + .range([paddingLeft, chartWidth - paddingRight]) + .padding(0.5) + +Y-axis (linear): d3.scaleLinear() + .domain([0, maxValue]) // non-stacked + .domain([0, maxStackedValue]) // stacked + .range([chartInnerHeight, 0]) + .nice() +``` + +`scalePoint` is used rather than `scaleBand` because area/line charts plot at point centers, not across bar widths. + +### 4.2 Area Generator + +```typescript +import { area, curveLinear, curveMonotoneX, curveStepAfter } from "d3-shape"; + +const curveMap = { + linear: curveLinear, + natural: curveMonotoneX, // smooth monotone — matches Recharts "monotone" + step: curveStepAfter, +}; + +const areaGenerator = area() + .x((d) => xScale(d[categoryKey])) + .y0((d) => yScale(d._y0 ?? 0)) // baseline (0 for non-stacked, stack baseline for stacked) + .y1((d) => yScale(d._y1 ?? d[seriesKey])) + .curve(curveMap[variant]); +``` + +### 4.3 Stacking + +When `stacked={true}`: + +```typescript +import { stack, stackOrderNone, stackOffsetNone } from "d3-shape"; + +const stackGenerator = stack() + .keys(dataKeys) + .order(stackOrderNone) + .offset(stackOffsetNone); + +const stackedData = stackGenerator(data); +// stackedData[seriesIndex][dataPointIndex] = [y0, y1] +``` + +When `stacked={false}`: + +Each series renders independently from `y0 = 0` to `y1 = value`. The Y-axis domain uses the global max across all series. + +### 4.4 SVG Structure + +``` + + + ← one per series + + ← clips to chart inner area + + + + + + + + + + + + + + + + + + + + + ← one per visible series + + + + + + + +``` + +### 4.5 Why React JSX, Not D3 DOM Manipulation + +D3 is used **only** for math: scales, path generation, tick calculation, stacking. The actual SVG elements are rendered by React JSX. This means: + +- React reconciliation handles updates efficiently. +- No `d3.select().append()` — avoids fighting with React's virtual DOM. +- Hooks (`useMemo`, `useCallback`) cache expensive D3 computations. +- Easier to test: components are pure functions of props. + +--- + +## 5. Component Decomposition + +### 5.1 `D3AreaChart` (main orchestrator) + +Responsibilities: + +- Parse data: `getDataKeys(data, categoryKey)` to extract numeric series keys. +- Build color palette via `useChartPalette`. +- Build chart config for tooltip via `get2dChartConfig`. +- Compute `containerWidth` via `useContainerSize` (ResizeObserver). +- Compute `yAxisWidth` via `useYAxisWidth`. +- Compute `xAxisHeight` via `useXAxisHeight`. +- Decide if horizontal scroll is needed: `dataWidth > containerWidth`. +- Manage hover state: `hoveredIndex: number | null`. +- Manage legend expand/collapse state. +- Render the SVG tree + legend + scroll buttons + tooltip. + +### 5.2 `parts/AreaSeries` + +Props: `data`, `dataKeys`, `xScale`, `yScale`, `variant`, `stacked`, `colors`, `transformedKeys`, `gradientId`. + +Renders one `` + one `` per series key. Uses `d3.area()` and `d3.line()` generators. The area path uses the gradient fill; the line path uses a solid stroke. + +### 5.3 `parts/XAxis` + +Props: `scale`, `data`, `categoryKey`, `tickVariant`, `widthOfGroup`, `labelHeight`. + +Computes tick positions from `xScale.domain()`. Renders `` elements with `` containing HTML labels (same approach as existing `XAxisTick`). Handles truncation via CSS (`-webkit-line-clamp` for multiLine, `text-overflow: ellipsis` for singleLine). Wraps truncated labels in ``. + +### 5.4 `parts/YAxis` + +Props: `scale`, `width`. + +Calls `yScale.ticks()` to get nice tick values. Renders `` elements with `numberTickFormatter`. Reports max label width back via callback (for dynamic Y-axis sizing). + +### 5.5 `parts/Grid` + +Props: `yScale`, `chartWidth`. + +Renders horizontal `` elements at each Y-axis tick position. Uses `openui-chart-cartesian-grid` class for consistent styling with existing charts. + +### 5.6 `parts/Crosshair` + +Props: `hoveredIndex`, `xScale`, `yScale`, `data`, `dataKeys`, `colors`, `stacked`, `chartHeight`. + +When `hoveredIndex !== null`: + +- Renders a vertical `` at the hovered X position. +- Renders `` active dots at each series' Y position. +- Active dot style: outer ring (4px radius, foreground color) + inner dot (2px radius, series color). + +### 5.7 `parts/GradientDefs` + +Props: `dataKeys`, `transformedKeys`. + +Renders `` containing one `` per series. Gradient goes from 60% opacity at top to 0% opacity at bottom (matching current chart gradient: `stopOpacity={0.6}` at 5%, `stopOpacity={0}` at 95%). + +--- + +## 6. Interaction Design + +### 6.1 Hover / Tooltip + +``` +User mouses over chart body + → onMouseMove handler + → compute nearest data index from mouse X: + const mouseX = d3.pointer(event)[0] + const domain = xScale.domain() + const nearestIndex = d3.bisector(...) + OR iterate domain and find closest xScale(category) + → set hoveredIndex state + → Crosshair renders at that index + → Tooltip renders via FloatingUIPortal at mouse position +``` + +The tooltip payload is constructed by the `useTooltipPayload` hook: + +```typescript +interface TooltipItem { + name: string; // series key + value: number; // series value at hovered index + color: string; // series color +} + +interface TooltipPayload { + label: string; // category value at hovered index + items: TooltipItem[]; +} +``` + +The tooltip is positioned using viewport coordinates (`event.clientX`, `event.clientY`). `ChartTooltip` uses Floating UI's virtual element API (`getBoundingClientRect`) — no phantom DOM div — with `offset`, `flip`, and `shift` middleware. It portals to `document.body` with the theme class from `useTheme()`. + +**Touch support**: Same `touchstart`/`touchmove` handlers, with `touchend` clearing the hover state. + +### 6.2 Legend Toggle (New Feature) + +Clicking a legend item hides/shows the corresponding series. This requires: + +- `hiddenSeries: Set` state in the main component. +- Filter `dataKeys` to exclude hidden series before computing stack/areas. +- Legend items show as dimmed when hidden. + +### 6.3 Scroll Behavior + +When `dataWidth > effectiveContainerWidth`: + +- The SVG is rendered at full `dataWidth` inside a scroll container. +- The Y-axis is rendered in a separate fixed container to the left (same pattern as current chart). +- `ScrollButtonsHorizontal` appears below the chart. +- Snap positions computed from `getSnapPositions(data)`. +- Scroll events update `canScrollLeft` / `canScrollRight` button states. + +Layout: + +``` +┌──────────────────────────────────────────────┐ +│ ┌─────────┐ ┌─────────────────────────────┐ │ +│ │ Y-Axis │ │ Scrollable Chart Body │ │ +│ │ (fixed) │ │ (overflow-x: auto) │ │ +│ │ │ │ SVG width = dataWidth │ │ +│ └─────────┘ └─────────────────────────────┘ │ +│ [◀] ─────────────── [▶] │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Legend (with expand/collapse) │ │ +│ └──────────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +--- + +## 7. Responsiveness + +### 7.1 Container Width + +```typescript +// hooks/useContainerSize.ts +export function useContainerSize( + ref: React.RefObject, + fixedWidth?: number, +): { width: number } { + const [width, setWidth] = useState(0); + + useEffect(() => { + if (fixedWidth || !ref.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setWidth(entry.contentRect.width); + } + }); + + observer.observe(ref.current); + setWidth(ref.current.getBoundingClientRect().width); + + return () => observer.disconnect(); + }, [fixedWidth]); + + return { width: fixedWidth ?? width }; +} +``` + +### 7.2 Adaptive Layout + +| Container width | Behavior | +| --------------- | ----------------------------------------------------------- | +| `>= dataWidth` | No scroll. SVG fills container. | +| `< dataWidth` | Horizontal scroll enabled. Scroll buttons appear. | +| `< 300px` | X-axis switches to `singleLine` tick variant automatically. | + +### 7.3 Chart Height + +``` +chartHeight = height ?? (296 + xAxisLabelHeight) +``` + +The 296px default matches the current chart. `xAxisLabelHeight` is measured dynamically via `useXAxisHeight`. + +--- + +## 8. Theming & Styling + +### 8.1 Design Tokens + +All styles use `cssUtils` tokens per the project's styling rule: + +```scss +@use "../../../../cssUtils" as cssUtils; + +.openui-d3-area-chart { + // Container + &-container { + width: 100%; + } + + &-container-inner { + display: flex; + width: 100%; + } + + &-y-axis-container { + flex-shrink: 0; + } + + &-main-container { + width: 100%; + overflow-x: auto; + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; + -ms-overflow-style: none; + } +} + +// Grid lines +.openui-d3-area-chart-grid line { + stroke: cssUtils.$border-default; + stroke-dasharray: 3 3; +} + +// Axis ticks +.openui-d3-area-chart-y-tick { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$text-neutral-secondary; +} + +.openui-d3-area-chart-x-tick-multi-line { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-secondary; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-align: center; + word-break: break-word; +} + +.openui-d3-area-chart-x-tick-single-line { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-secondary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +// Crosshair +.openui-d3-area-chart-crosshair-line { + stroke: cssUtils.$border-default; + stroke-width: 1; + stroke-dasharray: 4 4; +} + +// Area paths +.openui-d3-area-chart-area-path { + transition: opacity 0.2s ease; +} + +.openui-d3-area-chart-area-line { + fill: none; + stroke-width: 2; +} +``` + +### 8.2 Series Colors via CSS Variables + +Same pattern as existing charts. The main component injects CSS variables: + +```typescript +const chartStyle = dataKeys.reduce( + (styles, key) => { + const transformedKey = transformedKeys[key]; + const color = chartConfig[key]?.color; + return { + ...styles, + [`--color-${transformedKey}`]: color, + }; + }, + {} as Record, +); +``` + +Area paths reference these variables: `stroke: var(--color-{transformedKey})`. + +### 8.3 Palette System + +Copied from `Charts/utils/PalletUtils.ts` into `ChartsV2/utils/paletteUtils.ts`. Six palettes: + +| Name | Character | +| ---------- | ------------------------------ | +| `ocean` | Blues: `#0D47A1` → `#EFF8FF` | +| `orchid` | Purples: `#3A365B` → `#F7EFFF` | +| `emerald` | Greens: `#10451D` → `#DCFFE5` | +| `spectrum` | Blue-to-red diverging | +| `sunset` | Purple → orange → yellow | +| `vivid` | Multi-hue rainbow | + +`getDistributedColors(palette, dataLength)` picks evenly-spaced colors centered on the palette midpoint. + +--- + +## 9. Hooks Reference + +### `useContainerSize(ref, fixedWidth?)` + +Returns `{ width }`. Uses `ResizeObserver` when no `fixedWidth` is provided. + +### `useYAxisWidth(data, dataKeys)` + +Measures the widest formatted Y-axis label using a hidden `` for text measurement. Returns `{ yAxisWidth, setLabelWidth }`. Clamps between 20px and 200px. + +**Source**: Adapted from `Charts/hooks/useYAxisLabelWidth.tsx`. + +### `useXAxisHeight(data, categoryKey, tickVariant, widthOfGroup)` + +Measures the tallest X-axis label by creating a hidden DOM element and reading its `getBoundingClientRect`. Returns pixel height. + +**Source**: Adapted from `Charts/hooks/useMaxLabelHeight.tsx`. + +### `useTransformedKeys(keys)` + +Maps each data key to a stable UUID for use in CSS variable names and gradient IDs. Cached in a ref so UUIDs persist across re-renders. + +**Source**: Copied from `Charts/hooks/useTransformKey.tsx`. + +### `useChartPalette({ chartThemeName, customPalette, dataLength })` + +Returns an array of colors for the data series. Reads the theme context palette first, falls back to built-in palettes. + +**Source**: Adapted from `Charts/utils/PalletUtils.ts`. + +--- + +## 10. Shared Components to Copy + +Each of these is copied from `Charts/shared/` into `ChartsV2/shared/` with modifications noted: + +### `DefaultLegend` + +- **Source**: `Charts/shared/DefaultLegend/` +- **Changes**: Remove recharts dependency. Add `onItemClick(key)` callback prop for legend toggle. Dimmed style for hidden series. +- **Files**: `DefaultLegend.tsx`, `defaultLegend.scss`, `hooks/useDefaultLegend.ts` + +### `PortalTooltip` + +- **Source**: `Charts/shared/PortalTooltip/` +- **Changes**: Replaced `CustomTooltipContent` + `FloatingUIPortal` with a single `ChartTooltip` component using Floating UI's virtual element API. Accepts viewport coordinates directly, no phantom DOM div or manual scroll-clipping. +- **Files**: `ChartTooltip.tsx`, `portalTooltip.scss`, `utils/index.ts` + +### `ScrollButtonsHorizontal` + +- **Source**: `Charts/shared/ScrollButtonsHorizontal/` +- **Changes**: Remove `isSideBarTooltipOpen` prop (SideBarTooltip is deferred). Otherwise identical. +- **Files**: `ScrollButtonsHorizontal.tsx`, `scrollButtonsHorizontal.scss` + +### `LabelTooltip` + +- **Source**: `Charts/shared/LabelTooltip/` +- **Changes**: None — pure Radix tooltip wrapper, no recharts dependency. +- **Files**: `LabelTooltip.tsx` + +--- + +## 11. Utility Functions to Copy + +### `dataUtils.ts` + +From `Charts/utils/dataUtils.ts`. Functions: + +- `getDataKeys(data, categoryKey)` — extract numeric keys from first data row. +- `get2dChartConfig(dataKeys, colors, transformedKeys, secondaryColors?, icons?)` — build config map. +- `getLegendItems(dataKeys, colors, icons?)` — build legend item array. +- `getColorForDataKey(dataKey, dataKeys, colors)` — lookup color by key. + +### `styleUtils.ts` + +From `Charts/utils/styleUtils.ts`. Functions: + +- `numberTickFormatter(value)` — formats large numbers as K/M/B/T. + +### `scrollUtils.ts` + +From `Charts/utils/AreaAndLine/AreaAndLineUtils.ts`. Functions: + +- `getWidthOfGroup(data)` — returns 72px (fixed element spacing). +- `getWidthOfData(data, containerWidth)` — total data width or container width (whichever is larger). +- `getSnapPositions(data)` — array of scroll snap offsets. +- `findNearestSnapPosition(snapPositions, currentScroll, direction)` — for scroll button navigation. + +--- + +## 12. Animation Strategy + +### 12.1 Initial Draw Animation (`isAnimationActive`) + +**Approach**: CSS `stroke-dasharray` / `stroke-dashoffset` reveal. + +```scss +.openui-d3-area-chart-area-line--animated { + stroke-dasharray: var(--path-length); + stroke-dashoffset: var(--path-length); + animation: draw-line 1s ease-out forwards; +} + +@keyframes draw-line { + to { + stroke-dashoffset: 0; + } +} + +.openui-d3-area-chart-area-path--animated { + opacity: 0; + animation: fade-in 0.6s ease-out 0.4s forwards; +} + +@keyframes fade-in { + to { + opacity: 1; + } +} +``` + +The `--path-length` CSS variable is set via `ref.current.getTotalLength()` after mount. + +### 12.2 Data Update Transitions + +When data changes, area `d` attributes update. CSS transitions handle the interpolation: + +```scss +.openui-d3-area-chart-area-path { + transition: + d 0.3s ease-out, + opacity 0.2s ease; +} +``` + +Note: CSS `transition` on `d` is supported in modern browsers (Chrome 114+, Firefox 97+, Safari 17.5+). For older browsers, the change is instant (progressive enhancement). + +--- + +## 13. Data Flow Diagram + +``` +Props (data, categoryKey, theme, stacked, variant, ...) + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ D3AreaChart (main component) │ +│ │ +│ dataKeys = getDataKeys(data, categoryKey) │ +│ colors = useChartPalette(theme, dataKeys.length) │ +│ transformedKeys = useTransformedKeys(dataKeys) │ +│ containerWidth = useContainerSize(ref, width) │ +│ yAxisWidth = useYAxisWidth(data, dataKeys) │ +│ xAxisHeight = useXAxisHeight(data, categoryKey) │ +│ │ +│ xScale = d3.scalePoint(...) │ +│ yScale = d3.scaleLinear(...) │ +│ stackedData = stacked ? d3.stack()(...) : null │ +│ │ +│ dataWidth = getWidthOfData(data, containerWidth) │ +│ needsScroll = dataWidth > containerWidth │ +│ │ +│ [hoveredIndex, setHoveredIndex] = useState(null) │ +│ [hiddenSeries, setHiddenSeries] = useState(new Set) │ +│ [isLegendExpanded, setIsLegendExpanded] = useState(false) │ +└─────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Render Tree │ +│ │ +│
│ +│
│ +│ │ ← fixed left +│ │ +│ │ +│
│ ← scrollable +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│
│ +│
│ +│ │ +│ │ +│ │ ← portal tooltip (virtual element) +│
│ +└─────────────────────────────────────────────┘ +``` + +--- + +## 14. Mapping: Recharts AreaChart → D3 AreaChart + +| Recharts Concept | D3 Equivalent | Notes | +| ------------------------------------ | ---------------------------------------------------- | ---------------------------------------- | +| `` | `d3.stack()` + `d3.area()` computed in `useMemo` | React renders ``, D3 does math only | +| `` | `` | One `` per series | +| `` | `xScale.domain().map(...)` → `` ticks | Same HTML-in-SVG approach for text | +| `` | `yScale.ticks()` → `` elements | Simpler, no foreignObject needed | +| `` | `yScale.ticks()` → `` elements | Horizontal lines only | +| `` | `onMouseMove` → `ChartTooltip` (virtual element) | Decoupled from recharts context | +| `` | `useContainerSize()` hook | `ResizeObserver` directly | +| `activeDot={}` | `` component | Renders dots at hovered index | +| `` in `` | Same — `` component | Identical SVG pattern | +| `ChartContainer` + `ChartStyle` | CSS vars injected via `style` prop | No recharts `ChartContext` needed | + +--- + +## 15. Testing Plan + +### Unit Tests + +- `dataUtils`: verify `getDataKeys`, `getLegendItems` with various data shapes. +- `scrollUtils`: verify snap positions, nearest snap finding. +- `numberTickFormatter`: verify K/M/B/T formatting. +- D3 computations: verify scale domains/ranges, stacked vs non-stacked area paths. + +### Component Tests (React Testing Library) + +- Renders correct number of area `` elements. +- Y-axis tick labels formatted correctly. +- X-axis labels truncated when exceeding group width. +- Hover triggers crosshair and tooltip. +- Legend expand/collapse behavior. +- Legend toggle hides/shows series. +- Scroll buttons appear when data overflows. + +### Visual Tests (Storybook) + +- All stories from existing AreaChart replicated (DataExplorer, BigLabels, DenseTimeline, etc.). +- New stories: stacked vs overlapping comparison, legend toggle demo. + +--- + +## 16. Migration Path + +1. **Phase 1** (this document): Design and architecture. +2. **Phase 2**: Implement core — `D3AreaChart` with scales, area paths, axes, grid. No tooltip or legend. +3. **Phase 3**: Add tooltip (copy + adapt `PortalTooltip`), crosshair, active dots. +4. **Phase 4**: Add legend (copy + adapt `DefaultLegend`), legend toggle. +5. **Phase 5**: Add scroll behavior (copy `ScrollButtonsHorizontal`), snap positions. +6. **Phase 6**: Animation, polish, Storybook stories. +7. **Phase 7**: Export from `ChartsV2/index.ts`, add to main package exports. + +Each phase is independently testable and deployable. + +--- + +## 17. Open Questions + +- **Accessibility**: Should the chart include ARIA labels for screen readers? The current Recharts chart uses `accessibilityLayer`. We should add `role="img"` with `aria-label` at minimum. +- **Print context**: The current chart disables animation in print mode via `usePrintContext`. Should V2 support this from day one? +- **Export data**: The current chart supports `data-openui-chart` attribute for PPTX export. Include in V2? + +--- + +## Appendix A: D3 Imports + +Only the specific D3 sub-packages needed (tree-shakeable): + +```typescript +// Scales +import { scaleLinear, scalePoint } from "d3-scale"; + +// Shape generators +import { area, line, stack, stackOrderNone, stackOffsetNone } from "d3-shape"; + +// Curves +import { curveLinear, curveMonotoneX, curveStepAfter } from "d3-shape"; + +// Interaction +import { pointer, bisector } from "d3-selection"; + +// Formatting (optional — we have our own numberTickFormatter) +import { format } from "d3-format"; +``` + +These are all already installed as transitive dependencies of `d3@^7`. + +--- + +## Appendix B: Existing File References + +These are the source files from `Charts/` that this design is based on. Agents implementing this design should read these for reference: + +| File | Purpose | +| ------------------------------------------------------------------- | ----------------------------------------------------- | +| `Charts/AreaChart/AreaChart.tsx` | Main component — rendering logic, state management | +| `Charts/AreaChart/areaChart.scss` | Layout styles | +| `Charts/AreaChart/types/index.ts` | Data and variant types | +| `Charts/Charts.tsx` | ChartContainer, ChartConfig, ChartStyle, ChartTooltip | +| `Charts/utils/PalletUtils.ts` | Palette definitions, useChartPalette | +| `Charts/utils/dataUtils.ts` | getDataKeys, get2dChartConfig, getLegendItems | +| `Charts/utils/styleUtils.ts` | numberTickFormatter | +| `Charts/utils/AreaAndLine/AreaAndLineUtils.ts` | Scroll/snap utilities | +| `Charts/shared/DefaultLegend/DefaultLegend.tsx` | Legend component | +| `Charts/shared/PortalTooltip/CustomTooltipContent.tsx` | Tooltip content | +| `Charts/shared/PortalTooltip/FloatingUIPortal.tsx` | Portal positioning | +| `Charts/shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx` | Scroll buttons | +| `Charts/shared/XAxisTick/XAxisTick.tsx` | X-axis label rendering | +| `Charts/shared/YAxisTick/YAxisTick.tsx` | Y-axis label rendering | +| `Charts/shared/ActiveDot/ActiveDot.tsx` | Hover dot rendering | +| `Charts/hooks/useYAxisLabelWidth.tsx` | Y-axis width calculation | +| `Charts/hooks/useMaxLabelHeight.tsx` | X-axis height calculation | +| `Charts/hooks/useTransformKey.tsx` | Stable UUID mapping | +| `Charts/hooks/useCanvasContextForLabelSize.ts` | Canvas text measurement | diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss new file mode 100644 index 000000000..b138e86a1 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss @@ -0,0 +1,39 @@ +@use "../shared/cartesian/chartBase" as base; + +@include base.chart-base("area-chart"); +@include base.crosshair-styles("area-chart"); + +.openui-d3-area-chart-area-path { + transition: + d 0.3s ease-out, + opacity 0.2s ease; +} + +.openui-d3-area-chart-area-line { + fill: none; + stroke-width: 2; + transition: d 0.3s ease-out; +} + +.openui-d3-area-chart-area-line--animated { + stroke-dasharray: var(--path-length); + stroke-dashoffset: var(--path-length); + animation: openui-d3-draw-line 1s ease-out forwards; +} + +@keyframes openui-d3-draw-line { + to { + stroke-dashoffset: 0; + } +} + +.openui-d3-area-chart-area-path--animated { + opacity: 0; + animation: openui-d3-fade-in 0.6s ease-out 0.4s forwards; +} + +@keyframes openui-d3-fade-in { + to { + opacity: 1; + } +} diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/index.ts b/packages/react-ui/src/components/ChartsV2/D3AreaChart/index.ts new file mode 100644 index 000000000..f0d272ac2 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/index.ts @@ -0,0 +1,2 @@ +export { D3AreaChart } from "./D3AreaChart"; +export type { D3AreaChartData, D3AreaChartProps, D3AreaChartVariant } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx new file mode 100644 index 000000000..7e307cc6d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx @@ -0,0 +1,134 @@ +import type { ScaleLinear, ScalePoint } from "d3-scale"; +import { + curveLinear, + curveMonotoneX, + curveStepAfter, + area as d3Area, + line as d3Line, +} from "d3-shape"; +import React, { useEffect, useMemo, useRef } from "react"; +import type { StackedData } from "../../hooks"; +import { D3AreaChartVariant } from "../types"; + +const curveMap = { + linear: curveLinear, + natural: curveMonotoneX, + step: curveStepAfter, +}; + +interface AreaSeriesProps { + data: Array>; + dataKeys: string[]; + xScale: ScalePoint; + yScale: ScaleLinear; + variant: D3AreaChartVariant; + stackedData: StackedData | null; + categoryKey: string; + transformedKeys: Record; + colors: Record; + chartId: string; + isAnimationActive?: boolean; +} + +export const AreaSeries: React.FC = ({ + data, + dataKeys, + xScale, + yScale, + variant, + stackedData, + categoryKey, + transformedKeys, + colors, + chartId, + isAnimationActive, +}) => { + const curve = curveMap[variant]; + + const seriesPaths = useMemo(() => { + if (stackedData) { + return stackedData.map((series) => { + const areaGenerator = d3Area<[number, number]>() + .x((_, i) => xScale(String(data[i]![categoryKey])) ?? 0) + .y0((d) => yScale(d[0])) + .y1((d) => yScale(d[1])) + .curve(curve); + + const lineGenerator = d3Line<[number, number]>() + .x((_, i) => xScale(String(data[i]![categoryKey])) ?? 0) + .y((d) => yScale(d[1])) + .curve(curve); + + return { + key: series.key, + areaPath: areaGenerator(series as unknown as [number, number][]) ?? "", + linePath: lineGenerator(series as unknown as [number, number][]) ?? "", + }; + }); + } else { + return dataKeys.map((key) => { + const areaGenerator = d3Area>() + .x((d) => xScale(String(d[categoryKey])) ?? 0) + .y0(() => yScale(0)) + .y1((d) => yScale(Number(d[key]) || 0)) + .curve(curve); + + const lineGenerator = d3Line>() + .x((d) => xScale(String(d[categoryKey])) ?? 0) + .y((d) => yScale(Number(d[key]) || 0)) + .curve(curve); + + return { + key, + areaPath: areaGenerator(data) ?? "", + linePath: lineGenerator(data) ?? "", + }; + }); + } + }, [data, dataKeys, xScale, yScale, variant, stackedData, categoryKey, curve]); + + return ( + + {seriesPaths.map(({ key, areaPath, linePath }) => { + const transformedKey = transformedKeys[key]; + const color = colors[key] ?? "#000"; + return ( + + + + + ); + })} + + ); +}; + +const AnimatedLine: React.FC<{ + linePath: string; + color: string; + isAnimationActive?: boolean; +}> = ({ linePath, color, isAnimationActive }) => { + const lineRef = useRef(null); + + useEffect(() => { + if (isAnimationActive && lineRef.current) { + const length = lineRef.current.getTotalLength(); + lineRef.current.style.setProperty("--path-length", String(length)); + } + }, [linePath, isAnimationActive]); + + return ( + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx new file mode 100644 index 000000000..b9566ff5c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { ClipDefs } from "../../shared/cartesian/ClipDefs"; + +const GRADIENT_TOP_OPACITY = 0.6; +const GRADIENT_BOTTOM_OPACITY = 0; + +interface GradientDefsProps { + dataKeys: string[]; + transformedKeys: Record; + colors: Record; + chartId: string; + chartWidth: number; + chartHeight: number; +} + +export const GradientDefs: React.FC = ({ + dataKeys, + transformedKeys, + colors, + chartId, + chartWidth, + chartHeight, +}) => { + return ( + + + {dataKeys.map((key) => { + const transformedKey = transformedKeys[key]; + const color = colors[key] ?? "#000"; + return ( + + + + + ); + })} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx new file mode 100644 index 000000000..41407b023 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx @@ -0,0 +1,983 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useCallback, useState } from "react"; +import { Card } from "../../../Card"; +import { D3AreaChart } from "../D3AreaChart"; +import type { D3AreaChartProps } from "../types"; + +const dataVariations = { + default: [ + { month: "January", desktop: 150, mobile: 90, tablet: 120 }, + { month: "February", desktop: 280, mobile: 180, tablet: 140 }, + { month: "March", desktop: 220, mobile: 140, tablet: 160 }, + { month: "April", desktop: 180, mobile: 160, tablet: 180 }, + { month: "May", desktop: 250, mobile: 120, tablet: 140 }, + { month: "June", desktop: 300, mobile: 180, tablet: 160 }, + { month: "July", desktop: 350, mobile: 220, tablet: 180 }, + { month: "August", desktop: 400, mobile: 240, tablet: 200 }, + { month: "September", desktop: 450, mobile: 260, tablet: 220 }, + { month: "October", desktop: 500, mobile: 280, tablet: 240 }, + { month: "November", desktop: 550, mobile: 300, tablet: 260 }, + { month: "December", desktop: 600, mobile: 320, tablet: 280 }, + ], + bigLabels: [ + { + category: "Very Long Category Name That Should Be Truncated", + sales: 150, + revenue: 90, + profit: 120, + }, + { + category: "Another Extremely Long Label That Causes Collisions", + sales: 280, + revenue: 180, + profit: 140, + }, + { + category: "Super Duper Long Category Name That Tests Truncation", + sales: 220, + revenue: 140, + profit: 160, + }, + { + category: "Incredibly Long Text That Should Trigger Collision Detection", + sales: 180, + revenue: 160, + profit: 180, + }, + { + category: "Maximum Length Category Name That Tests All Edge Cases", + sales: 250, + revenue: 120, + profit: 140, + }, + { + category: "Extra Long Business Category Name With Many Words", + sales: 300, + revenue: 180, + profit: 160, + }, + { + category: "Comprehensive Long Label For Testing Horizontal Offset", + sales: 350, + revenue: 220, + profit: 180, + }, + { + category: "Extended Category Name That Pushes Truncation Limits", + sales: 400, + revenue: 240, + profit: 200, + }, + ], + denseTimeline: [ + { period: "Q1 2022 Jan-Mar", visitors: 120, conversions: 15, revenue: 1200 }, + { period: "Q1 2022 Apr-Jun", visitors: 150, conversions: 22, revenue: 1800 }, + { period: "Q2 2022 Jul-Sep", visitors: 180, conversions: 28, revenue: 2100 }, + { period: "Q2 2022 Oct-Dec", visitors: 200, conversions: 35, revenue: 2500 }, + { period: "Q3 2023 Jan-Mar", visitors: 160, conversions: 18, revenue: 1600 }, + { period: "Q3 2023 Apr-Jun", visitors: 190, conversions: 32, revenue: 2300 }, + { period: "Q4 2023 Jul-Sep", visitors: 220, conversions: 40, revenue: 2800 }, + { period: "Q4 2023 Oct-Dec", visitors: 240, conversions: 45, revenue: 3200 }, + { period: "Q1 2024 Jan-Mar", visitors: 210, conversions: 38, revenue: 2700 }, + { period: "Q1 2024 Apr-Jun", visitors: 230, conversions: 42, revenue: 3000 }, + { period: "Q2 2024 Jul-Sep", visitors: 250, conversions: 48, revenue: 3400 }, + { period: "Q2 2024 Oct-Dec", visitors: 270, conversions: 52, revenue: 3800 }, + { period: "Q3 2024 Jan-Mar", visitors: 260, conversions: 50, revenue: 3600 }, + { period: "Q3 2024 Apr-Jun", visitors: 280, conversions: 55, revenue: 4000 }, + { period: "Q4 2024 Jul-Sep", visitors: 300, conversions: 60, revenue: 4300 }, + { period: "Q4 2024 Oct-Dec", visitors: 290, conversions: 58, revenue: 4100 }, + ], + companyNames: [ + { company: "Apple Inc.", revenue: 394328000000, profit: 99803000000, marketCap: 3500000000000 }, + { + company: "Microsoft Corporation", + revenue: 211915000000, + profit: 83383000000, + marketCap: 2800000000000, + }, + { + company: "Alphabet Inc. (Google)", + revenue: 307394000000, + profit: 76033000000, + marketCap: 2100000000000, + }, + { + company: "Amazon.com Inc.", + revenue: 574785000000, + profit: 33364000000, + marketCap: 1600000000000, + }, + { + company: "Tesla Motors Inc.", + revenue: 96773000000, + profit: 15000000000, + marketCap: 800000000000, + }, + { + company: "Meta Platforms Inc.", + revenue: 134902000000, + profit: 39370000000, + marketCap: 900000000000, + }, + { + company: "NVIDIA Corporation", + revenue: 60922000000, + profit: 29760000000, + marketCap: 1800000000000, + }, + { + company: "Berkshire Hathaway Inc.", + revenue: 364482000000, + profit: 96223000000, + marketCap: 780000000000, + }, + ], + countryData: [ + { country: "United States of America", population: 331900000, gdp: 26900000000000 }, + { country: "People's Republic of China", population: 1412000000, gdp: 17700000000000 }, + { country: "Federal Republic of Germany", population: 83200000, gdp: 4300000000000 }, + { country: "United Kingdom of Great Britain", population: 67500000, gdp: 3100000000000 }, + { country: "French Republic", population: 68000000, gdp: 2900000000000 }, + { country: "Republic of India", population: 1380000000, gdp: 3700000000000 }, + { country: "Federative Republic of Brazil", population: 215000000, gdp: 2100000000000 }, + { country: "Russian Federation", population: 146000000, gdp: 1800000000000 }, + ], + mixedLengths: [ + { item: "A", valueA: 100, valueB: 80 }, + { item: "Short", valueA: 150, valueB: 120 }, + { item: "Medium Length Item", valueA: 200, valueB: 160 }, + { item: "Very Long Item Name That Tests Truncation", valueA: 250, valueB: 200 }, + { item: "B", valueA: 180, valueB: 140 }, + { item: "Another Really Long Category Name", valueA: 220, valueB: 180 }, + { item: "XL", valueA: 190, valueB: 150 }, + ], + edgeCases: [ + { name: "SinglePointDataSetForTestingEdgeCasesInCollisionDetection", value: 500 }, + { name: "SecondExtremelyLongDataPointNameThatShouldTriggerTruncation", value: 600 }, + ], + minimal: [ + { category: "Mobile Devices", users: 150, sessions: 90 }, + { category: "Desktop Computers", users: 280, sessions: 180 }, + { category: "Tablet Devices", users: 220, sessions: 140 }, + ], + expandCollapseMarketing: [ + { + channel: "Website Traffic and Organic Search Results", + impressions: 120000, + clicks: 15000, + conversions: 1200, + cost: 8500, + revenue: 24000, + roi: 182, + ctr: 12.5, + cpc: 0.57, + cpa: 7.08, + reach: 95000, + engagement: 8200, + shares: 420, + saves: 180, + comments: 650, + videoViews: 0, + }, + { + channel: "Social Media Engagement and Brand Awareness", + impressions: 85000, + clicks: 12000, + conversions: 950, + cost: 6200, + revenue: 19000, + roi: 206, + ctr: 14.1, + cpc: 0.52, + cpa: 6.53, + reach: 72000, + engagement: 15400, + shares: 890, + saves: 340, + comments: 1200, + videoViews: 28000, + }, + { + channel: "Email Marketing Campaign Performance", + impressions: 45000, + clicks: 8500, + conversions: 800, + cost: 2100, + revenue: 16000, + roi: 562, + ctr: 18.9, + cpc: 0.25, + cpa: 2.63, + reach: 42000, + engagement: 6800, + shares: 120, + saves: 85, + comments: 240, + videoViews: 0, + }, + { + channel: "Paid Advertising and PPC Campaign ROI", + impressions: 95000, + clicks: 18000, + conversions: 1500, + cost: 12500, + revenue: 30000, + roi: 140, + ctr: 18.9, + cpc: 0.69, + cpa: 8.33, + reach: 88000, + engagement: 12600, + shares: 320, + saves: 150, + comments: 480, + videoViews: 5200, + }, + { + channel: "Content Marketing and Blog Performance", + impressions: 60000, + clicks: 9500, + conversions: 750, + cost: 4800, + revenue: 15000, + roi: 213, + ctr: 15.8, + cpc: 0.51, + cpa: 6.4, + reach: 55000, + engagement: 7800, + shares: 680, + saves: 420, + comments: 950, + videoViews: 12000, + }, + { + channel: "Mobile Application Downloads and Usage", + impressions: 70000, + clicks: 11000, + conversions: 1100, + cost: 5500, + revenue: 22000, + roi: 300, + ctr: 15.7, + cpc: 0.5, + cpa: 5.0, + reach: 65000, + engagement: 9200, + shares: 180, + saves: 95, + comments: 320, + videoViews: 8500, + }, + ], +}; + +const categoryKeys: Record = { + default: "month", + bigLabels: "category", + denseTimeline: "period", + companyNames: "company", + countryData: "country", + mixedLengths: "item", + edgeCases: "name", + minimal: "category", + expandCollapseMarketing: "channel", +}; + +const areaChartData = dataVariations.default; + +const meta: Meta> = { + title: "Components/ChartsV2/D3AreaChart", + component: D3AreaChart, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + theme: { + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + }, + variant: { + control: "radio", + options: ["linear", "natural", "step"], + }, + stacked: { control: "boolean" }, + grid: { control: "boolean" }, + legend: { control: "boolean" }, + showYAxis: { control: "boolean" }, + isAnimationActive: { control: "boolean" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DataExplorer: Story = { + name: "Data Explorer", + args: { + data: areaChartData, + categoryKey: "month", + theme: "ocean", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: false, + showYAxis: true, + xAxisLabel: "Time Period", + yAxisLabel: "Values", + }, + render: (args: any) => { + const [selectedDataType, setSelectedDataType] = + useState("default"); + const currentData = dataVariations[selectedDataType]; + const currentCategoryKey = categoryKeys[selectedDataType]; + + const buttonStyle = { + margin: "2px", + padding: "6px 12px", + fontSize: "12px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + background: "#fff", + fontFamily: "monospace", + }; + const activeButtonStyle = { + ...buttonStyle, + background: "#007acc", + color: "white", + border: "1px solid #007acc", + }; + + return ( +
+
+ D3 AreaChart Explorer: +
+ {Object.keys(dataVariations).map((key) => ( + + ))} +
+
+ Dataset: {selectedDataType} | Items: {currentData.length} | Category:{" "} + {currentCategoryKey} +
+
+ + + +
+ ); + }, +}; + +export const BigLabelsStory: Story = { + name: "Big Labels", + args: { + data: dataVariations.bigLabels as any, + categoryKey: "category" as any, + theme: "emerald", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const DenseTimelineStory: Story = { + name: "Dense Timeline", + args: { + data: dataVariations.denseTimeline as any, + categoryKey: "period" as any, + theme: "sunset", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const CompanyNamesStory: Story = { + name: "Company Names", + args: { + data: dataVariations.companyNames as any, + categoryKey: "company" as any, + theme: "vivid", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const CountryDataStory: Story = { + name: "Country Names", + args: { + data: dataVariations.countryData as any, + categoryKey: "country" as any, + theme: "orchid", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const MixedLengthsStory: Story = { + name: "Mixed Lengths", + args: { + data: dataVariations.mixedLengths as any, + categoryKey: "item" as any, + theme: "spectrum", + variant: "linear", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const EdgeCasesStory: Story = { + name: "Edge Cases", + args: { + data: dataVariations.edgeCases as any, + categoryKey: "name" as any, + theme: "ocean", + variant: "step", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const MinimalDataStory: Story = { + name: "Minimal Data", + args: { + data: dataVariations.minimal as any, + categoryKey: "category" as any, + theme: "emerald", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const ExpandCollapseMarketingStory: Story = { + name: "Legend Expand/Collapse", + args: { + data: dataVariations.expandCollapseMarketing as any, + categoryKey: "channel" as any, + theme: "vivid", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const StackedVsOverlapping: Story = { + name: "Stacked vs Overlapping", + render: () => ( +
+
+

Stacked (default)

+ + + +
+
+

Overlapping

+ + + +
+
+ ), +}; + +export const CustomPaletteStory: Story = { + name: "Custom Palette", + args: { + data: dataVariations.default as any, + categoryKey: "month" as any, + customPalette: ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8"], + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + xAxisLabel: "Month", + yAxisLabel: "Traffic", + }, + render: (args: any) => ( + + + + ), +}; + +export const ResponsiveBehaviorDemo: Story = { + name: "Responsive Behavior", + args: { + data: dataVariations.bigLabels as any, + categoryKey: "category" as any, + theme: "sunset", + variant: "natural", + grid: true, + legend: true, + isAnimationActive: false, + showYAxis: true, + }, + render: (args: any) => { + const [width, setWidth] = useState(700); + + return ( +
+
+ +
+ + + +
+ ); + }, +}; + +export const AnimationDemo: Story = { + name: "Animation Demo", + args: { + data: dataVariations.default as any, + categoryKey: "month" as any, + theme: "orchid", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + isAnimationActive: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const FixedPixelDimensions: Story = { + name: "Fixed Pixel Dimensions", + render: () => ( +
+
+

+ width={400} height={200} +

+ + + +
+
+

+ width={700} height={400} +

+ + + +
+
+ ), +}; + +export const StringDimensions: Story = { + name: "String Dimensions (%, px, vh)", + render: () => ( +
+
+

+ width="100%" (fills parent) — no height prop (default 296px) +

+ + + +
+
+

+ width="500px" height="250px" +

+ + + +
+
+

+ width="50%" height="300px" — inside a 800px parent +

+ + + +
+
+ ), +}; + +export const DynamicSizing: Story = { + name: "Dynamic Sizing Playground", + render: () => { + const [widthMode, setWidthMode] = useState<"number" | "percent" | "px">("number"); + const [widthVal, setWidthVal] = useState(600); + const [heightMode, setHeightMode] = useState<"default" | "number" | "px">("default"); + const [heightVal, setHeightVal] = useState(296); + + const widthProp = + widthMode === "number" + ? widthVal + : widthMode === "percent" + ? `${widthVal}%` + : `${widthVal}px`; + const heightProp = + heightMode === "default" ? undefined : heightMode === "number" ? heightVal : `${heightVal}px`; + + const labelStyle: React.CSSProperties = { fontSize: "13px", fontFamily: "monospace" }; + const controlRow: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "12px", + marginBottom: "8px", + }; + + return ( +
+
+
+ width: + + setWidthVal(Number(e.target.value))} + style={{ width: "200px" }} + /> + + {widthMode === "number" ? `{${widthVal}}` : `"${widthProp}"`} + +
+
+ height: + + {heightMode !== "default" && ( + <> + setHeightVal(Number(e.target.value))} + style={{ width: "200px" }} + /> + + {heightMode === "number" ? `{${heightVal}}` : `"${heightProp}"`} + + + )} +
+
+ + + + +
+ ); + }, +}; + +export const OnClickHandler: Story = { + name: "onClick Handler", + render: () => { + const [clickLog, setClickLog] = useState< + Array<{ row: Record; index: number }> + >([]); + + const handleClick = useCallback((row: Record, index: number) => { + setClickLog((prev) => [{ row, index }, ...prev].slice(0, 5)); + }, []); + + const logStyle: React.CSSProperties = { + fontSize: "12px", + fontFamily: "monospace", + padding: "6px 10px", + background: "#f0f4f8", + borderRadius: "4px", + marginBottom: "4px", + border: "1px solid #e2e8f0", + }; + + return ( +
+ + + +
+ Click Log (last 5): +
+ {clickLog.length === 0 ? ( +

+ Click on the chart to see events here... +

+ ) : ( + clickLog.map((entry, i) => ( +
+ index: {entry.index} |{" "} + {Object.entries(entry.row) + .map(([k, v]) => `${k}: ${v}`) + .join(", ")} +
+ )) + )} +
+
+
+ ); + }, +}; + +export const Condensed: Story = { + name: "Condensed", + render: () => ( +
+
+

+ condensed — 12 months in 400px card (no scroll) +

+ + + +
+
+

+ condensed — 16 dense timeline items in 400px card +

+ + + +
+
+

+ normal (default) — same data scrolls +

+ + + +
+
+ ), +}; + +export const PrintContext: Story = { + name: "Print Context", + render: () => { + const [simulatePrint, setSimulatePrint] = useState(false); + + return ( +
+
+ Print Context Demo +

+ The usePrintContext hook automatically detects print mode (Ctrl+P / Cmd+P) + and disables animations so the chart renders its final state for print. Try printing + this page — the chart below has isAnimationActive=true but animations will + be suppressed in print preview. +

+ +
+ + + +
+ ); + }, +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/types.ts b/packages/react-ui/src/components/ChartsV2/D3AreaChart/types.ts new file mode 100644 index 000000000..86945bc8f --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/types.ts @@ -0,0 +1,11 @@ +import type { BaseChartProps, ChartData } from "../types"; + +export type D3AreaChartData = ChartData; + +export type D3AreaChartVariant = "linear" | "natural" | "step"; + +export interface D3AreaChartProps extends BaseChartProps { + variant?: D3AreaChartVariant; + stacked?: boolean; + onClick?: (row: T[number], index: number) => void; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx new file mode 100644 index 000000000..461126ab9 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx @@ -0,0 +1,20 @@ +import clsx from "clsx"; + +import { D3BarChartCondensed } from "./D3BarChartCondensed"; +import { D3BarChartScrollable } from "./D3BarChartScrollable"; + +import type { D3BarChartData, D3BarChartProps } from "./types"; + +export function D3BarChart(props: D3BarChartProps) { + if (!props.data || props.data.length === 0) { + return ( +
+ No data available +
+ ); + } + if (props.condensed) { + return ; + } + return ; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx new file mode 100644 index 000000000..452a71bab --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx @@ -0,0 +1,129 @@ +import { useCallback } from "react"; + +import { + useChartCondensedOrchestrator, + usePrintContext, + useStackedData, + useXBandScale, + useYScale, +} from "../hooks"; +import { AngledXAxis } from "../shared/cartesian/axes/AngledXAxis"; +import { ClipDefs } from "../shared/cartesian/ClipDefs"; +import { CondensedChartLayout } from "../shared/cartesian/layouts/CondensedChartLayout"; +import { findBandIndex } from "../utils/mouseUtils"; +import { BarSeries } from "./parts/BarSeries"; +import { Crosshair } from "./parts/Crosshair"; + +import type { D3BarChartData, D3BarChartProps } from "./types"; + +export function D3BarChartCondensed({ + data, + categoryKey, + theme: chartThemeName = "ocean", + customPalette, + variant = "grouped", + barRadius = 4, + maxBarWidth, + internalLine = true, + internalLineColor, + internalLineWidth, + grid = true, + legend: showLegend = true, + icons, + isAnimationActive = false, + showYAxis = true, + xAxisLabel, + yAxisLabel, + className, + height, + width: fixedWidth, + fitLegendInHeight, + onClick, +}: D3BarChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartCondensedOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + chartIdPrefix: "d3bc", + icons, + onClick, + }); + + const isStacked = variant === "stacked"; + const xScale = useXBandScale(data, orch.data.catKey, orch.dimensions.chartAreaWidth); + const stackedData = useStackedData(data, orch.data.dataKeys, isStacked); + const yScale = useYScale(data, orch.data.dataKeys, orch.dimensions.chartInnerHeight, stackedData); + + const findIndex = useCallback((mouseX: number) => findBandIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); + + return ( + + +
+ } + series={ + <> + + + + } + xAxis={ + + } + /> + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx new file mode 100644 index 000000000..66b24758d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx @@ -0,0 +1,138 @@ +import { useCallback } from "react"; + +import { + useChartScrollableOrchestrator, + usePrintContext, + useStackedData, + useXBandScale, + useYScale, +} from "../hooks"; +import { XAxis } from "../shared/cartesian/axes/XAxis"; +import { ClipDefs } from "../shared/cartesian/ClipDefs"; +import { ScrollableChartLayout } from "../shared/cartesian/layouts/ScrollableChartLayout"; +import { findBandIndex } from "../utils/mouseUtils"; +import { BarSeries } from "./parts/BarSeries"; +import { Crosshair } from "./parts/Crosshair"; + +import type { D3BarChartData, D3BarChartProps } from "./types"; + +export function D3BarChartScrollable({ + data, + categoryKey, + theme: chartThemeName = "ocean", + customPalette, + variant = "grouped", + tickVariant: tickVariantProp = "multiLine", + barRadius = 4, + maxBarWidth, + internalLine = true, + internalLineColor, + internalLineWidth, + grid = true, + legend: showLegend = true, + icons, + isAnimationActive = false, + showYAxis = true, + xAxisLabel, + yAxisLabel, + className, + height, + width: fixedWidth, + fitLegendInHeight, + condensed = false, + density, + onClick, +}: D3BarChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartScrollableOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3bc", + icons, + onClick, + condensed, + density, + }); + + const isStacked = variant === "stacked"; + const xScale = useXBandScale(data, orch.data.catKey, orch.dimensions.svgWidth); + const stackedData = useStackedData(data, orch.data.dataKeys, isStacked); + const yScale = useYScale(data, orch.data.dataKeys, orch.dimensions.chartInnerHeight, stackedData); + + const findIndex = useCallback((mouseX: number) => findBandIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); + + return ( + + + + } + series={ + <> + + + + } + xAxis={ + + } + /> + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss b/packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss new file mode 100644 index 000000000..c36ad8168 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss @@ -0,0 +1,35 @@ +@use "../../../cssUtils" as cssUtils; +@use "../shared/cartesian/chartBase" as base; + +@include base.chart-base("bar-chart"); + +.openui-d3-bar-chart-bar { + transition: + height 0.3s ease-out, + y 0.3s ease-out; +} + +.openui-d3-bar-chart-hover-highlight { + fill: cssUtils.$text-neutral-primary; + opacity: 0.04; + pointer-events: none; +} + +.openui-d3-bar-chart-internal-line { + pointer-events: none; +} + +.openui-d3-bar-chart-bar--animated { + transform-box: fill-box; + transform-origin: center bottom; + animation: openui-d3-bar-grow 0.6s ease-out forwards; +} + +@keyframes openui-d3-bar-grow { + from { + transform: scaleY(0); + } + to { + transform: scaleY(1); + } +} diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/index.ts b/packages/react-ui/src/components/ChartsV2/D3BarChart/index.ts new file mode 100644 index 000000000..492a172d6 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/index.ts @@ -0,0 +1,2 @@ +export { D3BarChart } from "./D3BarChart"; +export type { D3BarChartData, D3BarChartProps, D3BarChartVariant } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/BarSeries.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/BarSeries.tsx new file mode 100644 index 000000000..35c3ae771 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/BarSeries.tsx @@ -0,0 +1,185 @@ +import type { ScaleBand, ScaleLinear } from "d3-scale"; +import { scaleBand } from "d3-scale"; +import React, { useMemo } from "react"; +import type { StackedData } from "../../hooks"; +import type { D3BarChartVariant } from "../types"; + +const DEFAULT_MAX_BAR_WIDTH = 16; +const MIN_BAR_HEIGHT_FOR_LINE = 8; +const LINE_PADDING = 6; + +interface BarSeriesProps { + data: Array>; + dataKeys: string[]; + xScale: ScaleBand; + yScale: ScaleLinear; + variant: D3BarChartVariant; + stackedData: StackedData | null; + categoryKey: string; + colors: Record; + barRadius: number; + chartHeight: number; + maxBarWidth?: number; + internalLine?: boolean; + internalLineColor?: string; + internalLineWidth?: number; + isAnimationActive?: boolean; +} + +/** + * Computes capped bar width and centering offset within a band. + * When bandwidth exceeds maxBarWidth, the bar is capped and centered. + */ +function getBarLayout(bandwidth: number, maxBarWidth: number) { + const cappedWidth = Math.min(bandwidth, maxBarWidth); + const offset = (bandwidth - cappedWidth) / 2; + return { barWidth: cappedWidth, offset }; +} + +export const BarSeries: React.FC = ({ + data, + dataKeys, + xScale, + yScale, + variant, + stackedData, + categoryKey, + colors, + barRadius, + chartHeight, + maxBarWidth = DEFAULT_MAX_BAR_WIDTH, + internalLine = false, + internalLineColor = "rgba(255, 255, 255, 0.3)", + internalLineWidth = 1, + isAnimationActive, +}) => { + const bandwidth = xScale.bandwidth(); + + const innerScale = useMemo(() => { + if (variant !== "grouped") return null; + return scaleBand().domain(dataKeys).range([0, xScale.bandwidth()]).padding(0.05); + }, [variant, dataKeys, xScale]); + + // For grouped: cap individual bar within its inner band + // For stacked: cap the single bar within the full band + const groupedBarLayout = useMemo(() => { + if (variant !== "grouped" || !innerScale) return null; + return getBarLayout(innerScale.bandwidth(), maxBarWidth); + }, [variant, innerScale, maxBarWidth]); + + const stackedBarLayout = useMemo(() => { + if (variant !== "stacked") return null; + return getBarLayout(bandwidth, maxBarWidth); + }, [variant, bandwidth, maxBarWidth]); + + if (variant === "stacked" && stackedData && stackedBarLayout) { + const { barWidth, offset } = stackedBarLayout; + return ( + + {stackedData.map((series, seriesIndex) => { + const color = colors[series.key] ?? "#000"; + const isTopSeries = seriesIndex === stackedData.length - 1; + return ( + + {(series as unknown as [number, number][]).map((point, i) => { + const category = String(data[i]![categoryKey]); + const bandX = xScale(category) ?? 0; + const barX = bandX + offset; + const barY = yScale(point[1]); + const barHeight = Math.max(0, yScale(point[0]) - yScale(point[1])); + const applyRadius = isTopSeries && barHeight > 0; + + return ( + + + {internalLine && barHeight >= MIN_BAR_HEIGHT_FOR_LINE && barWidth >= 3 && ( + + )} + + ); + })} + + ); + })} + + ); + } + + // Grouped variant + return ( + + {data.map((row, i) => { + const category = String(row[categoryKey]); + const groupX = xScale(category) ?? 0; + return ( + + {dataKeys.map((key) => { + const value = Number(row[key]) || 0; + const innerBandX = innerScale?.(key) ?? 0; + const barX = groupX + innerBandX + (groupedBarLayout?.offset ?? 0); + const barY = yScale(value); + const barHeight = Math.max(0, chartHeight - barY); + const barWidth = groupedBarLayout?.barWidth ?? innerScale?.bandwidth() ?? bandwidth; + const color = colors[key] ?? "#000"; + + return ( + + + {internalLine && barHeight >= MIN_BAR_HEIGHT_FOR_LINE && barWidth >= 3 && ( + + )} + + ); + })} + + ); + })} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/Crosshair.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/Crosshair.tsx new file mode 100644 index 000000000..ea66e4490 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/Crosshair.tsx @@ -0,0 +1,38 @@ +import type { ScaleBand } from "d3-scale"; +import React from "react"; + +interface CrosshairProps { + hoveredIndex: number | null; + xScale: ScaleBand; + data: Array>; + categoryKey: string; + chartHeight: number; +} + +export const Crosshair: React.FC = ({ + hoveredIndex, + xScale, + data, + categoryKey, + chartHeight, +}) => { + if (hoveredIndex === null || hoveredIndex < 0 || hoveredIndex >= data.length) { + return null; + } + + const row = data[hoveredIndex]!; + const category = String(row[categoryKey]); + const x = xScale(category) ?? 0; + const width = xScale.bandwidth(); + + return ( + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/stories/d3BarChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/stories/d3BarChart.stories.tsx new file mode 100644 index 000000000..16d6ac377 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/stories/d3BarChart.stories.tsx @@ -0,0 +1,550 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useCallback, useState } from "react"; +import { Card } from "../../../Card"; +import { D3BarChart } from "../D3BarChart"; +import type { D3BarChartProps } from "../types"; + +const dataVariations = { + default: [ + { month: "January", desktop: 150, mobile: 90, tablet: 120 }, + { month: "February", desktop: 280, mobile: 180, tablet: 140 }, + { month: "March", desktop: 220, mobile: 140, tablet: 160 }, + { month: "April", desktop: 180, mobile: 160, tablet: 180 }, + { month: "May", desktop: 250, mobile: 120, tablet: 140 }, + { month: "June", desktop: 300, mobile: 180, tablet: 160 }, + { month: "July", desktop: 350, mobile: 220, tablet: 180 }, + { month: "August", desktop: 400, mobile: 240, tablet: 200 }, + { month: "September", desktop: 450, mobile: 260, tablet: 220 }, + { month: "October", desktop: 500, mobile: 280, tablet: 240 }, + { month: "November", desktop: 550, mobile: 300, tablet: 260 }, + { month: "December", desktop: 600, mobile: 320, tablet: 280 }, + ], + bigLabels: [ + { + category: "Very Long Category Name That Should Be Truncated", + sales: 150, + revenue: 90, + profit: 120, + }, + { + category: "Another Extremely Long Label That Causes Collisions", + sales: 280, + revenue: 180, + profit: 140, + }, + { + category: "Super Duper Long Category Name That Tests Truncation", + sales: 220, + revenue: 140, + profit: 160, + }, + { + category: "Incredibly Long Text That Should Trigger Collision Detection", + sales: 180, + revenue: 160, + profit: 180, + }, + { + category: "Maximum Length Category Name That Tests All Edge Cases", + sales: 250, + revenue: 120, + profit: 140, + }, + { + category: "Extra Long Business Category Name With Many Words", + sales: 300, + revenue: 180, + profit: 160, + }, + ], + minimal: [ + { category: "Mobile Devices", users: 150, sessions: 90 }, + { category: "Desktop Computers", users: 280, sessions: 180 }, + { category: "Tablet Devices", users: 220, sessions: 140 }, + ], + singleSeries: [ + { month: "Jan", revenue: 1200 }, + { month: "Feb", revenue: 1800 }, + { month: "Mar", revenue: 2100 }, + { month: "Apr", revenue: 1600 }, + { month: "May", revenue: 2300 }, + { month: "Jun", revenue: 2800 }, + ], + manySeries: [ + { quarter: "Q1", sales: 100, marketing: 80, engineering: 150, design: 60, ops: 90 }, + { quarter: "Q2", sales: 120, marketing: 95, engineering: 170, design: 75, ops: 100 }, + { quarter: "Q3", sales: 140, marketing: 110, engineering: 190, design: 85, ops: 115 }, + { quarter: "Q4", sales: 160, marketing: 125, engineering: 210, design: 95, ops: 130 }, + ], +}; + +const categoryKeys: Record = { + default: "month", + bigLabels: "category", + minimal: "category", + singleSeries: "month", + manySeries: "quarter", +}; + +const barChartData = dataVariations.default; + +const meta: Meta> = { + title: "Components/ChartsV2/D3BarChart", + component: D3BarChart, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + theme: { + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + }, + variant: { + control: "radio", + options: ["grouped", "stacked"], + }, + barRadius: { control: { type: "range", min: 0, max: 12, step: 1 } }, + grid: { control: "boolean" }, + legend: { control: "boolean" }, + showYAxis: { control: "boolean" }, + isAnimationActive: { control: "boolean" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DataExplorer: Story = { + name: "Data Explorer", + args: { + data: barChartData, + categoryKey: "month", + theme: "ocean", + variant: "grouped", + barRadius: 4, + grid: true, + legend: true, + isAnimationActive: false, + showYAxis: true, + xAxisLabel: "Time Period", + yAxisLabel: "Values", + }, + render: (args: any) => { + const [selectedDataType, setSelectedDataType] = + useState("default"); + const currentData = dataVariations[selectedDataType]; + const currentCategoryKey = categoryKeys[selectedDataType]; + + const buttonStyle = { + margin: "2px", + padding: "6px 12px", + fontSize: "12px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + background: "#fff", + fontFamily: "monospace", + }; + const activeButtonStyle = { + ...buttonStyle, + background: "#007acc", + color: "white", + border: "1px solid #007acc", + }; + + return ( +
+
+ D3 BarChart Explorer: +
+ {Object.keys(dataVariations).map((key) => ( + + ))} +
+
+ Dataset: {selectedDataType} | Items: {currentData.length} | Category:{" "} + {currentCategoryKey} +
+
+ + + +
+ ); + }, +}; + +export const GroupedVsStacked: Story = { + name: "Grouped vs Stacked", + render: () => ( +
+
+

Grouped (default)

+ + + +
+
+

Stacked

+ + + +
+
+ ), +}; + +export const SingleSeriesStory: Story = { + name: "Single Series", + args: { + data: dataVariations.singleSeries as any, + categoryKey: "month" as any, + theme: "emerald", + variant: "grouped", + barRadius: 4, + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const ManySeriesStory: Story = { + name: "Many Series (Grouped)", + args: { + data: dataVariations.manySeries as any, + categoryKey: "quarter" as any, + theme: "vivid", + variant: "grouped", + barRadius: 2, + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const AnimationDemo: Story = { + name: "Animation Demo", + args: { + data: dataVariations.default as any, + categoryKey: "month" as any, + theme: "orchid", + variant: "grouped", + grid: true, + legend: true, + showYAxis: true, + isAnimationActive: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const CustomPaletteStory: Story = { + name: "Custom Palette", + args: { + data: dataVariations.default as any, + categoryKey: "month" as any, + customPalette: ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8"], + variant: "grouped", + barRadius: 6, + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const OnClickHandler: Story = { + name: "onClick Handler", + render: () => { + const [clickLog, setClickLog] = useState< + Array<{ row: Record; index: number }> + >([]); + + const handleClick = useCallback((row: Record, index: number) => { + setClickLog((prev) => [{ row, index }, ...prev].slice(0, 5)); + }, []); + + const logStyle: React.CSSProperties = { + fontSize: "12px", + fontFamily: "monospace", + padding: "6px 10px", + background: "#f0f4f8", + borderRadius: "4px", + marginBottom: "4px", + border: "1px solid #e2e8f0", + }; + + return ( +
+ + + +
+ Click Log (last 5): +
+ {clickLog.length === 0 ? ( +

+ Click on the chart to see events here... +

+ ) : ( + clickLog.map((entry, i) => ( +
+ index: {entry.index} |{" "} + {Object.entries(entry.row) + .map(([k, v]) => `${k}: ${v}`) + .join(", ")} +
+ )) + )} +
+
+
+ ); + }, +}; + +export const InternalLineAndMaxWidth: Story = { + name: "Internal Line & Max Width", + render: () => ( +
+
+

+ Few data points — bars capped at maxBarWidth=16, centered +

+ + + +
+
+

+ Internal line on grouped bars (default 12-item dataset) +

+ + + +
+
+

Internal line on stacked bars

+ + + +
+
+

+ Wide bars (maxBarWidth=40) vs narrow (maxBarWidth=10) +

+
+ + + + + + +
+
+
+ ), +}; + +export const FixedPixelDimensions: Story = { + name: "Fixed Pixel Dimensions", + render: () => ( +
+
+

+ width={400} height={200} +

+ + + +
+
+

+ width={700} height={400} +

+ + + +
+
+ ), +}; + +export const Condensed: Story = { + name: "Condensed (Angled Labels)", + render: () => ( +
+
+

+ condensed — 12 months with angled labels +

+ + + +
+
+

+ condensed stacked — angled labels +

+ + + +
+
+

+ condensed — long labels (big angle) +

+ + + +
+
+

+ condensed — short labels (minimal angle) +

+ + + +
+
+

+ condensed — narrow container (300px) +

+ + + +
+
+ ), +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/types.ts b/packages/react-ui/src/components/ChartsV2/D3BarChart/types.ts new file mode 100644 index 000000000..5a40b983f --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/types.ts @@ -0,0 +1,19 @@ +import type { BaseChartProps, ChartData } from "../types"; + +export type D3BarChartData = ChartData; + +export type D3BarChartVariant = "grouped" | "stacked"; + +export interface D3BarChartProps extends BaseChartProps { + variant?: D3BarChartVariant; + barRadius?: number; + /** Maximum width in px for a single bar. Bars will center within their band when capped. */ + maxBarWidth?: number; + /** Show a decorative internal line inside each bar from base toward the tip. Default false. */ + internalLine?: boolean; + /** Color of the internal line. Defaults to semi-transparent white. */ + internalLineColor?: string; + /** Stroke width of the internal line. Defaults to 1. */ + internalLineWidth?: number; + onClick?: (row: T[number], index: number) => void; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx new file mode 100644 index 000000000..b3d157bd3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx @@ -0,0 +1,22 @@ +import clsx from "clsx"; + +import { D3LineChartCondensed } from "./D3LineChartCondensed"; +import { D3LineChartScrollable } from "./D3LineChartScrollable"; + +import type { D3LineChartData, D3LineChartProps } from "./types"; + +export function D3LineChart(props: D3LineChartProps) { + if (!props.data || props.data.length === 0) { + return ( +
+ No data available +
+ ); + } + if (props.condensed) { + return ; + } + return ; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx new file mode 100644 index 000000000..832843d7e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx @@ -0,0 +1,128 @@ +import { useCallback } from "react"; + +import { useChartCondensedOrchestrator, usePrintContext, useXScale, useYScale } from "../hooks"; +import { AngledXAxis } from "../shared/cartesian/axes/AngledXAxis"; +import { ClipDefs } from "../shared/cartesian/ClipDefs"; +import { CondensedChartLayout } from "../shared/cartesian/layouts/CondensedChartLayout"; +import { LineDotCrosshair } from "../shared/cartesian/LineDotCrosshair"; +import { findNearestDataIndex } from "../utils/mouseUtils"; +import { LineSeries } from "./parts/LineSeries"; + +import type { D3LineChartData, D3LineChartProps } from "./types"; + +export function D3LineChartCondensed({ + data, + categoryKey, + theme: chartThemeName = "ocean", + customPalette, + variant = "natural", + showDots = false, + dotRadius = 3, + grid = true, + legend: showLegend = true, + icons, + isAnimationActive = false, + showYAxis = true, + xAxisLabel, + yAxisLabel, + className, + height, + width: fixedWidth, + fitLegendInHeight, + onClick, +}: D3LineChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartCondensedOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + chartIdPrefix: "d3lc", + icons, + onClick, + }); + + const xScale = useXScale( + data, + orch.data.catKey, + orch.dimensions.chartAreaWidth, + orch.dimensions.widthPerDataPoint, + ); + const yScale = useYScale(data, orch.data.dataKeys, orch.dimensions.chartInnerHeight, null); + + const getYValue = useCallback( + (row: Record, key: string) => Number(row[key]) || 0, + [], + ); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); + + return ( + + + + } + series={ + <> + + + + } + xAxis={ + + } + /> + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx new file mode 100644 index 000000000..eeb7d8245 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx @@ -0,0 +1,138 @@ +import { useCallback } from "react"; + +import { useChartScrollableOrchestrator, usePrintContext, useXScale, useYScale } from "../hooks"; +import { ClipDefs } from "../shared/cartesian/ClipDefs"; +import { LineDotCrosshair } from "../shared/cartesian/LineDotCrosshair"; +import { XAxis } from "../shared/cartesian/axes/XAxis"; +import { ScrollableChartLayout } from "../shared/cartesian/layouts/ScrollableChartLayout"; +import { findNearestDataIndex } from "../utils/mouseUtils"; +import { LineSeries } from "./parts/LineSeries"; + +import type { D3LineChartData, D3LineChartProps } from "./types"; + +export function D3LineChartScrollable({ + data, + categoryKey, + theme: chartThemeName = "ocean", + customPalette, + variant = "natural", + tickVariant: tickVariantProp = "multiLine", + showDots = false, + dotRadius = 3, + grid = true, + legend: showLegend = true, + icons, + isAnimationActive = false, + showYAxis = true, + xAxisLabel, + yAxisLabel, + className, + height, + width: fixedWidth, + fitLegendInHeight, + condensed = false, + density, + onClick, +}: D3LineChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartScrollableOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3lc", + icons, + onClick, + condensed, + density, + }); + + const xScale = useXScale( + data, + orch.data.catKey, + orch.dimensions.svgWidth, + orch.dimensions.widthOfGroup, + ); + const yScale = useYScale(data, orch.data.dataKeys, orch.dimensions.chartInnerHeight, null); + + const getYValue = useCallback( + (row: Record, key: string) => Number(row[key]) || 0, + [], + ); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); + + return ( + + + + } + series={ + <> + + + + } + xAxis={ + + } + /> + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss b/packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss new file mode 100644 index 000000000..40b45b65e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss @@ -0,0 +1,31 @@ +@use "../../../cssUtils" as cssUtils; +@use "../shared/cartesian/chartBase" as base; + +@include base.chart-base("line-chart"); +@include base.crosshair-styles("line-chart"); + +.openui-d3-line-chart-line { + fill: none; + stroke-width: 2; + transition: d 0.3s ease-out; +} + +.openui-d3-line-chart-dot { + stroke: cssUtils.$foreground; + stroke-width: 1; + transition: + cx 0.3s ease-out, + cy 0.3s ease-out; +} + +.openui-d3-line-chart-line--animated { + stroke-dasharray: var(--path-length); + stroke-dashoffset: var(--path-length); + animation: openui-d3-line-draw 1s ease-out forwards; +} + +@keyframes openui-d3-line-draw { + to { + stroke-dashoffset: 0; + } +} diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/index.ts b/packages/react-ui/src/components/ChartsV2/D3LineChart/index.ts new file mode 100644 index 000000000..f6164d39e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/index.ts @@ -0,0 +1,2 @@ +export { D3LineChart } from "./D3LineChart"; +export type { D3LineChartData, D3LineChartProps, D3LineChartVariant } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/LineSeries.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/LineSeries.tsx new file mode 100644 index 000000000..dfe7d6555 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/LineSeries.tsx @@ -0,0 +1,106 @@ +import type { ScaleLinear, ScalePoint } from "d3-scale"; +import { curveLinear, curveMonotoneX, curveStepAfter, line as d3Line } from "d3-shape"; +import React, { useEffect, useMemo, useRef } from "react"; +import type { D3LineChartVariant } from "../types"; + +const curveMap = { + linear: curveLinear, + natural: curveMonotoneX, + step: curveStepAfter, +}; + +interface LineSeriesProps { + data: Array>; + dataKeys: string[]; + xScale: ScalePoint; + yScale: ScaleLinear; + variant: D3LineChartVariant; + categoryKey: string; + colors: Record; + showDots: boolean; + dotRadius: number; + isAnimationActive?: boolean; +} + +export const LineSeries: React.FC = ({ + data, + dataKeys, + xScale, + yScale, + variant, + categoryKey, + colors, + showDots, + dotRadius, + isAnimationActive, +}) => { + const curve = curveMap[variant]; + + const seriesPaths = useMemo(() => { + return dataKeys.map((key) => { + const lineGenerator = d3Line>() + .x((d) => xScale(String(d[categoryKey])) ?? 0) + .y((d) => yScale(Number(d[key]) || 0)) + .curve(curve); + + return { + key, + linePath: lineGenerator(data) ?? "", + }; + }); + }, [data, dataKeys, xScale, yScale, categoryKey, curve]); + + return ( + + {seriesPaths.map(({ key, linePath }) => { + const color = colors[key] ?? "#000"; + return ( + + + {showDots && + data.map((row, i) => { + const x = xScale(String(row[categoryKey])) ?? 0; + const y = yScale(Number(row[key]) || 0); + return ( + + ); + })} + + ); + })} + + ); +}; + +const AnimatedLine: React.FC<{ + linePath: string; + color: string; + isAnimationActive?: boolean; +}> = ({ linePath, color, isAnimationActive }) => { + const lineRef = useRef(null); + + useEffect(() => { + if (isAnimationActive && lineRef.current) { + const length = lineRef.current.getTotalLength(); + lineRef.current.style.setProperty("--path-length", String(length)); + } + }, [linePath, isAnimationActive]); + + return ( + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/stories/d3LineChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/stories/d3LineChart.stories.tsx new file mode 100644 index 000000000..c3cc8e469 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/stories/d3LineChart.stories.tsx @@ -0,0 +1,448 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useCallback, useState } from "react"; +import { Card } from "../../../Card"; +import { D3LineChart } from "../D3LineChart"; +import type { D3LineChartProps } from "../types"; + +const dataVariations = { + default: [ + { month: "January", desktop: 150, mobile: 90, tablet: 120 }, + { month: "February", desktop: 280, mobile: 180, tablet: 140 }, + { month: "March", desktop: 220, mobile: 140, tablet: 160 }, + { month: "April", desktop: 180, mobile: 160, tablet: 180 }, + { month: "May", desktop: 250, mobile: 120, tablet: 140 }, + { month: "June", desktop: 300, mobile: 180, tablet: 160 }, + { month: "July", desktop: 350, mobile: 220, tablet: 180 }, + { month: "August", desktop: 400, mobile: 240, tablet: 200 }, + { month: "September", desktop: 450, mobile: 260, tablet: 220 }, + { month: "October", desktop: 500, mobile: 280, tablet: 240 }, + { month: "November", desktop: 550, mobile: 300, tablet: 260 }, + { month: "December", desktop: 600, mobile: 320, tablet: 280 }, + ], + bigLabels: [ + { + category: "Very Long Category Name That Should Be Truncated", + sales: 150, + revenue: 90, + profit: 120, + }, + { + category: "Another Extremely Long Label That Causes Collisions", + sales: 280, + revenue: 180, + profit: 140, + }, + { + category: "Super Duper Long Category Name That Tests Truncation", + sales: 220, + revenue: 140, + profit: 160, + }, + { + category: "Incredibly Long Text That Should Trigger Collision Detection", + sales: 180, + revenue: 160, + profit: 180, + }, + { + category: "Maximum Length Category Name That Tests All Edge Cases", + sales: 250, + revenue: 120, + profit: 140, + }, + { + category: "Extra Long Business Category Name With Many Words", + sales: 300, + revenue: 180, + profit: 160, + }, + ], + denseTimeline: [ + { period: "Q1 2022 Jan-Mar", visitors: 120, conversions: 15, revenue: 1200 }, + { period: "Q1 2022 Apr-Jun", visitors: 150, conversions: 22, revenue: 1800 }, + { period: "Q2 2022 Jul-Sep", visitors: 180, conversions: 28, revenue: 2100 }, + { period: "Q2 2022 Oct-Dec", visitors: 200, conversions: 35, revenue: 2500 }, + { period: "Q3 2023 Jan-Mar", visitors: 160, conversions: 18, revenue: 1600 }, + { period: "Q3 2023 Apr-Jun", visitors: 190, conversions: 32, revenue: 2300 }, + { period: "Q4 2023 Jul-Sep", visitors: 220, conversions: 40, revenue: 2800 }, + { period: "Q4 2023 Oct-Dec", visitors: 240, conversions: 45, revenue: 3200 }, + { period: "Q1 2024 Jan-Mar", visitors: 210, conversions: 38, revenue: 2700 }, + { period: "Q1 2024 Apr-Jun", visitors: 230, conversions: 42, revenue: 3000 }, + { period: "Q2 2024 Jul-Sep", visitors: 250, conversions: 48, revenue: 3400 }, + { period: "Q2 2024 Oct-Dec", visitors: 270, conversions: 52, revenue: 3800 }, + { period: "Q3 2024 Jan-Mar", visitors: 260, conversions: 50, revenue: 3600 }, + { period: "Q3 2024 Apr-Jun", visitors: 280, conversions: 55, revenue: 4000 }, + { period: "Q4 2024 Jul-Sep", visitors: 300, conversions: 60, revenue: 4300 }, + { period: "Q4 2024 Oct-Dec", visitors: 290, conversions: 58, revenue: 4100 }, + ], + minimal: [ + { category: "Mobile Devices", users: 150, sessions: 90 }, + { category: "Desktop Computers", users: 280, sessions: 180 }, + { category: "Tablet Devices", users: 220, sessions: 140 }, + ], +}; + +const categoryKeys: Record = { + default: "month", + bigLabels: "category", + denseTimeline: "period", + minimal: "category", +}; + +const lineChartData = dataVariations.default; + +const meta: Meta> = { + title: "Components/ChartsV2/D3LineChart", + component: D3LineChart, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + theme: { + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + }, + variant: { + control: "radio", + options: ["linear", "natural", "step"], + }, + showDots: { control: "boolean" }, + dotRadius: { control: { type: "range", min: 1, max: 8, step: 1 } }, + grid: { control: "boolean" }, + legend: { control: "boolean" }, + showYAxis: { control: "boolean" }, + isAnimationActive: { control: "boolean" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DataExplorer: Story = { + name: "Data Explorer", + args: { + data: lineChartData, + categoryKey: "month", + theme: "ocean", + variant: "natural", + grid: true, + legend: true, + showDots: false, + dotRadius: 3, + isAnimationActive: false, + showYAxis: true, + xAxisLabel: "Time Period", + yAxisLabel: "Values", + }, + render: (args: any) => { + const [selectedDataType, setSelectedDataType] = + useState("default"); + const currentData = dataVariations[selectedDataType]; + const currentCategoryKey = categoryKeys[selectedDataType]; + + const buttonStyle = { + margin: "2px", + padding: "6px 12px", + fontSize: "12px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + background: "#fff", + fontFamily: "monospace", + }; + const activeButtonStyle = { + ...buttonStyle, + background: "#007acc", + color: "white", + border: "1px solid #007acc", + }; + + return ( +
+
+ D3 LineChart Explorer: +
+ {Object.keys(dataVariations).map((key) => ( + + ))} +
+
+ Dataset: {selectedDataType} | Items: {currentData.length} | Category:{" "} + {currentCategoryKey} +
+
+ + + +
+ ); + }, +}; + +export const ShowDots: Story = { + name: "Show Dots", + render: () => ( +
+
+

Without Dots (default)

+ + + +
+
+

With Dots

+ + + +
+
+ ), +}; + +export const CurveVariants: Story = { + name: "Curve Variants", + render: () => ( +
+ {(["linear", "natural", "step"] as const).map((v) => ( +
+

variant="{v}"

+ + + +
+ ))} +
+ ), +}; + +export const DenseTimelineStory: Story = { + name: "Dense Timeline", + args: { + data: dataVariations.denseTimeline as any, + categoryKey: "period" as any, + theme: "sunset", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const AnimationDemo: Story = { + name: "Animation Demo", + args: { + data: dataVariations.default as any, + categoryKey: "month" as any, + theme: "emerald", + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + isAnimationActive: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const CustomPaletteStory: Story = { + name: "Custom Palette", + args: { + data: dataVariations.default as any, + categoryKey: "month" as any, + customPalette: ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8"], + variant: "natural", + grid: true, + legend: true, + showYAxis: true, + showDots: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const OnClickHandler: Story = { + name: "onClick Handler", + render: () => { + const [clickLog, setClickLog] = useState< + Array<{ row: Record; index: number }> + >([]); + + const handleClick = useCallback((row: Record, index: number) => { + setClickLog((prev) => [{ row, index }, ...prev].slice(0, 5)); + }, []); + + const logStyle: React.CSSProperties = { + fontSize: "12px", + fontFamily: "monospace", + padding: "6px 10px", + background: "#f0f4f8", + borderRadius: "4px", + marginBottom: "4px", + border: "1px solid #e2e8f0", + }; + + return ( +
+ + + +
+ Click Log (last 5): +
+ {clickLog.length === 0 ? ( +

+ Click on the chart to see events here... +

+ ) : ( + clickLog.map((entry, i) => ( +
+ index: {entry.index} |{" "} + {Object.entries(entry.row) + .map(([k, v]) => `${k}: ${v}`) + .join(", ")} +
+ )) + )} +
+
+
+ ); + }, +}; + +export const FixedPixelDimensions: Story = { + name: "Fixed Pixel Dimensions", + render: () => ( +
+
+

+ width={400} height={200} +

+ + + +
+
+

+ width={700} height={400} +

+ + + +
+
+ ), +}; + +export const Condensed: Story = { + name: "Condensed", + render: () => ( +
+
+

+ condensed — 12 months in 400px card (no scroll) +

+ + + +
+
+

+ condensed with dots — 12 months in 400px card +

+ + + +
+
+

+ normal (default) — same data scrolls +

+ + + +
+
+ ), +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/types.ts b/packages/react-ui/src/components/ChartsV2/D3LineChart/types.ts new file mode 100644 index 000000000..955fed930 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/types.ts @@ -0,0 +1,12 @@ +import type { BaseChartProps, ChartData } from "../types"; + +export type D3LineChartData = ChartData; + +export type D3LineChartVariant = "linear" | "natural" | "step"; + +export interface D3LineChartProps extends BaseChartProps { + variant?: D3LineChartVariant; + showDots?: boolean; + dotRadius?: number; + onClick?: (row: T[number], index: number) => void; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3PieChart/D3PieChart.tsx b/packages/react-ui/src/components/ChartsV2/D3PieChart/D3PieChart.tsx new file mode 100644 index 000000000..9dcfdf2ad --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3PieChart/D3PieChart.tsx @@ -0,0 +1,150 @@ +import clsx from "clsx"; +import { arc, pie, type PieArcDatum } from "d3-shape"; +import { useMemo } from "react"; + +import { type CategoricalSlice, useCategoricalChartOrchestrator } from "../hooks"; +import { DefaultLegend } from "../shared/core/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/core/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/core/PortalTooltip/ChartTooltip"; +import { PieSlices } from "./parts/PieSlices"; +import type { D3PieChartData, D3PieChartProps } from "./types"; + +export function D3PieChart(props: D3PieChartProps) { + const { + data, + categoryKey, + dataKey, + theme = "ocean", + customPalette, + variant = "pie", + appearance = "circular", + format = "number", + legend: showLegend = true, + isAnimationActive = true, + cornerRadius = 0, + paddingAngle = 0, + maxChartSize = 500, + minChartSize = 150, + height, + width, + fitLegendInHeight, + className, + onClick, + } = props; + + const isSemiCircular = appearance === "semiCircular"; + + const orch = useCategoricalChartOrchestrator({ + data, + categoryKey, + dataKey, + chartThemeName: theme, + customPalette, + format, + showLegend, + isSemiCircular, + maxChartSize, + minChartSize, + height, + width, + fitLegendInHeight, + }); + + // Pie-specific geometry + const outerRadius = orch.dimensions.chartSize * 0.45; + const innerRadius = variant === "donut" ? outerRadius * 0.6 : 0; + + const startAngle = isSemiCircular ? -Math.PI / 2 : 0; + const endAngle = isSemiCircular ? Math.PI / 2 : 2 * Math.PI; + + const pieGenerator = useMemo( + () => + pie() + .value((d) => d.value) + .sort(null) + .startAngle(startAngle) + .endAngle(endAngle) + .padAngle((paddingAngle * Math.PI) / 180), + [startAngle, endAngle, paddingAngle], + ); + + const arcGenerator = useMemo( + () => + arc>() + .innerRadius(innerRadius) + .outerRadius(outerRadius) + .cornerRadius(cornerRadius), + [innerRadius, outerRadius, cornerRadius], + ); + + const arcs = useMemo( + () => pieGenerator(orch.data.visibleSlices), + [pieGenerator, orch.data.visibleSlices], + ); + + // Empty state + if (!data || data.length === 0) { + return ( +
+ No data available +
+ ); + } + + return ( + +
+
+ + + !orch.data.hiddenSlices.has(String(row[String(categoryKey)])), + )} + onMouseMove={orch.hover.handleMouseMove} + onMouseLeave={orch.hover.handleMouseLeave} + onClick={onClick} + /> + + +
+ + {showLegend && ( + + )} + + {orch.tooltip.tooltipPayload && orch.hover.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3PieChart/d3PieChart.scss b/packages/react-ui/src/components/ChartsV2/D3PieChart/d3PieChart.scss new file mode 100644 index 000000000..d837d719a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3PieChart/d3PieChart.scss @@ -0,0 +1,37 @@ +@use "../../../cssUtils" as cssUtils; + +.openui-d3-pie-chart-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + position: relative; +} + +.openui-d3-pie-chart-svg-wrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.openui-d3-pie-chart-empty { + @include cssUtils.typography(body, default); + color: cssUtils.$text-neutral-secondary; +} + +.openui-d3-pie-chart-slice--animated { + opacity: 0; + transform-origin: center center; + animation: openui-d3-pie-appear 0.6s ease-out forwards; +} + +@keyframes openui-d3-pie-appear { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/packages/react-ui/src/components/ChartsV2/D3PieChart/index.ts b/packages/react-ui/src/components/ChartsV2/D3PieChart/index.ts new file mode 100644 index 000000000..c6ba89834 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3PieChart/index.ts @@ -0,0 +1,2 @@ +export { D3PieChart } from "./D3PieChart"; +export type { D3PieChartData, D3PieChartProps } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/D3PieChart/parts/PieSlices.tsx b/packages/react-ui/src/components/ChartsV2/D3PieChart/parts/PieSlices.tsx new file mode 100644 index 000000000..f23478301 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3PieChart/parts/PieSlices.tsx @@ -0,0 +1,70 @@ +import type { Arc, PieArcDatum } from "d3-shape"; +import React, { useCallback } from "react"; +import type { CategoricalSlice } from "../../hooks"; +import { getSliceStyle } from "../../utils/polarUtils"; + +interface PieSlicesProps { + arcs: PieArcDatum[]; + arcGenerator: Arc>; + slices: CategoricalSlice[]; + hoveredIndex: number | null; + isAnimationActive: boolean; + isPrinting: boolean; + data: T[]; + onMouseMove: (event: React.MouseEvent, index: number) => void; + onMouseLeave: () => void; + onClick?: (row: T, index: number) => void; +} + +export function PieSlices({ + arcs, + arcGenerator, + slices, + hoveredIndex, + isAnimationActive, + isPrinting, + data, + onMouseMove, + onMouseLeave, + onClick, +}: PieSlicesProps) { + const handleClick = useCallback( + (index: number) => { + if (onClick) { + onClick(data[index]!, index); + } + }, + [onClick, data], + ); + + const animate = isAnimationActive && !isPrinting; + + return ( + + {arcs.map((arc, i) => { + const slice = slices[i]; + if (!slice) return null; + const pathD = arcGenerator(arc); + if (!pathD) return null; + + return ( + onMouseMove(e, i)} + onMouseLeave={onMouseLeave} + onClick={() => handleClick(i)} + /> + ); + })} + + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3PieChart/stories/d3PieChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3PieChart/stories/d3PieChart.stories.tsx new file mode 100644 index 000000000..c972a6f6d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3PieChart/stories/d3PieChart.stories.tsx @@ -0,0 +1,422 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useCallback, useState } from "react"; +import { Card } from "../../../Card"; +import { D3PieChart } from "../D3PieChart"; +import type { D3PieChartProps } from "../types"; + +const salesData = [ + { category: "Electronics", value: 12500 }, + { category: "Apparel", value: 9800 }, + { category: "Groceries", value: 14500 }, + { category: "Home Goods", value: 13200 }, + { category: "Books", value: 8800 }, + { category: "Toys", value: 7600 }, +]; + +const monthlyData = [ + { month: "January", revenue: 1250 }, + { month: "February", revenue: 980 }, + { month: "March", revenue: 1450 }, + { month: "April", revenue: 1320 }, + { month: "May", revenue: 1680 }, + { month: "June", revenue: 2100 }, +]; + +const minimalData = [ + { name: "A", amount: 100 }, + { name: "B", amount: 250 }, + { name: "C", amount: 180 }, +]; + +const largeData = [ + { category: "Electronics", value: 12500 }, + { category: "Apparel", value: 9800 }, + { category: "Groceries", value: 14500 }, + { category: "Home Goods", value: 13200 }, + { category: "Books", value: 8800 }, + { category: "Toys", value: 7600 }, + { category: "Automotive", value: 6500 }, + { category: "Health", value: 11200 }, + { category: "Beauty", value: 9300 }, + { category: "Sports", value: 8100 }, + { category: "Outdoors", value: 7200 }, + { category: "Music", value: 4500 }, + { category: "Software", value: 10500 }, +]; + +const meta: Meta> = { + title: "Components/ChartsV2/D3PieChart", + component: D3PieChart, + parameters: { + layout: "centered", + }, + tags: ["autodocs", "!dev"], + argTypes: { + theme: { + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + }, + variant: { + control: "radio", + options: ["pie", "donut"], + }, + appearance: { + control: "radio", + options: ["circular", "semiCircular"], + }, + format: { + control: "radio", + options: ["number", "percentage"], + }, + cornerRadius: { control: { type: "range", min: 0, max: 20, step: 1 } }, + paddingAngle: { control: { type: "range", min: 0, max: 10, step: 0.5 } }, + legend: { control: "boolean" }, + isAnimationActive: { control: "boolean" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DataExplorer: Story = { + name: "Data Explorer", + args: { + data: salesData, + categoryKey: "category", + dataKey: "value", + theme: "ocean", + variant: "pie", + appearance: "circular", + format: "number", + legend: true, + isAnimationActive: false, + cornerRadius: 0, + paddingAngle: 0, + }, + render: (args: any) => ( + + + + ), +}; + +export const VariantsComparison: Story = { + name: "Variants Comparison", + render: () => ( +
+
+

Pie - Circular

+ + + +
+
+

Donut - Circular

+ + + +
+
+

Pie - Semi-circular

+ + + +
+
+

Donut - Semi-circular

+ + + +
+
+ ), +}; + +export const PercentageFormat: Story = { + name: "Percentage Format", + args: { + data: salesData, + categoryKey: "category", + dataKey: "value", + theme: "spectrum", + variant: "donut", + format: "percentage", + legend: true, + cornerRadius: 4, + paddingAngle: 2, + }, + render: (args: any) => ( + + + + ), +}; + +export const StyledSlices: Story = { + name: "Corner Radius & Padding", + render: () => ( +
+
+

cornerRadius=8

+ + + +
+
+

paddingAngle=4

+ + + +
+
+ ), +}; + +export const CustomPaletteStory: Story = { + name: "Custom Palette", + args: { + data: salesData, + categoryKey: "category", + dataKey: "value", + customPalette: ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD"], + variant: "pie", + legend: true, + }, + render: (args: any) => ( + + + + ), +}; + +export const AnimationDemo: Story = { + name: "Animation Demo", + args: { + data: salesData, + categoryKey: "category", + dataKey: "value", + theme: "orchid", + variant: "donut", + isAnimationActive: true, + cornerRadius: 4, + paddingAngle: 1, + }, + render: (args: any) => ( + + + + ), +}; + +export const MinimalData: Story = { + name: "Minimal Data (3 items)", + render: () => ( + + + + ), +}; + +export const LargeDataset: Story = { + name: "Large Dataset (13 items)", + render: () => ( + + + + ), +}; + +export const OnClickHandler: Story = { + name: "onClick Handler", + render: () => { + const [clickLog, setClickLog] = useState< + Array<{ row: Record; index: number }> + >([]); + + const handleClick = useCallback((row: Record, index: number) => { + setClickLog((prev) => [{ row, index }, ...prev].slice(0, 5)); + }, []); + + const logStyle: React.CSSProperties = { + fontSize: "12px", + fontFamily: "monospace", + padding: "6px 10px", + background: "#f0f4f8", + borderRadius: "4px", + marginBottom: "4px", + border: "1px solid #e2e8f0", + }; + + return ( +
+ + + +
+ Click Log (last 5): +
+ {clickLog.length === 0 ? ( +

+ Click on slices to see events here... +

+ ) : ( + clickLog.map((entry, i) => ( +
+ index: {entry.index} |{" "} + {Object.entries(entry.row) + .map(([k, v]) => `${k}: ${v}`) + .join(", ")} +
+ )) + )} +
+
+
+ ); + }, +}; + +export const FixedDimensions: Story = { + name: "Fixed Dimensions", + render: () => ( +
+
+

+ width={300} height={300} +

+ + + +
+
+

+ width={500} height={400} variant="donut" +

+ + + +
+
+ ), +}; + +export const ThemeShowcase: Story = { + name: "Theme Showcase", + render: () => { + const themes = ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"] as const; + return ( +
+ {themes.map((t) => ( +
+

+ {t} +

+ + + +
+ ))} +
+ ); + }, +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3PieChart/types.ts b/packages/react-ui/src/components/ChartsV2/D3PieChart/types.ts new file mode 100644 index 000000000..2661088dd --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3PieChart/types.ts @@ -0,0 +1,26 @@ +import type { ChartData } from "../types"; +import type { PaletteName } from "../utils/paletteUtils"; + +export type D3PieChartData = ChartData; + +export interface D3PieChartProps { + data: T; + categoryKey: keyof T[number]; + dataKey: keyof T[number]; + theme?: PaletteName; + customPalette?: string[]; + variant?: "pie" | "donut"; + appearance?: "circular" | "semiCircular"; + format?: "percentage" | "number"; + legend?: boolean; + isAnimationActive?: boolean; + cornerRadius?: number; + paddingAngle?: number; + maxChartSize?: number; + minChartSize?: number; + height?: number | string; + width?: number | string; + fitLegendInHeight?: boolean; + className?: string; + onClick?: (row: T[number], index: number) => void; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/D3RadarChart.tsx b/packages/react-ui/src/components/ChartsV2/D3RadarChart/D3RadarChart.tsx new file mode 100644 index 000000000..452eb8290 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/D3RadarChart.tsx @@ -0,0 +1,155 @@ +import clsx from "clsx"; +import { useCallback, useMemo } from "react"; + +import { useRadarChartOrchestrator } from "../hooks"; +import { DefaultLegend } from "../shared/core/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/core/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/core/PortalTooltip/ChartTooltip"; +import { RadarAxisLabels } from "./parts/RadarAxisLabels"; +import { RadarGrid } from "./parts/RadarGrid"; +import { RadarSeries } from "./parts/RadarSeries"; +import type { D3RadarChartData, D3RadarChartProps } from "./types"; + +export function D3RadarChart(props: D3RadarChartProps) { + const { + data, + categoryKey, + theme = "ocean", + customPalette, + gridShape = "polygon", + gridLevels = 5, + grid: showGrid = true, + legend: showLegend = true, + icons, + isAnimationActive = true, + showDots = true, + dotRadius = 4, + fillOpacity = 0.15, + maxChartSize = 500, + minChartSize = 150, + height, + width, + fitLegendInHeight, + className, + onClick, + } = props; + + const orch = useRadarChartOrchestrator({ + data, + categoryKey, + chartThemeName: theme, + customPalette, + icons, + showLegend, + maxChartSize, + minChartSize, + height, + width, + fitLegendInHeight, + onClick, + }); + + const numAxes = data.length; + + const findIndex = useCallback( + (mouseX: number, mouseY: number) => { + if (numAxes === 0) return -1; + let angle = Math.atan2(mouseY, mouseX) + Math.PI / 2; + if (angle < 0) angle += 2 * Math.PI; + return Math.round(angle / ((2 * Math.PI) / numAxes)) % numAxes; + }, + [numAxes], + ); + + const mouseHandlers = useMemo( + () => orch.hover.createMouseHandlers(findIndex), + [orch.hover.createMouseHandlers, findIndex], + ); + + // Empty state + if (!data || data.length === 0) { + return ( +
+ No data available +
+ ); + } + + return ( + +
+
+ + + {showGrid && ( + + )} + + + + +
+ + {showLegend && ( + + )} + + {orch.tooltip.tooltipPayload && orch.hover.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/d3RadarChart.scss b/packages/react-ui/src/components/ChartsV2/D3RadarChart/d3RadarChart.scss new file mode 100644 index 000000000..64b8e3dff --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/d3RadarChart.scss @@ -0,0 +1,66 @@ +@use "../../../cssUtils" as cssUtils; + +.openui-d3-radar-chart-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + position: relative; +} + +.openui-d3-radar-chart-svg-wrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.openui-d3-radar-chart-grid-ring { + stroke: cssUtils.$border-default; + stroke-width: 1; + stroke-dasharray: 4 4; +} + +.openui-d3-radar-chart-grid-spoke { + stroke: cssUtils.$border-default; + stroke-width: 1; + stroke-opacity: 0.5; +} + +.openui-d3-radar-chart-polygon-area { + transition: opacity 0.2s ease; +} + +.openui-d3-radar-chart-polygon-stroke { + fill: none; + stroke-width: 2; +} + +.openui-d3-radar-chart-dot { + stroke: cssUtils.$foreground; + stroke-width: 1.5; + transition: + r 0.15s ease, + opacity 0.15s ease; +} + +.openui-d3-radar-chart-axis-label { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$text-neutral-secondary; +} + +.openui-d3-radar-chart-polygon--animated { + opacity: 0; + transform-origin: center center; + animation: openui-d3-radar-polygon-appear 0.5s ease-out forwards; +} + +@keyframes openui-d3-radar-polygon-appear { + from { + opacity: 0; + transform: scale(0.3); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/index.ts b/packages/react-ui/src/components/ChartsV2/D3RadarChart/index.ts new file mode 100644 index 000000000..88bbdb8ca --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/index.ts @@ -0,0 +1,2 @@ +export { D3RadarChart } from "./D3RadarChart"; +export type { D3RadarChartData, D3RadarChartProps } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarAxisLabels.tsx b/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarAxisLabels.tsx new file mode 100644 index 000000000..24139011a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarAxisLabels.tsx @@ -0,0 +1,49 @@ +import type { ChartData } from "../../types"; +import { radarAxisAngle, radarLabelAnchor } from "../../utils/polarUtils"; + +interface RadarAxisLabelsProps { + data: T; + catKey: string; + numAxes: number; + maxRadius: number; + labelPadding?: number; +} + +export function RadarAxisLabels({ + data, + catKey, + numAxes, + maxRadius, + labelPadding = 16, +}: RadarAxisLabelsProps) { + const r = maxRadius + labelPadding; + + return ( + + {data.map((row, i) => { + const angle = radarAxisAngle(i, numAxes); + const x = r * Math.cos(angle); + const y = r * Math.sin(angle); + const anchor = radarLabelAnchor(angle); + + const sinAngle = Math.sin(angle); + let baseline: "auto" | "middle" | "hanging" = "middle"; + if (sinAngle < -0.3) baseline = "auto"; + else if (sinAngle > 0.3) baseline = "hanging"; + + return ( + + {String(row[catKey])} + + ); + })} + + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarGrid.tsx b/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarGrid.tsx new file mode 100644 index 000000000..46a67bae3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarGrid.tsx @@ -0,0 +1,56 @@ +import { radarAxisAngle } from "../../utils/polarUtils"; + +interface RadarGridProps { + maxRadius: number; + gridLevels: number; + gridShape: "circle" | "polygon"; + numAxes: number; +} + +function buildPolygonPoints(radius: number, numAxes: number): string { + return Array.from({ length: numAxes }, (_, i) => { + const angle = radarAxisAngle(i, numAxes); + return `${radius * Math.cos(angle)},${radius * Math.sin(angle)}`; + }).join(" "); +} + +export function RadarGrid({ maxRadius, gridLevels, gridShape, numAxes }: RadarGridProps) { + const levels = Array.from({ length: gridLevels }, (_, i) => ((i + 1) / gridLevels) * maxRadius); + + return ( + + {levels.map((r) => + gridShape === "circle" ? ( + + ) : ( + + ), + )} + {Array.from({ length: numAxes }, (_, i) => { + const angle = radarAxisAngle(i, numAxes); + return ( + + ); + })} + + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarSeries.tsx b/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarSeries.tsx new file mode 100644 index 000000000..b4dbd7fcf --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarSeries.tsx @@ -0,0 +1,98 @@ +import type { ScaleLinear } from "d3-scale"; + +import type { ChartData } from "../../types"; +import { radarAxisAngle } from "../../utils/polarUtils"; + +interface RadarSeriesProps { + data: T; + dataKeys: string[]; + catKey: string; + radialScale: ScaleLinear; + numAxes: number; + colorMap: Record; + fillOpacity: number; + showDots: boolean; + dotRadius: number; + hoveredIndex: number | null; + isAnimationActive: boolean; + isPrinting: boolean; +} + +function computeVertices( + data: ChartData, + key: string, + numAxes: number, + radialScale: ScaleLinear, +): Array<{ x: number; y: number }> { + return data.map((row, i) => { + const angle = radarAxisAngle(i, numAxes); + const r = radialScale(Number(row[key]) || 0); + return { x: r * Math.cos(angle), y: r * Math.sin(angle) }; + }); +} + +export function RadarSeries({ + data, + dataKeys, + catKey: _catKey, + radialScale, + numAxes, + colorMap, + fillOpacity, + showDots, + dotRadius, + hoveredIndex, + isAnimationActive, + isPrinting, +}: RadarSeriesProps) { + const shouldAnimate = isAnimationActive && !isPrinting; + + return ( + + {dataKeys.map((key, seriesIdx) => { + const color = colorMap[key] ?? "#000"; + const vertices = computeVertices(data, key, numAxes, radialScale); + const points = vertices.map((v) => `${v.x},${v.y}`).join(" "); + + const animationClass = shouldAnimate ? "openui-d3-radar-chart-polygon--animated" : ""; + const animationDelay = shouldAnimate ? `${seriesIdx * 80}ms` : undefined; + + return ( + + + + {showDots && + vertices.map((v, i) => { + const isHoveredAxis = hoveredIndex === i; + const r = isHoveredAxis ? dotRadius * 1.5 : dotRadius; + const opacity = hoveredIndex !== null && !isHoveredAxis ? 0.3 : 1; + + return ( + + ); + })} + + ); + })} + + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/stories/d3RadarChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3RadarChart/stories/d3RadarChart.stories.tsx new file mode 100644 index 000000000..a359da070 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/stories/d3RadarChart.stories.tsx @@ -0,0 +1,243 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Card } from "../../../Card"; +import { D3RadarChart } from "../D3RadarChart"; +import type { D3RadarChartProps } from "../types"; + +const playerData = [ + { attribute: "Speed", playerA: 75, playerB: 60, playerC: 85 }, + { attribute: "Power", playerA: 80, playerB: 72, playerC: 78 }, + { attribute: "Defense", playerA: 65, playerB: 88, playerC: 70 }, + { attribute: "Stamina", playerA: 90, playerB: 75, playerC: 82 }, + { attribute: "Technique", playerA: 70, playerB: 85, playerC: 68 }, + { attribute: "Agility", playerA: 88, playerB: 65, playerC: 90 }, +]; + +const singleSeriesData = [ + { skill: "HTML", level: 90 }, + { skill: "CSS", level: 85 }, + { skill: "JavaScript", level: 78 }, + { skill: "TypeScript", level: 72 }, + { skill: "React", level: 88 }, + { skill: "Node.js", level: 65 }, + { skill: "SQL", level: 60 }, + { skill: "DevOps", level: 45 }, +]; + +const manySeriesData = [ + { metric: "A", s1: 80, s2: 65, s3: 72, s4: 90, s5: 55 }, + { metric: "B", s1: 70, s2: 85, s3: 60, s4: 75, s5: 88 }, + { metric: "C", s1: 90, s2: 50, s3: 82, s4: 68, s5: 72 }, + { metric: "D", s1: 65, s2: 78, s3: 90, s4: 55, s5: 80 }, + { metric: "E", s1: 75, s2: 70, s3: 68, s4: 85, s5: 62 }, +]; + +const meta: Meta> = { + title: "Components/ChartsV2/D3RadarChart", + component: D3RadarChart, + parameters: { + layout: "centered", + }, + tags: ["autodocs", "!dev"], + argTypes: { + theme: { + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + }, + gridShape: { + control: "radio", + options: ["polygon", "circle"], + }, + gridLevels: { control: { type: "range", min: 2, max: 10, step: 1 } }, + grid: { control: "boolean" }, + legend: { control: "boolean" }, + isAnimationActive: { control: "boolean" }, + showDots: { control: "boolean" }, + dotRadius: { control: { type: "range", min: 1, max: 10, step: 1 } }, + fillOpacity: { control: { type: "range", min: 0, max: 1, step: 0.05 } }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DataExplorer: Story = { + name: "Data Explorer", + args: { + data: playerData, + categoryKey: "attribute", + theme: "ocean", + gridShape: "polygon", + gridLevels: 5, + grid: true, + legend: true, + isAnimationActive: true, + showDots: true, + dotRadius: 4, + fillOpacity: 0.15, + }, + render: (args: any) => ( + + + + ), +}; + +export const GridShapes: Story = { + name: "Grid Shapes", + render: () => ( +
+
+

Polygon Grid

+ + + +
+
+

Circle Grid

+ + + +
+
+ ), +}; + +export const ThemeShowcase: Story = { + name: "Theme Showcase", + render: () => { + const themes = ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"] as const; + return ( +
+ {themes.map((t) => ( +
+

+ {t} +

+ + + +
+ ))} +
+ ); + }, +}; + +export const SingleSeries: Story = { + name: "Single Series", + render: () => ( + + + + ), +}; + +export const ManySeries: Story = { + name: "Many Series", + render: () => ( + + + + ), +}; + +export const FillOpacity: Story = { + name: "Fill Opacity Comparison", + render: () => ( +
+
+

opacity: 0.05

+ + + +
+
+

opacity: 0.3

+ + + +
+
+ ), +}; + +export const FixedDimensions: Story = { + name: "Fixed Dimensions", + render: () => ( +
+
+

+ width={300} height={300} +

+ + + +
+
+

+ width={500} height={500} +

+ + + +
+
+ ), +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/types.ts b/packages/react-ui/src/components/ChartsV2/D3RadarChart/types.ts new file mode 100644 index 000000000..02b0ab09d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/types.ts @@ -0,0 +1,27 @@ +import type { ChartData } from "../types"; +import type { PaletteName } from "../utils/paletteUtils"; + +export type D3RadarChartData = ChartData; + +export interface D3RadarChartProps { + data: T; + categoryKey: keyof T[number]; + theme?: PaletteName; + customPalette?: string[]; + gridShape?: "circle" | "polygon"; + gridLevels?: number; + grid?: boolean; + legend?: boolean; + icons?: Partial>; + isAnimationActive?: boolean; + showDots?: boolean; + dotRadius?: number; + fillOpacity?: number; + maxChartSize?: number; + minChartSize?: number; + height?: number | string; + width?: number | string; + fitLegendInHeight?: boolean; + className?: string; + onClick?: (row: T[number], index: number) => void; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx b/packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx new file mode 100644 index 000000000..35c051135 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx @@ -0,0 +1,143 @@ +import clsx from "clsx"; +import { useMemo } from "react"; + +import { useCategoricalChartOrchestrator } from "../hooks"; +import { DefaultLegend } from "../shared/core/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/core/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/core/PortalTooltip/ChartTooltip"; +import { RadialBars } from "./parts/RadialBars"; +import { RadialGrid } from "./parts/RadialGrid"; +import type { D3RadialChartData, D3RadialChartProps } from "./types"; + +const BAR_GAP = 2; + +export function D3RadialChart(props: D3RadialChartProps) { + const { + data, + categoryKey, + dataKey, + theme = "ocean", + customPalette, + variant = "circular", + format = "number", + legend: showLegend = true, + grid: showGrid = false, + isAnimationActive = false, + cornerRadius = 10, + maxChartSize = 500, + minChartSize = 150, + height, + width, + fitLegendInHeight, + className, + onClick, + } = props; + + const isSemiCircular = variant === "semiCircular"; + + const orch = useCategoricalChartOrchestrator({ + data, + categoryKey, + dataKey, + chartThemeName: theme, + customPalette, + format, + showLegend, + isSemiCircular, + maxChartSize, + minChartSize, + height, + width, + fitLegendInHeight, + }); + + // Radial-specific geometry + const maxRadius = orch.dimensions.chartSize * 0.45; + const minRadius = maxRadius * 0.25; + const startAngle = 0; + const endAngle = isSemiCircular ? Math.PI : 2 * Math.PI; + + const maxValue = useMemo( + () => Math.max(...orch.data.visibleSlices.map((s) => s.value), 0), + [orch.data.visibleSlices], + ); + + // Empty state + if (!data || data.length === 0) { + return ( +
+ No data available +
+ ); + } + + return ( + +
+
+ + + {showGrid && ( + + )} + !orch.data.hiddenSlices.has(String(row[String(categoryKey)])), + )} + onMouseMove={orch.hover.handleMouseMove} + onMouseLeave={orch.hover.handleMouseLeave} + onClick={onClick} + /> + + +
+ + {showLegend && ( + + )} + + {orch.tooltip.tooltipPayload && orch.hover.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadialChart/d3RadialChart.scss b/packages/react-ui/src/components/ChartsV2/D3RadialChart/d3RadialChart.scss new file mode 100644 index 000000000..f7713b12a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadialChart/d3RadialChart.scss @@ -0,0 +1,41 @@ +@use "../../../cssUtils" as cssUtils; + +.openui-d3-radial-chart-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + position: relative; +} + +.openui-d3-radial-chart-svg-wrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.openui-d3-radial-chart-empty { + @include cssUtils.typography(body, default); + color: cssUtils.$text-neutral-secondary; +} + +.openui-d3-radial-chart-grid-circle { + stroke: cssUtils.$border-default; + stroke-width: 1; + stroke-dasharray: 4 4; +} + +.openui-d3-radial-chart-bar--animated { + opacity: 0; + transform-origin: center center; + animation: openui-d3-radial-bar-appear 0.5s ease-out forwards; +} + +@keyframes openui-d3-radial-bar-appear { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadialChart/index.ts b/packages/react-ui/src/components/ChartsV2/D3RadialChart/index.ts new file mode 100644 index 000000000..1f7603290 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadialChart/index.ts @@ -0,0 +1,2 @@ +export { D3RadialChart } from "./D3RadialChart"; +export type { D3RadialChartData, D3RadialChartProps } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/D3RadialChart/parts/RadialBars.tsx b/packages/react-ui/src/components/ChartsV2/D3RadialChart/parts/RadialBars.tsx new file mode 100644 index 000000000..35159a2bb --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadialChart/parts/RadialBars.tsx @@ -0,0 +1,98 @@ +import { arc } from "d3-shape"; +import React, { useCallback, useMemo } from "react"; +import type { CategoricalSlice } from "../../hooks"; +import { getSliceStyle } from "../../utils/polarUtils"; + +interface RadialBarsProps { + slices: CategoricalSlice[]; + maxValue: number; + maxRadius: number; + minRadius: number; + startAngle: number; + endAngle: number; + cornerRadius: number; + barGap: number; + hoveredIndex: number | null; + isAnimationActive: boolean; + isPrinting: boolean; + data: T[]; + onMouseMove: (event: React.MouseEvent, index: number) => void; + onMouseLeave: () => void; + onClick?: (row: T, index: number) => void; +} + +export function RadialBars({ + slices, + maxValue, + maxRadius, + minRadius, + startAngle, + endAngle, + cornerRadius, + barGap, + hoveredIndex, + isAnimationActive, + isPrinting, + data, + onMouseMove, + onMouseLeave, + onClick, +}: RadialBarsProps) { + const handleClick = useCallback( + (index: number) => { + if (onClick) { + onClick(data[index]!, index); + } + }, + [onClick, data], + ); + + const animate = isAnimationActive && !isPrinting; + const totalSweep = endAngle - startAngle; + const barThickness = (maxRadius - minRadius) / slices.length; + + const bars = useMemo( + () => + slices.map((slice, i) => { + const barInner = minRadius + i * barThickness + barGap; + const barOuter = minRadius + (i + 1) * barThickness; + const sweepAngle = maxValue > 0 ? (slice.value / maxValue) * totalSweep : 0; + + const arcGen = arc() + .innerRadius(barInner) + .outerRadius(barOuter) + .startAngle(startAngle) + .endAngle(startAngle + sweepAngle) + .cornerRadius(cornerRadius); + + return { + path: arcGen(null) as string, + color: slice.color, + label: slice.label, + }; + }), + [slices, maxValue, minRadius, startAngle, totalSweep, cornerRadius, barGap, barThickness], + ); + + return ( + + {bars.map((bar, i) => ( + onMouseMove(e, i)} + onMouseLeave={onMouseLeave} + onClick={() => handleClick(i)} + /> + ))} + + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadialChart/parts/RadialGrid.tsx b/packages/react-ui/src/components/ChartsV2/D3RadialChart/parts/RadialGrid.tsx new file mode 100644 index 000000000..236f18fe3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadialChart/parts/RadialGrid.tsx @@ -0,0 +1,55 @@ +interface RadialGridProps { + maxRadius: number; + minRadius: number; + gridLevels?: number; + startAngle: number; + endAngle: number; +} + +export function RadialGrid({ + maxRadius, + minRadius, + gridLevels = 5, + startAngle, + endAngle, +}: RadialGridProps) { + const isSemiCircular = endAngle - startAngle < 2 * Math.PI; + + return ( + + {Array.from({ length: gridLevels }, (_, i) => { + const fraction = (i + 1) / gridLevels; + const r = minRadius + (maxRadius - minRadius) * fraction; + + if (isSemiCircular) { + const x1 = r * Math.cos(startAngle - Math.PI / 2); + const y1 = r * Math.sin(startAngle - Math.PI / 2); + const x2 = r * Math.cos(endAngle - Math.PI / 2); + const y2 = r * Math.sin(endAngle - Math.PI / 2); + // SVG arc large-arc-flag: use long arc when sweep > 180 degrees + const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0; + + return ( + + ); + } + + return ( + + ); + })} + + ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3RadialChart/stories/d3RadialChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3RadialChart/stories/d3RadialChart.stories.tsx new file mode 100644 index 000000000..f2be17364 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadialChart/stories/d3RadialChart.stories.tsx @@ -0,0 +1,367 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useCallback, useState } from "react"; +import { Card } from "../../../Card"; +import { D3RadialChart } from "../D3RadialChart"; +import type { D3RadialChartProps } from "../types"; + +const monthlyData = [ + { month: "January", revenue: 1250 }, + { month: "February", revenue: 980 }, + { month: "March", revenue: 1450 }, + { month: "April", revenue: 1320 }, + { month: "May", revenue: 1680 }, + { month: "June", revenue: 2100 }, +]; + +const performanceData = [ + { metric: "Speed", score: 92 }, + { metric: "Reliability", score: 85 }, + { metric: "Usability", score: 78 }, + { metric: "Security", score: 95 }, + { metric: "Performance", score: 88 }, +]; + +const largeData = [ + { category: "Base Salary", amount: 75000 }, + { category: "Q1 Bonus", amount: 8500 }, + { category: "Q2 Bonus", amount: 9200 }, + { category: "Q3 Bonus", amount: 7800 }, + { category: "Q4 Bonus", amount: 11000 }, + { category: "Holiday Pay", amount: 6500 }, + { category: "Overtime", amount: 4200 }, + { category: "Commission", amount: 8900 }, + { category: "Performance Incentive", amount: 7200 }, + { category: "Stock Options", amount: 12000 }, + { category: "Healthcare Benefits", amount: 4800 }, + { category: "Retirement Match", amount: 3600 }, +]; + +const meta: Meta> = { + title: "Components/ChartsV2/D3RadialChart", + component: D3RadialChart, + parameters: { + layout: "centered", + }, + tags: ["autodocs", "!dev"], + argTypes: { + theme: { + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + }, + variant: { + control: "radio", + options: ["circular", "semiCircular"], + }, + format: { + control: "radio", + options: ["number", "percentage"], + }, + cornerRadius: { control: { type: "range", min: 0, max: 20, step: 1 } }, + grid: { control: "boolean" }, + legend: { control: "boolean" }, + isAnimationActive: { control: "boolean" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DataExplorer: Story = { + name: "Data Explorer", + args: { + data: monthlyData, + categoryKey: "month", + dataKey: "revenue", + theme: "ocean", + variant: "circular", + format: "number", + legend: true, + grid: false, + isAnimationActive: false, + cornerRadius: 10, + }, + render: (args: any) => ( + + + + ), +}; + +export const CircularVsSemiCircular: Story = { + name: "Circular vs Semi-circular", + render: () => ( +
+
+

Circular

+ + + +
+
+

Semi-circular

+ + + +
+
+ ), +}; + +export const WithGrid: Story = { + name: "With Grid Lines", + render: () => ( +
+
+

Circular with grid

+ + + +
+
+

Semi-circular with grid

+ + + +
+
+ ), +}; + +export const PercentageFormat: Story = { + name: "Percentage Format", + args: { + data: performanceData, + categoryKey: "metric", + dataKey: "score", + theme: "spectrum", + variant: "circular", + format: "percentage", + legend: true, + cornerRadius: 8, + }, + render: (args: any) => ( + + + + ), +}; + +export const CustomPaletteStory: Story = { + name: "Custom Palette", + args: { + data: performanceData, + categoryKey: "metric", + dataKey: "score", + customPalette: ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7"], + variant: "circular", + legend: true, + cornerRadius: 12, + }, + render: (args: any) => ( + + + + ), +}; + +export const LargeDataset: Story = { + name: "Large Dataset (12 items)", + render: () => ( + + + + ), +}; + +export const AnimationDemo: Story = { + name: "Animation Demo", + args: { + data: performanceData, + categoryKey: "metric", + dataKey: "score", + theme: "vivid", + variant: "circular", + isAnimationActive: true, + cornerRadius: 10, + }, + render: (args: any) => ( + + + + ), +}; + +export const OnClickHandler: Story = { + name: "onClick Handler", + render: () => { + const [clickLog, setClickLog] = useState< + Array<{ row: Record; index: number }> + >([]); + + const handleClick = useCallback((row: Record, index: number) => { + setClickLog((prev) => [{ row, index }, ...prev].slice(0, 5)); + }, []); + + const logStyle: React.CSSProperties = { + fontSize: "12px", + fontFamily: "monospace", + padding: "6px 10px", + background: "#f0f4f8", + borderRadius: "4px", + marginBottom: "4px", + border: "1px solid #e2e8f0", + }; + + return ( +
+ + + +
+ Click Log (last 5): +
+ {clickLog.length === 0 ? ( +

+ Click on bars to see events here... +

+ ) : ( + clickLog.map((entry, i) => ( +
+ index: {entry.index} |{" "} + {Object.entries(entry.row) + .map(([k, v]) => `${k}: ${v}`) + .join(", ")} +
+ )) + )} +
+
+
+ ); + }, +}; + +export const ThemeShowcase: Story = { + name: "Theme Showcase", + render: () => { + const themes = ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"] as const; + return ( +
+ {themes.map((t) => ( +
+

+ {t} +

+ + + +
+ ))} +
+ ); + }, +}; + +export const FixedDimensions: Story = { + name: "Fixed Dimensions", + render: () => ( +
+
+

+ width={300} height={300} +

+ + + +
+
+

+ width={500} height={400} +

+ + + +
+
+ ), +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3RadialChart/types.ts b/packages/react-ui/src/components/ChartsV2/D3RadialChart/types.ts new file mode 100644 index 000000000..85467954e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3RadialChart/types.ts @@ -0,0 +1,25 @@ +import type { ChartData } from "../types"; +import type { PaletteName } from "../utils/paletteUtils"; + +export type D3RadialChartData = ChartData; + +export interface D3RadialChartProps { + data: T; + categoryKey: keyof T[number]; + dataKey: keyof T[number]; + theme?: PaletteName; + customPalette?: string[]; + variant?: "circular" | "semiCircular"; + format?: "percentage" | "number"; + legend?: boolean; + grid?: boolean; + isAnimationActive?: boolean; + cornerRadius?: number; + maxChartSize?: number; + minChartSize?: number; + height?: number | string; + width?: number | string; + fitLegendInHeight?: boolean; + className?: string; + onClick?: (row: T[number], index: number) => void; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx new file mode 100644 index 000000000..d3f41aff1 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx @@ -0,0 +1,170 @@ +import clsx from "clsx"; + +import { useScatterChartOrchestrator } from "../hooks"; +import { YAxis } from "../shared/cartesian/axes/YAxis"; +import { Grid } from "../shared/cartesian/Grid"; +import { VerticalGrid } from "../shared/cartesian/VerticalGrid"; +import { DefaultLegend } from "../shared/core/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/core/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/core/PortalTooltip/ChartTooltip"; +import { NumericXAxis } from "./parts/NumericXAxis"; +import { ScatterDots } from "./parts/ScatterDots"; +import type { D3ScatterChartProps } from "./types"; + +export function D3ScatterChart(props: D3ScatterChartProps) { + const { + data, + theme = "ocean", + customPalette, + grid: showGrid = true, + verticalGrid: showVerticalGrid = true, + legend: showLegend = true, + showYAxis = true, + xAxisLabel, + yAxisLabel, + isAnimationActive = true, + dotRadius = 4, + className, + height, + width, + fitLegendInHeight, + onClick, + } = props; + + const orch = useScatterChartOrchestrator({ + data, + chartThemeName: theme, + customPalette, + showLegend, + showYAxis, + height, + width, + fitLegendInHeight, + xAxisLabel: typeof xAxisLabel === "string" ? xAxisLabel : undefined, + yAxisLabel: typeof yAxisLabel === "string" ? yAxisLabel : undefined, + onClick, + }); + + if (!data || data.length === 0) { + return ( +
+ No data available +
+ ); + } + + const { + dimensions: { + effectiveYAxisWidth, + chartAreaWidth, + chartInnerHeight, + totalSvgWidth, + totalSvgHeight, + xAxisHeight, + CHART_MARGIN_TOP: marginTop, + }, + scales: { xScale, yScale }, + } = orch; + + return ( + +
+ + {showYAxis && ( + + + + )} + + + + + {showGrid && ( + + )} + + {showVerticalGrid && ( + + )} + + + + + + + + + + {showLegend && ( + + )} + + {orch.tooltip.tooltipPayload && orch.hover.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/d3ScatterChart.scss b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/d3ScatterChart.scss new file mode 100644 index 000000000..fa223e7c3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/d3ScatterChart.scss @@ -0,0 +1,53 @@ +@use "../../../cssUtils" as cssUtils; + +.openui-d3-scatter-chart-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + position: relative; +} + +.openui-d3-scatter-chart-grid line { + stroke: cssUtils.$border-default; + stroke-dasharray: 3 3; +} + +.openui-d3-scatter-chart-grid-vertical line { + stroke: cssUtils.$border-default; + stroke-dasharray: 3 3; +} + +.openui-d3-scatter-chart-y-tick { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$text-neutral-secondary; +} + +.openui-d3-scatter-chart-x-tick { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$text-neutral-secondary; +} + +.openui-d3-scatter-chart-dot { + stroke: cssUtils.$foreground; + stroke-width: 1.5; + transition: + r 0.15s ease, + opacity 0.15s ease; +} + +.openui-d3-scatter-chart-dot--animated { + opacity: 0; + transform-origin: center center; + animation: openui-d3-scatter-dot-appear 0.4s ease-out forwards; +} + +@keyframes openui-d3-scatter-dot-appear { + from { + opacity: 0; + r: 0; + } + to { + opacity: 1; + } +} diff --git a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/index.ts b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/index.ts new file mode 100644 index 000000000..d7eb75b0f --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/index.ts @@ -0,0 +1,2 @@ +export { D3ScatterChart } from "./D3ScatterChart"; +export type { D3ScatterChartData, D3ScatterChartProps } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/NumericXAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/NumericXAxis.tsx new file mode 100644 index 000000000..e96cc23ff --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/NumericXAxis.tsx @@ -0,0 +1,41 @@ +import type { ScaleLinear } from "d3-scale"; +import React from "react"; +import { numberTickFormatter } from "../../utils/styleUtils"; + +interface NumericXAxisProps { + scale: ScaleLinear; + chartWidth: number; + height: number; + className?: string; + tickClassName?: string; +} + +const MIN_TICK_SPACING = 60; + +export const NumericXAxis: React.FC = ({ + scale, + chartWidth, + height, + className, + tickClassName, +}) => { + const tickCount = Math.max(2, Math.floor(chartWidth / MIN_TICK_SPACING)); + const ticks = scale.ticks(tickCount); + + return ( + + {ticks.map((tick) => ( + + {numberTickFormatter(tick)} + + ))} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/ScatterDots.tsx b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/ScatterDots.tsx new file mode 100644 index 000000000..ec5d966e7 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/ScatterDots.tsx @@ -0,0 +1,65 @@ +import type { ScaleLinear } from "d3-scale"; +import React from "react"; + +import type { VisibleDataset } from "../../hooks/cartesian/useScatterChartOrchestrator"; +import type { HoveredScatterPoint } from "../types"; + +interface ScatterDotsProps { + datasets: VisibleDataset[]; + xScale: ScaleLinear; + yScale: ScaleLinear; + colorMap: Record; + dotRadius: number; + hoveredPoint: HoveredScatterPoint | null; + isAnimationActive: boolean; + isPrinting: boolean; +} + +export const ScatterDots: React.FC = ({ + datasets, + xScale, + yScale, + colorMap, + dotRadius, + hoveredPoint, + isAnimationActive, + isPrinting, +}) => { + const shouldAnimate = isAnimationActive && !isPrinting; + + return ( + + {datasets.map(({ dataset: ds, originalIndex }) => { + const color = colorMap[ds.name] ?? "#000"; + const isHoveredDataset = hoveredPoint?.datasetName === ds.name; + const hasHover = hoveredPoint !== null; + + return ( + + {ds.data.map((pt, ptIdx) => { + const isHoveredDot = isHoveredDataset && hoveredPoint?.pointIndex === ptIdx; + + const r = isHoveredDot ? dotRadius * 1.5 : dotRadius; + const opacity = hasHover && !isHoveredDataset ? 0.3 : 1; + + const animationClass = shouldAnimate ? "openui-d3-scatter-chart-dot--animated" : ""; + const animationDelay = shouldAnimate ? `${originalIndex * 80}ms` : undefined; + + return ( + + ); + })} + + ); + })} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/stories/d3ScatterChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/stories/d3ScatterChart.stories.tsx new file mode 100644 index 000000000..b2bbb2a14 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/stories/d3ScatterChart.stories.tsx @@ -0,0 +1,260 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Card } from "../../../Card"; +import { D3ScatterChart } from "../D3ScatterChart"; +import type { D3ScatterChartData, D3ScatterChartProps } from "../types"; + +const performanceData: D3ScatterChartData = [ + { + name: "Team A", + data: [ + { x: 10, y: 30 }, + { x: 20, y: 45 }, + { x: 35, y: 65 }, + { x: 50, y: 55 }, + { x: 65, y: 80 }, + { x: 80, y: 72 }, + { x: 90, y: 88 }, + ], + }, + { + name: "Team B", + data: [ + { x: 15, y: 55 }, + { x: 25, y: 40 }, + { x: 40, y: 70 }, + { x: 55, y: 48 }, + { x: 70, y: 62 }, + { x: 85, y: 90 }, + ], + }, + { + name: "Team C", + data: [ + { x: 12, y: 20 }, + { x: 28, y: 35 }, + { x: 42, y: 50 }, + { x: 58, y: 42 }, + { x: 72, y: 58 }, + { x: 88, y: 68 }, + { x: 95, y: 75 }, + ], + }, +]; + +const singleDataset: D3ScatterChartData = [ + { + name: "Measurements", + data: [ + { x: 1, y: 2.5 }, + { x: 2, y: 4.1 }, + { x: 3, y: 3.8 }, + { x: 4, y: 6.2 }, + { x: 5, y: 5.9 }, + { x: 6, y: 7.4 }, + { x: 7, y: 8.1 }, + { x: 8, y: 7.8 }, + { x: 9, y: 9.5 }, + { x: 10, y: 10.2 }, + ], + }, +]; + +const meta: Meta = { + title: "Components/ChartsV2/D3ScatterChart", + component: D3ScatterChart, + parameters: { + layout: "centered", + }, + tags: ["autodocs", "!dev"], + argTypes: { + theme: { + control: "select", + options: ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"], + }, + grid: { control: "boolean" }, + verticalGrid: { control: "boolean" }, + legend: { control: "boolean" }, + showYAxis: { control: "boolean" }, + isAnimationActive: { control: "boolean" }, + dotRadius: { control: { type: "range", min: 1, max: 10, step: 1 } }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DataExplorer: Story = { + name: "Data Explorer", + args: { + data: performanceData, + theme: "ocean", + grid: true, + verticalGrid: true, + legend: true, + showYAxis: true, + isAnimationActive: true, + dotRadius: 4, + }, + render: (args: any) => ( + + + + ), +}; + +export const MultipleDatasets: Story = { + name: "Multiple Datasets", + render: () => ( + + + + ), +}; + +export const SingleDataset: Story = { + name: "Single Dataset", + render: () => ( + + + + ), +}; + +export const ThemeShowcase: Story = { + name: "Theme Showcase", + render: () => { + const themes = ["ocean", "orchid", "emerald", "sunset", "spectrum", "vivid"] as const; + return ( +
+ {themes.map((t) => ( +
+

+ {t} +

+ + + +
+ ))} +
+ ); + }, +}; + +export const GridOptions: Story = { + name: "Grid Options", + render: () => ( +
+
+

Both Grids

+ + + +
+
+

Horizontal Only

+ + + +
+
+

Vertical Only

+ + + +
+
+

No Grid

+ + + +
+
+ ), +}; + +export const AxisLabels: Story = { + name: "Axis Labels", + render: () => ( + + + + ), +}; + +export const FixedDimensions: Story = { + name: "Fixed Dimensions", + render: () => ( +
+
+

+ width={400} height={300} +

+ + + +
+
+

+ width={600} height={400} +

+ + + +
+
+ ), +}; diff --git a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/types.ts b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/types.ts new file mode 100644 index 000000000..dae4e5899 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/types.ts @@ -0,0 +1,45 @@ +import type { PaletteName } from "../utils/paletteUtils"; + +export interface ScatterPoint { + x: number; + y: number; + [key: string]: string | number | undefined; +} + +export interface ScatterDataset { + name: string; + data: ScatterPoint[]; +} + +export type D3ScatterChartData = ScatterDataset[]; + +export interface HoveredScatterPoint { + datasetIndex: number; + pointIndex: number; + point: ScatterPoint; + datasetName: string; +} + +export interface D3ScatterChartProps { + data: D3ScatterChartData; + theme?: PaletteName; + customPalette?: string[]; + grid?: boolean; + verticalGrid?: boolean; + legend?: boolean; + showYAxis?: boolean; + xAxisLabel?: React.ReactNode; + yAxisLabel?: React.ReactNode; + isAnimationActive?: boolean; + dotRadius?: number; + className?: string; + height?: number | string; + width?: number | string; + fitLegendInHeight?: boolean; + onClick?: ( + point: ScatterPoint, + datasetName: string, + datasetIndex: number, + pointIndex: number, + ) => void; +} diff --git a/packages/react-ui/src/components/ChartsV2/README.md b/packages/react-ui/src/components/ChartsV2/README.md new file mode 100644 index 000000000..32232f047 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/README.md @@ -0,0 +1,239 @@ +# ChartsV2 — Internal Developer Guide + +D3-based chart components for OpenUI. Five chart types across two topologies (cartesian and polar) share a common infrastructure of hooks, components, and utilities. + +### Cartesian Charts + +| Chart | File | Scale | Variants | +| ------------- | ----------------------------- | ------------ | --------------------------------- | +| `D3AreaChart` | `D3AreaChart/D3AreaChart.tsx` | `scalePoint` | linear, natural, step (+ stacked) | +| `D3BarChart` | `D3BarChart/D3BarChart.tsx` | `scaleBand` | grouped, stacked | +| `D3LineChart` | `D3LineChart/D3LineChart.tsx` | `scalePoint` | linear, natural, step | + +### Polar Charts + +| Chart | File | Topology | Variants | +| --------------- | --------------------------------- | -------- | ----------------------- | +| `D3PieChart` | `D3PieChart/D3PieChart.tsx` | polar | pie, donut (+ semi) | +| `D3RadialChart` | `D3RadialChart/D3RadialChart.tsx` | polar | circular, semiCircular | + +## Architecture + +### Cartesian Charts + +```text +┌──────────────────────────────────────────────────┐ +│ D3[Type]Chart Component │ +│ │ +│ useChartScrollableOrchestrator (hooks/cartesian)│ +│ ├── useChartData (keys, colors, legend) │ +│ ├── useChartDimensions (sizing, layout) │ +│ ├── useChartHover (hover state, handlers)│ +│ └── useChartScroll (scroll state) │ +│ │ +│ + type-specific scale hook (useXScale or │ +│ useXBandScale) + useYScale + useStackedData │ +│ │ +│ Renders via ScrollableChartLayout: │ +│ ├── YAxis (separate SVG) │ +│ ├── Main SVG (scrollable container) │ +│ │ ├── Grid, Series, Crosshair │ +│ │ └── XAxis │ +│ ├── ScrollButtonsHorizontal │ +│ ├── DefaultLegend │ +│ └── ChartTooltip (portal) │ +└──────────────────────────────────────────────────┘ +``` + +### Polar Charts + +```text +┌──────────────────────────────────────────────────┐ +│ D3PieChart / D3RadialChart │ +│ │ +│ useCategoricalChartOrchestrator (hooks/polar) │ +│ ├── useCategoricalChartData (slices, colors) │ +│ ├── useContainerSize (responsive size) │ +│ ├── useLegendHeight (legend measuring) │ +│ └── usePrintContext (print detection) │ +│ │ +│ + chart-specific geometry (arcs / radial bars) │ +│ │ +│ Renders: │ +│ ├── SVG with centered transform │ +│ │ └── PieSlices / RadialBars (+ RadialGrid) │ +│ ├── DefaultLegend │ +│ └── ChartTooltip (portal) │ +└──────────────────────────────────────────────────┘ +``` + +## Hooks Reference + +Hooks are organized into three layers under `hooks/` and re-exported from `hooks/index.ts`: + +```text +hooks/ + core/ — coordinate-agnostic (shared by all chart types) + cartesian/ — cartesian-only (scales, axes, scroll, orchestrators) + polar/ — polar-only (categorical orchestrator) + index.ts — barrel re-exporting all layers +``` + +### Orchestration + +| Hook | Layer | Signature | Description | +| ----------------------------------- | --------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useChartScrollableOrchestrator` | cartesian | `(params) => OrchestrationResult` | Master hook that composes `useChartData`, `useChartDimensions`, `useChartHover`, and `useChartScroll`. Returns 40+ properties covering refs, data, dimensions, hover, scroll, legend, tooltip, styles, and a `createMouseHandlers` factory. Used by all three cartesian charts. | +| `useChartCondensedOrchestrator` | cartesian | `(params) => OrchestrationResult` | Condensed variant orchestrator for cartesian charts. Similar composition but uses angled X-axis labels and no scroll. Used by `*Condensed` chart variants. | +| `useCategoricalChartOrchestrator` | polar | `(params) => OrchestrationResult` | Orchestrator for categorical (single-series) polar charts. Composes `useCategoricalChartData`, `useContainerSize`, `useLegendHeight`, `usePrintContext`. Provides refs, slices, dimensions, hover, tooltip, and legend state. Used by D3PieChart and D3RadialChart. | + +### Data (core/) + +| Hook | Signature | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useChartData` | `(params) => { dataKeys, colors, hiddenSeries, toggleSeries, legendItems, chartConfig, colorMap, chartStyle, ... }` | Extracts data keys, assigns palette colors, manages series visibility toggle, and builds legend items. Ensures at least one series stays visible. | +| `useCategoricalChartData` | `(params) => { slices, total, hiddenSlices, toggleSlice, legendItems, chartStyle, sortedData }` | Single-series categorical data hook for polar charts. Sorts by value, assigns colors, manages slice visibility. | +| `useTransformedKeys` | `(keys) => Record` | Maps data keys to shorthand IDs (`tk-0`, `tk-1`, ...) for CSS custom property names. Maintains a persistent cache across renders. | +| `useTooltipPayload` | `(hoveredIndex, data, dataKeys, catKey, chartConfig) => TooltipPayload \| null` | Builds tooltip content from the hovered data row. Returns null when nothing is hovered. | + +### Data (cartesian/) + +| Hook | Signature | Description | +| ---------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `useStackedData` | `(data, dataKeys, stacked) => StackedData \| null` | Runs D3 `stack()` generator. Returns null when stacking is disabled. Used by AreaChart and BarChart (stacked). | + +### Scales (cartesian/) + +| Hook | Signature | Charts | Description | +| --------------- | ---------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------- | +| `useXScale` | `(data, categoryKey, svgWidth, widthOfGroup) => ScalePoint` | Area, Line | D3 point scale — positions points centered within group width. | +| `useXBandScale` | `(data, categoryKey, svgWidth, paddingInner?, paddingOuter?) => ScaleBand` | Bar | D3 band scale — maps categories to discrete bands (default padding: 0.2 inner, 0.1 outer). | +| `useYScale` | `(data, dataKeys, chartInnerHeight, stackedData) => ScaleLinear` | All | D3 linear scale with auto-domain from data (handles stacked max). Applies `.nice()` for readable ticks. | + +### Layout & Dimensions + +| Hook | Layer | Signature | Description | +| ------------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useChartDimensions` | cartesian | `(params) => { containerWidth, effectiveYAxisWidth, tickVariant, xAxisHeight, chartInnerHeight, totalHeight, svgWidth, dataWidth, widthOfGroup, needsScroll, labelInterval, MARGIN_TOP }` | Calculates all layout measurements. Determines scroll necessity, auto-switches tick variant at 300px breakpoint, accounts for legend height when `fitLegendInHeight` is true. | +| `useContainerSize` | core | `(ref, fixedWidth?, fixedHeight?) => { width, height }` | Measures container via ResizeObserver. Supports fixed overrides that bypass measurement. | +| `useXAxisHeight` | cartesian | `(data, categoryKey, tickVariant, widthOfGroup?) => number` | Measures required X-axis label height by rendering hidden DOM elements. Returns 30px for `singleLine` variant. | +| `useYAxisWidth` | cartesian | `(data, dataKeys) => { yAxisWidth, setLabelWidth }` | Measures Y-axis width from formatted numbers. Clamped between 20-200px. `setLabelWidth` allows runtime refinement. | +| `useMaxLabelWidth` | cartesian | `(data, categoryKey) => number` | Measures maximum category label width in pixels via canvas context. Used by condensed orchestrator for angle calculation. | +| `useAutoAngleCalculation` | cartesian | `(maxLabelWidth, enabled, availableWidth?) => { angle, height }` | Calculates optimal label rotation angle for condensed charts. | +| `useCanvasContextForLabelSize` | core | `() => CanvasRenderingContext2D` | Returns a memoized canvas 2D context configured with theme font, used for text measurement. | +| `useLegendHeight` | core | `(ref, showLegend) => number` | Measures legend element height via ResizeObserver. Returns 0 when legend is hidden. | + +### Interaction + +| Hook | Layer | Signature | Description | +| ---------------- | --------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `useChartHover` | core | `(params) => { hoveredIndex, mousePos, createMouseHandlers }` | Manages hover state. `createMouseHandlers(findIndex)` is a factory that returns `handleMouseMove`, `handleMouseLeave`, `handleTouchMove`, `handleTouchEnd`, and `handleClick`. | +| `useChartScroll` | cartesian | `(params) => { canScrollLeft, canScrollRight, handleScroll, scrollTo }` | Manages horizontal scroll state with snap-to-data-point behavior. `scrollTo('left' \| 'right')` uses smooth scrolling. | + +### Context + +| Hook | Layer | Signature | Description | +| ----------------- | ----- | --------------- | ------------------------------------------------------------------------------------------- | +| `usePrintContext` | core | `() => boolean` | Detects print mode via `matchMedia("print")`. Useful for disabling animations during print. | + +## Shared Components Reference + +All components are in `shared/` and re-exported from `shared/index.ts`. + +### Axis Components + +| Component | File | Key Props | Description | +| ------------ | ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `XAxis` | `shared/XAxis.tsx` | `scale`, `data`, `categoryKey`, `tickVariant`, `labelInterval`, `classPrefix` | Renders X-axis category labels in `` elements. Supports `singleLine` and `multiLine` variants. Handles label interval skipping; last label always shows. Works with both point and band scales. | +| `AngledXAxis`| `shared/AngledXAxis.tsx`| `data`, `categoryKey`, `chartAreaWidth`, `angle`, `classPrefix` | Renders angled X-axis labels for condensed charts. Rotates labels to fit within constrained widths. | +| `YAxis` | `shared/YAxis.tsx` | `scale`, `width`, `chartHeight` | Renders Y-axis tick labels. Formats numbers with K/M/B/T abbreviations via `numberTickFormatter`. Auto-calculates tick count (40px min spacing). | +| `XAxisLabel` | `shared/XAxisLabel.tsx` | `label`, `tickVariant`, `width`, `multiLineClassName`, `singleLineClassName` | Individual X-axis label with auto-truncation detection. Shows `LabelTooltip` only when text overflows. | + +### Visual Components + +| Component | File | Key Props | Description | +| ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `Grid` | `shared/Grid.tsx` | `yScale`, `chartWidth`, `chartHeight` | Horizontal grid lines based on Y-axis ticks. 40px minimum spacing between lines. | +| `ClipDefs` | `shared/ClipDefs.tsx` | `chartId`, `chartWidth`, `chartHeight` | SVG `` to prevent chart content overflow. ID format: `clip-{chartId}`. Adds 6px top padding. | +| `LineDotCrosshair` | `shared/LineDotCrosshair.tsx` | `hoveredIndex`, `xScale`, `yScale`, `data`, `dataKeys`, `categoryKey`, `colors`, `chartHeight`, `getYValue` | Vertical crosshair line with colored dots at each series intersection. Used by Area and Line charts. | + +### Layout Components + +| Component | File | Key Props | Description | +| ----------------------- | --------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `ScrollableChartLayout` | `shared/ScrollableChartLayout.tsx`| `orch`, `yScale`, `mouseHandlers`, `series`, `xAxis`, `classPrefix`, `showYAxis`, ... | Shared layout for scrollable cartesian charts. Handles Y-axis, scrollable SVG, scroll buttons, legend, and tooltip. | +| `CondensedChartLayout` | `shared/CondensedChartLayout.tsx` | `orch`, `yScale`, `mouseHandlers`, `series`, `xAxis`, `classPrefix`, `showYAxis`, ... | Shared layout for condensed cartesian charts. Single non-scrollable SVG with angled labels. Same legend/tooltip pattern. | + +### Tooltip Components + +| Component | File | Key Props | Description | +| ---------------------- | --------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `ChartTooltip` | `shared/PortalTooltip/ChartTooltip.tsx` | `label`, `items: TooltipItem[]`, `viewportPosition` | Floating tooltip rendered via portal. Uses floating-ui for auto-positioning. Shows first 5 items if >10 with "Click to view all" text. | +| `LabelTooltipProvider` | `shared/LabelTooltip/LabelTooltip.tsx` | `children`, `delayDuration?` | Radix UI tooltip provider — wraps chart root. | +| `LabelTooltip` | `shared/LabelTooltip/LabelTooltip.tsx` | `children`, `content`, `side?`, `disabled?` | Tooltip wrapper for truncated labels. Returns child directly when disabled. | + +### Legend & Navigation + +| Component | File | Key Props | Description | +| ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DefaultLegend` | `shared/DefaultLegend/DefaultLegend.tsx` | `items: LegendItem[]`, `isExpanded`, `setIsExpanded`, `onItemClick?`, `hiddenSeries?`, `xAxisLabel?`, `yAxisLabel?` | Bottom legend with color indicators or custom icons. Supports expand/collapse ("Show N more"). Hidden series shown at 0.3 opacity. Uses `useDefaultLegend` hook for intelligent wrapping. | +| `ScrollButtonsHorizontal` | `shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx` | `dataWidth`, `effectiveWidth`, `canScrollLeft`, `canScrollRight`, `onScrollLeft`, `onScrollRight` | Left/right chevron buttons for horizontal scrolling. Hidden when data fits within container. | + +### Shared Hook + +| Hook | File | Signature | Description | +| ---------------- | -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------- | +| `useIsTruncated` | `shared/useIsTruncated.ts` | `(ref) => boolean` | Returns true if element overflows (width or height). Uses ResizeObserver for reactive updates. | + +## Utilities + +All utilities are in `utils/` and re-exported from `utils/index.ts`. + +| Module | File | Key Exports | Description | +| -------------- | ----------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `dataUtils` | `utils/dataUtils.ts` | `getDataKeys()`, `get2dChartConfig()`, `getLegendItems()`, `getColorForDataKey()` | Data key extraction, chart config building, legend item generation. | +| `paletteUtils` | `utils/paletteUtils.ts` | `useChartPalette()`, `getPalette()`, `getDistributedColors()`, `PaletteName` | 6 color palettes (ocean, orchid, emerald, spectrum, sunset, vivid) with 11 colors each. Distributes colors around midpoint for visual balance. | +| `mouseUtils` | `utils/mouseUtils.ts` | `findNearestDataIndex()`, `findBandIndex()` | Index lookup from mouse position — point scale (nearest) vs band scale (positional). | +| `scrollUtils` | `utils/scrollUtils.ts` | `getWidthOfData()`, `getWidthOfGroup()`, `getSnapPositions()`, `findNearestSnapPosition()` | Width calculation and snap-to-point scrolling. `ELEMENT_SPACING = 72px` per data group. | +| `styleUtils` | `utils/styleUtils.ts` | `numberTickFormatter()` | Formats numbers with K/M/B/T abbreviations for axis ticks. | +| `polarUtils` | `utils/polarUtils.ts` | `sortByValueDescending()`, `getSliceStyle()`, `formatPercentage()` | Polar chart helpers — value sorting, hover opacity styling, percentage formatting. | + +## Adding a New Chart + +### Cartesian Chart (e.g., `D3ScatterChart`) + +1. **Create the directory** following the existing pattern: + + ```text + D3ScatterChart/ + ├── D3ScatterChart.tsx + ├── types.ts + ├── index.ts + ├── parts/ + │ ├── ScatterSeries.tsx + │ └── Crosshair.tsx + └── stories/ + └── d3ScatterChart.stories.tsx + ``` + +2. **Define types** in `types.ts` — extend `BaseChartProps` from `types/common.ts` and add chart-specific props (e.g., `dotRadius`, `variant`). + +3. **Use `useChartScrollableOrchestrator`** (from `hooks/cartesian/`) as the primary hook — it provides data, dimensions, hover, scroll, legend, and tooltip state out of the box. For condensed variants, use `useChartCondensedOrchestrator`. + +4. **Pick the right scale hook** — `useXScale` (point) for continuous positioning, `useXBandScale` (band) for discrete categories. + +5. **Build series component** in `parts/` — receives scale, data, and style props. Render SVG elements. + +6. **Use `ScrollableChartLayout`** (or `CondensedChartLayout`) for the render pattern — handles Y-axis, scrollable SVG, scroll buttons, legend, and tooltip. + +7. **Export** from `D3ScatterChart/index.ts` and add to `ChartsV2/index.ts`. + +### Polar Chart (e.g., `D3RadarChart`) + +1. **Create the directory** following the pie/radial pattern. + +2. **Use `useCategoricalChartOrchestrator`** (from `hooks/polar/`) for single-series categorical data — it provides refs, slices, dimensions, hover, tooltip, and legend. For multi-series polar charts (e.g., radar), create a new orchestrator in `hooks/polar/` composing `useChartData` from `hooks/core/`. + +3. **Add chart-specific geometry** — the orchestrator handles shared concerns; you only implement the SVG rendering (arcs, bars, spokes, etc.). + +4. **Export** from the chart's `index.ts` and add to `ChartsV2/index.ts`. diff --git a/packages/react-ui/src/components/ChartsV2/chartsV2.scss b/packages/react-ui/src/components/ChartsV2/chartsV2.scss new file mode 100644 index 000000000..e31dd8fc8 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/chartsV2.scss @@ -0,0 +1,10 @@ +@forward "./D3AreaChart/d3AreaChart"; +@forward "./D3LineChart/d3LineChart"; +@forward "./D3BarChart/d3BarChart"; +@forward "./D3PieChart/d3PieChart"; +@forward "./D3RadialChart/d3RadialChart"; +@forward "./D3RadarChart/d3RadarChart"; +@forward "./D3ScatterChart/d3ScatterChart"; +@forward "./shared/core/PortalTooltip/portalTooltip"; +@forward "./shared/core/DefaultLegend/defaultLegend"; +@forward "./shared/cartesian/ScrollButtonsHorizontal/scrollButtonsHorizontal"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/index.ts new file mode 100644 index 000000000..4c4bbeeca --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/index.ts @@ -0,0 +1,13 @@ +export * from "./useAutoAngleCalculation"; +export * from "./useChartCondensedOrchestrator"; +export * from "./useChartDimensions"; +export * from "./useChartScroll"; +export * from "./useChartScrollableOrchestrator"; +export * from "./useMaxLabelWidth"; +export * from "./useStackedData"; +export * from "./useXAxisHeight"; +export * from "./useXBandScale"; +export * from "./useXScale"; +export * from "./useYAxisWidth"; +export * from "./useScatterChartOrchestrator"; +export * from "./useYScale"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useAutoAngleCalculation.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useAutoAngleCalculation.ts new file mode 100644 index 000000000..7812a0d2c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useAutoAngleCalculation.ts @@ -0,0 +1,67 @@ +import { useMemo } from "react"; + +const DEFAULT_X_AXIS_HEIGHT = 30; +const MIN_ROTATION_ANGLE = 0; +const X_AXIS_PADDING = 20; + +interface AngleCalculationResult { + angle: number; + height: number; +} + +/** + * Calculates the optimal rotation angle and height for X-axis labels using trigonometry. + * + * Uses the Pythagorean theorem: + * - Hypotenuse = maxLabelWidth (the label length) + * - Base = widthOfData or X_AXIS_PADDING (available horizontal space) + * - Height = sqrt(hypotenuse^2 - base^2) + * - Angle = atan(height / base) converted to degrees + */ +export const useAutoAngleCalculation = ( + maxLabelWidth: number, + enabled: boolean, + widthOfData?: number, +): AngleCalculationResult => { + return useMemo(() => { + if (!enabled) { + return { + angle: 0, + height: DEFAULT_X_AXIS_HEIGHT, + }; + } + + const base = widthOfData ?? X_AXIS_PADDING; + const hypotenuse = maxLabelWidth; + + // Labels fit horizontally — apply minimum rotation + if (base >= hypotenuse) { + const angleRadians = (MIN_ROTATION_ANGLE * Math.PI) / 180; + const height = Math.ceil(hypotenuse * Math.sin(angleRadians)); + + return { + angle: -MIN_ROTATION_ANGLE, + height: Math.max(height, DEFAULT_X_AXIS_HEIGHT), + }; + } + + // Calculate height using Pythagorean theorem + const heightSquared = hypotenuse * hypotenuse - base * base; + const height = Math.sqrt(Math.max(0, heightSquared)); + + const angleRadians = Math.atan(height / base); + const angleDegrees = (angleRadians * 180) / Math.PI; + + const finalAngle = Math.max(angleDegrees, MIN_ROTATION_ANGLE); + + const finalHeight = + finalAngle > angleDegrees + ? Math.ceil(hypotenuse * Math.sin((finalAngle * Math.PI) / 180)) + : Math.ceil(height); + + return { + angle: -finalAngle, + height: Math.max(finalHeight + 16, DEFAULT_X_AXIS_HEIGHT), + }; + }, [maxLabelWidth, enabled, widthOfData]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartCondensedOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartCondensedOrchestrator.ts new file mode 100644 index 000000000..1deff1545 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartCondensedOrchestrator.ts @@ -0,0 +1,141 @@ +import React, { useId, useMemo, useRef, useState } from "react"; + +import { buildContainerStyle } from "../../utils/buildContainerStyle"; +import { + ANGLED_LABEL_THRESHOLD, + CHART_MARGIN_TOP, + DEFAULT_CHART_HEIGHT, +} from "../../utils/constants"; +import { useChartData } from "../core/useChartData"; +import { useChartHover } from "../core/useChartHover"; +import { useContainerSize } from "../core/useContainerSize"; +import { useLegendHeight } from "../core/useLegendHeight"; +import { useTooltipPayload } from "../core/useTooltipPayload"; +import { useAutoAngleCalculation } from "./useAutoAngleCalculation"; +import { useMaxLabelWidth } from "./useMaxLabelWidth"; +import { useYAxisWidth } from "./useYAxisWidth"; + +import type { ChartData } from "../../types"; +import type { PaletteName } from "../../utils/paletteUtils"; + +export interface UseChartCondensedOrchestratorParams { + data: T; + categoryKey: keyof T[number]; + chartThemeName: PaletteName; + customPalette?: string[]; + showLegend: boolean; + showYAxis: boolean; + height?: number | string; + fixedWidth?: number | string; + fitLegendInHeight?: boolean; + chartIdPrefix: string; + icons?: Partial>; + onClick?: (row: T[number], index: number) => void; +} + +export function useChartCondensedOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + chartIdPrefix, + icons, + onClick, +}: UseChartCondensedOrchestratorParams) { + const containerRef = useRef(null); + const legendRef = useRef(null); + const chartId = `${chartIdPrefix}-condensed-${useId()}`; + + const chartData = useChartData({ data, categoryKey, chartThemeName, customPalette, icons }); + + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const legendHeight = useLegendHeight(legendRef, showLegend); + + const { width: containerWidth, height: containerHeight } = useContainerSize( + containerRef, + fixedWidth, + height, + ); + + const { yAxisWidth } = useYAxisWidth(data, chartData.dataKeys); + const effectiveYAxisWidth = showYAxis ? yAxisWidth : 0; + const chartAreaWidth = containerWidth - effectiveYAxisWidth; + const widthPerDataPoint = data.length > 0 ? chartAreaWidth / data.length : 0; + + const maxLabelWidth = useMaxLabelWidth(data, chartData.catKey); + const { angle, height: xAxisHeight } = useAutoAngleCalculation( + maxLabelWidth, + true, + maxLabelWidth < ANGLED_LABEL_THRESHOLD ? widthPerDataPoint : undefined, + ); + + const resolvedHeight = + typeof height === "number" + ? height + : containerHeight > 0 + ? containerHeight + : DEFAULT_CHART_HEIGHT; + const shouldFitLegend = fitLegendInHeight ?? !!height; + const svgAvailableHeight = shouldFitLegend ? resolvedHeight - legendHeight : resolvedHeight; + const chartInnerHeight = Math.max(0, svgAvailableHeight - CHART_MARGIN_TOP - xAxisHeight); + const totalSvgHeight = svgAvailableHeight; + const totalSvgWidth = effectiveYAxisWidth + chartAreaWidth; + + const hover = useChartHover({ data, onClick }); + + const tooltipPayload = useTooltipPayload( + hover.hoveredIndex, + data, + chartData.dataKeys, + chartData.catKey, + chartData.chartConfig, + ); + + const containerStyle = useMemo( + () => buildContainerStyle(chartData.chartStyle, fixedWidth, height), + [chartData.chartStyle, fixedWidth, height], + ); + + return { + refs: { containerRef, legendRef }, + identity: { chartId }, + data: { + catKey: chartData.catKey, + dataKeys: chartData.dataKeys, + colorMap: chartData.colorMap, + chartConfig: chartData.chartConfig, + chartStyle: chartData.chartStyle, + transformedKeys: chartData.transformedKeys, + }, + dimensions: { + effectiveYAxisWidth, + chartAreaWidth, + widthPerDataPoint, + containerWidth, + chartInnerHeight, + totalSvgHeight, + totalSvgWidth, + CHART_MARGIN_TOP, + }, + xAxis: { angle, xAxisHeight }, + hover: { + hoveredIndex: hover.hoveredIndex, + mousePos: hover.mousePos, + createMouseHandlers: hover.createMouseHandlers, + }, + legend: { + legendItems: chartData.legendItems, + hiddenSeries: chartData.hiddenSeries, + toggleSeries: chartData.toggleSeries, + isLegendExpanded, + setIsLegendExpanded, + }, + tooltip: { tooltipPayload }, + style: { containerStyle }, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartDimensions.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartDimensions.ts new file mode 100644 index 000000000..898ed560e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartDimensions.ts @@ -0,0 +1,108 @@ +import React, { useMemo } from "react"; + +import { + CHART_MARGIN_TOP, + DEFAULT_CHART_HEIGHT, + SINGLE_LINE_BREAKPOINT, +} from "../../utils/constants"; +import { getWidthOfData, getWidthOfGroup } from "../../utils/scrollUtils"; +import { useContainerSize } from "../core/useContainerSize"; +import { useLegendHeight } from "../core/useLegendHeight"; +import { useXAxisHeight } from "./useXAxisHeight"; +import { useYAxisWidth } from "./useYAxisWidth"; + +import type { ChartData } from "../../types"; +import type { ChartDensity } from "../../utils/scrollUtils"; + +export interface UseChartDimensionsParams { + containerRef: React.RefObject; + legendRef: React.RefObject; + data: T; + catKey: string; + dataKeys: string[]; + showYAxis: boolean; + showLegend: boolean; + height?: number | string; + fixedWidth?: number | string; + fitLegendInHeight?: boolean; + tickVariantProp: "singleLine" | "multiLine"; + condensed: boolean; + density?: ChartDensity; +} + +export function useChartDimensions({ + containerRef, + legendRef, + data, + catKey, + dataKeys, + showYAxis, + showLegend, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + condensed, + density, +}: UseChartDimensionsParams) { + const legendHeight = useLegendHeight(legendRef, showLegend); + + const { width: containerWidth, height: containerHeight } = useContainerSize( + containerRef, + fixedWidth, + height, + ); + + const { yAxisWidth } = useYAxisWidth(data, dataKeys); + const effectiveYAxisWidth = showYAxis ? yAxisWidth : 0; + const availableWidth = containerWidth - effectiveYAxisWidth; + + const widthOfGroup = condensed + ? availableWidth / Math.max(data.length, 1) + : getWidthOfGroup(density); + + const tickVariant = condensed + ? ("singleLine" as const) + : containerWidth < SINGLE_LINE_BREAKPOINT + ? ("singleLine" as const) + : tickVariantProp; + + const xAxisHeight = useXAxisHeight(data, catKey, tickVariant, widthOfGroup); + + const dataWidth = useMemo( + () => (condensed ? availableWidth : getWidthOfData(data, availableWidth, density)), + [condensed, data, availableWidth, density], + ); + const needsScroll = condensed ? false : dataWidth > availableWidth; + + const MIN_LABEL_WIDTH = 40; + const labelInterval = + condensed && widthOfGroup < MIN_LABEL_WIDTH ? Math.ceil(MIN_LABEL_WIDTH / widthOfGroup) : 1; + + const resolvedHeight = + typeof height === "number" + ? height + : containerHeight > 0 + ? containerHeight + : DEFAULT_CHART_HEIGHT; + const shouldFitLegend = fitLegendInHeight ?? !!height; + const svgAvailableHeight = shouldFitLegend ? resolvedHeight - legendHeight : resolvedHeight; + const chartInnerHeight = Math.max(0, svgAvailableHeight - CHART_MARGIN_TOP - xAxisHeight); + const totalHeight = svgAvailableHeight; + const svgWidth = needsScroll ? dataWidth : containerWidth - effectiveYAxisWidth; + + return { + containerWidth, + effectiveYAxisWidth, + tickVariant, + xAxisHeight, + chartInnerHeight, + totalHeight, + svgWidth, + dataWidth, + widthOfGroup, + needsScroll, + labelInterval, + MARGIN_TOP: CHART_MARGIN_TOP, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScroll.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScroll.ts new file mode 100644 index 000000000..abe430952 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScroll.ts @@ -0,0 +1,64 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { findNearestSnapPosition, getSnapPositions } from "../../utils/scrollUtils"; + +import type { ChartData } from "../../types"; +import type { ChartDensity } from "../../utils/scrollUtils"; + +export interface UseChartScrollParams { + mainContainerRef: React.RefObject; + data: T; + needsScroll: boolean; + density?: ChartDensity; +} + +export function useChartScroll({ + mainContainerRef, + data, + needsScroll, + density, +}: UseChartScrollParams) { + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(needsScroll); + + useEffect(() => { + if (!needsScroll) { + setCanScrollLeft(false); + setCanScrollRight(false); + return; + } + const el = mainContainerRef.current; + if (el) { + setCanScrollLeft(el.scrollLeft > 1); + setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1); + } else { + setCanScrollRight(true); + } + }, [needsScroll, mainContainerRef]); + + const handleScroll = useCallback(() => { + const el = mainContainerRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 1); + setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1); + }, [mainContainerRef]); + + const scrollTo = useCallback( + (direction: "left" | "right") => { + const el = mainContainerRef.current; + if (!el) return; + const snaps = getSnapPositions(data, density); + const idx = findNearestSnapPosition(snaps, el.scrollLeft, direction); + const target = snaps[idx] ?? 0; + el.scrollTo({ left: target, behavior: "smooth" }); + }, + [data, mainContainerRef, density], + ); + + return { + canScrollLeft, + canScrollRight, + handleScroll, + scrollTo, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScrollableOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScrollableOrchestrator.ts new file mode 100644 index 000000000..3524433aa --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScrollableOrchestrator.ts @@ -0,0 +1,156 @@ +import React, { useId, useMemo, useRef, useState } from "react"; + +import { buildContainerStyle } from "../../utils/buildContainerStyle"; +import { useChartData } from "../core/useChartData"; +import { useChartHover } from "../core/useChartHover"; +import { useTooltipPayload } from "../core/useTooltipPayload"; +import { useChartDimensions } from "./useChartDimensions"; +import { useChartScroll } from "./useChartScroll"; + +import type { ChartData } from "../../types"; +import type { PaletteName } from "../../utils/paletteUtils"; +import type { ChartDensity } from "../../utils/scrollUtils"; + +export interface UseChartScrollableOrchestratorParams { + data: T; + categoryKey: keyof T[number]; + chartThemeName: PaletteName; + customPalette?: string[]; + showLegend: boolean; + showYAxis: boolean; + height?: number | string; + fixedWidth?: number | string; + fitLegendInHeight?: boolean; + tickVariantProp: "singleLine" | "multiLine"; + chartIdPrefix: string; + icons?: Partial>; + onClick?: (row: T[number], index: number) => void; + condensed?: boolean; + density?: ChartDensity; +} + +export function useChartScrollableOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix, + icons, + onClick, + condensed = false, + density, +}: UseChartScrollableOrchestratorParams) { + const containerRef = useRef(null); + const mainContainerRef = useRef(null); + const legendRef = useRef(null); + const chartId = `${chartIdPrefix}-${useId()}`; + + // Data: keys, colors, hidden series, config + const chartData = useChartData({ + data, + categoryKey, + chartThemeName, + customPalette, + icons, + }); + + // Dimensions: sizing, layout, axis measurements + const dimensions = useChartDimensions({ + containerRef, + legendRef, + data, + catKey: chartData.catKey, + dataKeys: chartData.dataKeys, + showYAxis, + showLegend, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + condensed, + density, + }); + + // Hover: index, mouse position, handler factory + const hover = useChartHover({ data, onClick }); + + // Scroll: buttons, snap navigation + const scroll = useChartScroll({ + mainContainerRef, + data, + needsScroll: dimensions.needsScroll, + density, + }); + + // Legend expand/collapse + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + + // Tooltip + const tooltipPayload = useTooltipPayload( + hover.hoveredIndex, + data, + chartData.dataKeys, + chartData.catKey, + chartData.chartConfig, + ); + + // Container style + const containerStyle = useMemo( + () => buildContainerStyle(chartData.chartStyle, fixedWidth, height), + [chartData.chartStyle, fixedWidth, height], + ); + + return { + refs: { containerRef, mainContainerRef, legendRef }, + identity: { chartId }, + data: { + catKey: chartData.catKey, + allDataKeys: chartData.allDataKeys, + dataKeys: chartData.dataKeys, + colors: chartData.colors, + transformedKeys: chartData.transformedKeys, + chartConfig: chartData.chartConfig, + colorMap: chartData.colorMap, + }, + dimensions: { + containerWidth: dimensions.containerWidth, + effectiveYAxisWidth: dimensions.effectiveYAxisWidth, + tickVariant: dimensions.tickVariant, + xAxisHeight: dimensions.xAxisHeight, + chartInnerHeight: dimensions.chartInnerHeight, + totalHeight: dimensions.totalHeight, + svgWidth: dimensions.svgWidth, + dataWidth: dimensions.dataWidth, + widthOfGroup: dimensions.widthOfGroup, + needsScroll: dimensions.needsScroll, + labelInterval: dimensions.labelInterval, + MARGIN_TOP: dimensions.MARGIN_TOP, + }, + hover: { + hoveredIndex: hover.hoveredIndex, + mousePos: hover.mousePos, + createMouseHandlers: hover.createMouseHandlers, + }, + scroll: { + canScrollLeft: scroll.canScrollLeft, + canScrollRight: scroll.canScrollRight, + handleScroll: scroll.handleScroll, + scrollTo: scroll.scrollTo, + }, + legend: { + legendItems: chartData.legendItems, + hiddenSeries: chartData.hiddenSeries, + isLegendExpanded, + setIsLegendExpanded, + handleLegendItemClick: chartData.toggleSeries, + }, + tooltip: { tooltipPayload }, + style: { containerStyle, chartStyle: chartData.chartStyle }, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useMaxLabelWidth.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useMaxLabelWidth.ts new file mode 100644 index 000000000..580d6564d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useMaxLabelWidth.ts @@ -0,0 +1,34 @@ +import { useMemo } from "react"; + +import { useCanvasContextForLabelSize } from "../core/useCanvasContextForLabelSize"; + +/** + * Measures the pixel width of each category label and returns the maximum width found. + * Uses a canvas context to accurately measure text dimensions based on the theme font. + */ +export const useMaxLabelWidth = >( + data: T[], + categoryKey: keyof T, +): number => { + const context = useCanvasContextForLabelSize(); + + return useMemo(() => { + if (!data || data.length === 0) { + return 0; + } + + let maxWidth = 0; + + for (const item of data) { + const labelValue = String(item[categoryKey] ?? ""); + const metrics = context.measureText(labelValue); + const width = metrics.width; + + if (width > maxWidth) { + maxWidth = width; + } + } + + return maxWidth; + }, [data, categoryKey, context]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useScatterChartOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useScatterChartOrchestrator.ts new file mode 100644 index 000000000..504e8e6e8 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useScatterChartOrchestrator.ts @@ -0,0 +1,296 @@ +import { scaleLinear } from "d3-scale"; +import { pointer } from "d3-selection"; +import React, { useCallback, useMemo, useRef, useState } from "react"; + +import type { + D3ScatterChartData, + HoveredScatterPoint, + ScatterPoint, +} from "../../D3ScatterChart/types"; +import type { LegendItem } from "../../types"; +import { buildContainerStyle } from "../../utils/buildContainerStyle"; +import { CHART_MARGIN_TOP, DEFAULT_CHART_HEIGHT } from "../../utils/constants"; +import { useChartPalette, type PaletteName } from "../../utils/paletteUtils"; +import { measureYAxisWidth } from "../../utils/styleUtils"; +import { useCanvasContextForLabelSize } from "../core/useCanvasContextForLabelSize"; +import { useContainerSize } from "../core/useContainerSize"; +import { useLegendHeight } from "../core/useLegendHeight"; +import { usePrintContext } from "../core/usePrintContext"; +import { useSeriesVisibility } from "../core/useSeriesVisibility"; + +const SNAP_RADIUS = 30; +const X_AXIS_HEIGHT = 28; + +export interface VisibleDataset { + dataset: D3ScatterChartData[number]; + originalIndex: number; +} + +export interface UseScatterChartOrchestratorParams { + data: D3ScatterChartData; + chartThemeName: PaletteName; + customPalette?: string[]; + showLegend: boolean; + showYAxis: boolean; + height?: number | string; + width?: number | string; + fitLegendInHeight?: boolean; + xAxisLabel?: string; + yAxisLabel?: string; + onClick?: ( + point: ScatterPoint, + datasetName: string, + datasetIndex: number, + pointIndex: number, + ) => void; +} + +export function useScatterChartOrchestrator({ + data, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + width, + fitLegendInHeight, + xAxisLabel, + yAxisLabel, + onClick, +}: UseScatterChartOrchestratorParams) { + const containerRef = useRef(null); + const legendRef = useRef(null); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const [hoveredPoint, setHoveredPoint] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + + const isPrinting = usePrintContext(); + const { width: containerWidth, height: containerHeight } = useContainerSize( + containerRef, + width, + height, + ); + const legendHeight = useLegendHeight(legendRef, showLegend); + const context = useCanvasContextForLabelSize(); + + const shouldFitLegend = fitLegendInHeight ?? height !== undefined; + + // --- Colors (via shared useChartPalette — respects ThemeProvider overrides) --- + const datasetNames = useMemo(() => data.map((ds) => ds.name), [data]); + + const distributedColors = useChartPalette({ + chartThemeName, + customPalette, + themePaletteName: "defaultChartPalette", + dataLength: datasetNames.length, + }); + + const colorMap = useMemo(() => { + const map: Record = {}; + datasetNames.forEach((name, i) => { + map[name] = distributedColors[i] ?? "#000"; + }); + return map; + }, [datasetNames, distributedColors]); + + // --- Hidden series (via shared useSeriesVisibility) --- + const { hiddenSeries, toggleSeries } = useSeriesVisibility(datasetNames); + + const visibleDatasets: VisibleDataset[] = useMemo( + () => + data + .map((dataset, originalIndex) => ({ dataset, originalIndex })) + .filter(({ dataset }) => !hiddenSeries.has(dataset.name)), + [data, hiddenSeries], + ); + + // --- Scales (domain from ALL datasets for stability) --- + const { xMin, xMax, yMin, yMax } = useMemo(() => { + let xMin = Infinity; + let xMax = -Infinity; + let yMin = Infinity; + let yMax = -Infinity; + for (const ds of data) { + for (const pt of ds.data) { + if (pt.x < xMin) xMin = pt.x; + if (pt.x > xMax) xMax = pt.x; + if (pt.y < yMin) yMin = pt.y; + if (pt.y > yMax) yMax = pt.y; + } + } + if (!isFinite(xMin)) { + xMin = 0; + xMax = 100; + yMin = 0; + yMax = 100; + } + return { xMin, xMax, yMin, yMax }; + }, [data]); + + // --- Y-axis width (via shared measureYAxisWidth utility) --- + const yAxisWidth = useMemo(() => { + if (!showYAxis) return 0; + const tempScale = scaleLinear().domain([yMin, yMax]).nice(); + const ticks = tempScale.ticks(); + return measureYAxisWidth(ticks, context); + }, [showYAxis, yMin, yMax, context]); + + // --- Dimensions --- + const effectiveHeight = containerHeight || (typeof height === "number" ? height : 0); + const totalHeight = effectiveHeight || DEFAULT_CHART_HEIGHT; + const legendDeduction = showLegend && shouldFitLegend ? legendHeight : 0; + const chartInnerHeight = totalHeight - CHART_MARGIN_TOP - X_AXIS_HEIGHT - legendDeduction; + const chartAreaWidth = Math.max(0, (containerWidth || 0) - yAxisWidth); + + const totalSvgWidth = containerWidth || 0; + const totalSvgHeight = CHART_MARGIN_TOP + Math.max(0, chartInnerHeight) + X_AXIS_HEIGHT; + + // --- Scales --- + const xScale = useMemo( + () => scaleLinear().domain([xMin, xMax]).range([0, chartAreaWidth]).nice(), + [xMin, xMax, chartAreaWidth], + ); + + const yScale = useMemo( + () => scaleLinear().domain([yMin, yMax]).range([chartInnerHeight, 0]).nice(), + [yMin, yMax, chartInnerHeight], + ); + + // --- Hover (2D nearest-point) --- + const findNearestPoint = useCallback( + (clientX: number, clientY: number, currentTarget: SVGGElement) => { + const [mx, my] = pointer({ clientX, clientY }, currentTarget); + let minDist = Infinity; + let closest: HoveredScatterPoint | null = null; + + for (const { dataset: ds, originalIndex } of visibleDatasets) { + for (let ptIdx = 0; ptIdx < ds.data.length; ptIdx++) { + const pt = ds.data[ptIdx]!; + const px = xScale(pt.x); + const py = yScale(pt.y); + const dist = Math.sqrt((mx - px) ** 2 + (my - py) ** 2); + if (dist < minDist) { + minDist = dist; + closest = { + datasetIndex: originalIndex, + pointIndex: ptIdx, + point: pt, + datasetName: ds.name, + }; + } + } + } + + if (closest && minDist <= SNAP_RADIUS) { + setHoveredPoint(closest); + } else { + setHoveredPoint(null); + } + + setMousePos({ x: clientX, y: clientY }); + }, + [visibleDatasets, xScale, yScale], + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + findNearestPoint(event.clientX, event.clientY, event.currentTarget); + }, + [findNearestPoint], + ); + + const handleMouseLeave = useCallback(() => { + setHoveredPoint(null); + setMousePos(null); + }, []); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + findNearestPoint(touch.clientX, touch.clientY, event.currentTarget); + }, + [findNearestPoint], + ); + + const handleTouchEnd = useCallback(() => { + setHoveredPoint(null); + setMousePos(null); + }, []); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (!onClick || !hoveredPoint) return; + event.stopPropagation(); + onClick( + hoveredPoint.point, + hoveredPoint.datasetName, + hoveredPoint.datasetIndex, + hoveredPoint.pointIndex, + ); + }, + [onClick, hoveredPoint], + ); + + // --- Tooltip (uses xAxisLabel/yAxisLabel for item names) --- + const tooltipPayload = useMemo(() => { + if (!hoveredPoint) return null; + const color = colorMap[hoveredPoint.datasetName] ?? "#000"; + return { + label: hoveredPoint.datasetName, + items: [ + { name: xAxisLabel ?? "X", value: hoveredPoint.point.x, color }, + { name: yAxisLabel ?? "Y", value: hoveredPoint.point.y, color }, + ], + }; + }, [hoveredPoint, colorMap, xAxisLabel, yAxisLabel]); + + // --- Legend --- + const legendItems: LegendItem[] = useMemo( + () => + datasetNames.map((name) => ({ + key: name, + label: name, + color: colorMap[name] ?? "#000", + })), + [datasetNames, colorMap], + ); + + // --- Container style --- + const containerStyle = useMemo(() => buildContainerStyle({}, width, height), [width, height]); + + return { + refs: { containerRef, legendRef }, + isPrinting, + data: { visibleDatasets, datasetNames, colorMap }, + dimensions: { + containerWidth, + effectiveYAxisWidth: yAxisWidth, + chartAreaWidth, + chartInnerHeight: Math.max(0, chartInnerHeight), + totalSvgWidth, + totalSvgHeight, + xAxisHeight: X_AXIS_HEIGHT, + CHART_MARGIN_TOP, + }, + scales: { xScale, yScale }, + hover: { + hoveredPoint, + mousePos, + handleMouseMove, + handleMouseLeave, + handleTouchMove, + handleTouchEnd, + handleClick, + }, + legend: { + legendItems, + hiddenSeries, + toggleSeries, + isLegendExpanded, + setIsLegendExpanded, + }, + tooltip: { tooltipPayload }, + style: { containerStyle }, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useStackedData.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useStackedData.ts new file mode 100644 index 000000000..273093ab4 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useStackedData.ts @@ -0,0 +1,23 @@ +import type { Series } from "d3-shape"; +import { stack, stackOffsetNone, stackOrderNone } from "d3-shape"; +import { useMemo } from "react"; +import type { ChartData } from "../../types"; + +export type StackedData = Series, string>[]; + +export const useStackedData = ( + data: ChartData, + dataKeys: string[], + stacked: boolean, +): StackedData | null => { + return useMemo(() => { + if (!stacked || dataKeys.length === 0) return null; + + const stackGenerator = stack>() + .keys(dataKeys) + .order(stackOrderNone) + .offset(stackOffsetNone); + + return stackGenerator(data as Iterable<{ [key: string]: number }>); + }, [data, dataKeys, stacked]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts new file mode 100644 index 000000000..a415492ab --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import { XAxisTickVariant } from "../../types"; +import { useCanvasContextForLabelSize } from "../core/useCanvasContextForLabelSize"; + +const MIN_HEIGHT = 30; +const X_AXIS_LABEL_PADDING = 13; +// Must match -webkit-line-clamp value in chartBase.scss (.openui-d3-*-x-tick-multi-line) +const MAX_LABEL_LINES = 3; + +/** + * Parses line-height from a CSS font shorthand string. + * Handles both px values ("400 12px/15px Inter") and unitless multipliers ("400 12px/1.25 Inter"). + */ +function parseLineHeight(fontShorthand: string): number { + const sizeMatch = fontShorthand.match(/(\d+(?:\.\d+)?)px/); + const fontSize = sizeMatch ? parseFloat(sizeMatch[1]!) : 12; + + const lhMatch = fontShorthand.match(/\/(\d+(?:\.\d+)?)(px)?/); + if (!lhMatch) return Math.ceil(fontSize * 1.2); // browser default + + const lhValue = parseFloat(lhMatch[1]!); + const hasPxUnit = !!lhMatch[2]; + return hasPxUnit ? lhValue : Math.ceil(fontSize * lhValue); +} + +/** + * Simulates CSS word-wrap to count how many lines a label will occupy. + * Uses canvas measureText for per-character width measurement. + * Matches CSS `word-break: break-word` behavior: words exceeding maxWidth break mid-word. + * + * Note: canvas measureText does not account for letter-spacing from the theme. + * This is a pre-existing limitation shared with useYAxisWidth and useMaxLabelWidth. + */ +function countWrappedLines( + context: CanvasRenderingContext2D, + text: string, + maxWidth: number, +): number { + if (maxWidth <= 0) return 1; + + const words = text.split(/\s+/); + let lines = 1; + let currentLineWidth = 0; + const spaceWidth = context.measureText(" ").width; + + for (const word of words) { + const wordWidth = context.measureText(word).width; + + if (wordWidth > maxWidth) { + // Word exceeds line — break character by character (matches CSS break-word) + for (const char of word) { + const charWidth = context.measureText(char).width; + if (currentLineWidth + charWidth > maxWidth && currentLineWidth > 0) { + lines++; + currentLineWidth = charWidth; + } else { + currentLineWidth += charWidth; + } + } + } else if (currentLineWidth + wordWidth > maxWidth) { + // Word doesn't fit on current line — wrap + lines++; + currentLineWidth = wordWidth; + } else { + // Add space between words on same line + const gap = currentLineWidth > 0 ? spaceWidth : 0; + currentLineWidth += gap + wordWidth; + } + } + + return lines; +} + +export const useXAxisHeight = ( + data: Record[], + categoryKey: string, + tickVariant: XAxisTickVariant, + widthOfGroup = 70, +) => { + const context = useCanvasContextForLabelSize(); + + return useMemo(() => { + const lineHeight = parseLineHeight(context.font); + // Single-line: 1 line of text + padding, floored at MIN_HEIGHT. + // This is font-aware — if the theme uses a large font, the height adapts. + const singleLineHeight = Math.max(lineHeight + X_AXIS_LABEL_PADDING, MIN_HEIGHT); + + if (tickVariant !== "multiLine") return singleLineHeight; + if (!data || data.length === 0) return singleLineHeight; + if (widthOfGroup <= 0) return singleLineHeight; + + let maxLines = 1; + for (const item of data) { + const label = String(item[categoryKey]); + const lines = countWrappedLines(context, label, widthOfGroup); + maxLines = Math.max(maxLines, Math.min(lines, MAX_LABEL_LINES)); + } + + const labelHeight = maxLines * lineHeight; + return Math.max(labelHeight + X_AXIS_LABEL_PADDING, MIN_HEIGHT); + }, [data, categoryKey, tickVariant, widthOfGroup, context]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXBandScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXBandScale.ts new file mode 100644 index 000000000..0bef4f4b5 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXBandScale.ts @@ -0,0 +1,20 @@ +import type { ScaleBand } from "d3-scale"; +import { scaleBand } from "d3-scale"; +import { useMemo } from "react"; +import type { ChartData } from "../../types"; + +export const useXBandScale = ( + data: ChartData, + categoryKey: string, + svgWidth: number, + paddingInner?: number, + paddingOuter?: number, +): ScaleBand => { + return useMemo(() => { + return scaleBand() + .domain(data.map((d) => String(d[categoryKey]))) + .range([0, svgWidth]) + .paddingInner(paddingInner ?? 0.2) + .paddingOuter(paddingOuter ?? 0.1); + }, [data, categoryKey, svgWidth, paddingInner, paddingOuter]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXScale.ts new file mode 100644 index 000000000..2dbc47f8a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXScale.ts @@ -0,0 +1,18 @@ +import type { ScalePoint } from "d3-scale"; +import { scalePoint } from "d3-scale"; +import { useMemo } from "react"; +import type { ChartData } from "../../types"; + +export const useXScale = ( + data: ChartData, + categoryKey: string, + svgWidth: number, + widthOfGroup: number, +): ScalePoint => { + return useMemo(() => { + return scalePoint() + .domain(data.map((d) => String(d[categoryKey]))) + .range([widthOfGroup / 2, svgWidth - widthOfGroup / 2]) + .padding(0); + }, [data, categoryKey, svgWidth, widthOfGroup]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYAxisWidth.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYAxisWidth.ts new file mode 100644 index 000000000..7f4e54227 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYAxisWidth.ts @@ -0,0 +1,55 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { numberTickFormatter } from "../../utils/styleUtils"; +import { useCanvasContextForLabelSize } from "../core/useCanvasContextForLabelSize"; + +const DEFAULT_Y_AXIS_WIDTH = 40; +const MIN_Y_AXIS_WIDTH = 20; +const MAX_Y_AXIS_WIDTH = 200; +const LABEL_PADDING = 10; + +export const useYAxisWidth = (data: Array>, dataKeys: string[]) => { + const context = useCanvasContextForLabelSize(); + const [maxLabelWidthReceived, setMaxLabelWidthReceived] = useState(0); + + const maxLabelWidth = useMemo(() => { + if (typeof window === "undefined" || !data || data.length === 0 || !dataKeys.length) { + return DEFAULT_Y_AXIS_WIDTH; + } + + if (!context) { + return DEFAULT_Y_AXIS_WIDTH; + } + + let maxWidth = 0; + + dataKeys.forEach((key) => { + const values = [ + ...new Set(data.map((item) => item[key]).filter((v) => v != null && typeof v === "number")), + ]; + + values.forEach((value) => { + const displayValue = numberTickFormatter(value as number); + const textWidth = context.measureText(displayValue).width; + + maxWidth = Math.max(maxWidth, textWidth); + }); + }); + + const totalWidth = Math.ceil(maxWidth) + LABEL_PADDING; + + return Math.max(MIN_Y_AXIS_WIDTH, Math.min(MAX_Y_AXIS_WIDTH, totalWidth)); + }, [data, dataKeys, context]); + + const maxLabelWidthRef = useRef(maxLabelWidth); + maxLabelWidthRef.current = maxLabelWidthReceived || maxLabelWidth; + + const setLabelWidth = useCallback( + (displayValue: string) => { + const textWidth = context.measureText(displayValue).width + LABEL_PADDING; + setMaxLabelWidthReceived((currentWidth) => Math.max(currentWidth, textWidth)); + }, + [context], + ); + + return { yAxisWidth: maxLabelWidthRef.current, setLabelWidth }; +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYScale.ts new file mode 100644 index 000000000..6b87342da --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYScale.ts @@ -0,0 +1,33 @@ +import type { ScaleLinear } from "d3-scale"; +import { scaleLinear } from "d3-scale"; +import { useMemo } from "react"; +import type { ChartData } from "../../types"; +import type { StackedData } from "./useStackedData"; + +export const useYScale = ( + data: ChartData, + dataKeys: string[], + chartInnerHeight: number, + stackedData: StackedData | null, +): ScaleLinear => { + return useMemo(() => { + let maxVal = 0; + + if (stackedData) { + stackedData.forEach((series) => { + series.forEach((point) => { + maxVal = Math.max(maxVal, point[1]); + }); + }); + } else { + data.forEach((row) => { + dataKeys.forEach((key) => { + const val = Number(row[key]) || 0; + maxVal = Math.max(maxVal, val); + }); + }); + } + + return scaleLinear().domain([0, maxVal]).range([chartInnerHeight, 0]).nice(); + }, [data, dataKeys, stackedData, chartInnerHeight]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/index.ts new file mode 100644 index 000000000..65a3c665d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/index.ts @@ -0,0 +1,10 @@ +export * from "./useCanvasContextForLabelSize"; +export * from "./useCategoricalChartData"; +export * from "./useChartData"; +export * from "./useChartHover"; +export * from "./useContainerSize"; +export * from "./useLegendHeight"; +export * from "./usePrintContext"; +export * from "./useSeriesVisibility"; +export * from "./useTooltipPayload"; +export * from "./useTransformedKeys"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useCanvasContextForLabelSize.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useCanvasContextForLabelSize.ts new file mode 100644 index 000000000..47ad57a76 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useCanvasContextForLabelSize.ts @@ -0,0 +1,23 @@ +import { useMemo } from "react"; +import { useTheme } from "../../../ThemeProvider"; + +export const useCanvasContextForLabelSize = () => { + const { theme: userTheme } = useTheme(); + + return useMemo(() => { + const font = userTheme.textLabelXs ?? "400 12px/1.25 Inter"; + + if (typeof document === "undefined") { + // SSR stub: returns zero-width measurements but preserves font string + // so parseLineHeight can derive correct line-height even on the server. + // Note: only measureText and font are implemented — other CanvasRenderingContext2D + // methods are not available on this stub. + return { measureText: () => ({ width: 0 }), font } as unknown as CanvasRenderingContext2D; + } + + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + context.font = font; + return context; + }, [userTheme.textLabelXs]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useCategoricalChartData.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useCategoricalChartData.ts new file mode 100644 index 000000000..3ff780796 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useCategoricalChartData.ts @@ -0,0 +1,118 @@ +import { useCallback, useMemo, useState } from "react"; + +import type { ChartData, LegendItem } from "../../types"; +import type { PaletteName } from "../../utils/paletteUtils"; +import { useChartPalette } from "../../utils/paletteUtils"; +import { sortByValueDescending } from "../../utils/polarUtils"; + +export interface CategoricalSlice { + label: string; + value: number; + color: string; + percentage: number; +} + +export interface UseCategoricalChartDataParams { + data: T; + categoryKey: keyof T[number]; + dataKey: keyof T[number]; + chartThemeName: PaletteName; + customPalette?: string[]; + format?: "number" | "percentage"; +} + +export function useCategoricalChartData({ + data, + categoryKey, + dataKey, + chartThemeName, + customPalette, + format = "number", +}: UseCategoricalChartDataParams) { + const catKey = String(categoryKey); + const valKey = String(dataKey); + + const sortedData = useMemo(() => sortByValueDescending(data, valKey), [data, valKey]); + + const [hiddenSlices, setHiddenSlices] = useState>(new Set()); + + const colors = useChartPalette({ + chartThemeName, + customPalette, + themePaletteName: "defaultChartPalette", + dataLength: sortedData.length, + }); + + const total = useMemo( + () => + sortedData.reduce((sum, row) => { + if (hiddenSlices.has(String(row[catKey]))) return sum; + return sum + (Number(row[valKey]) || 0); + }, 0), + [sortedData, catKey, valKey, hiddenSlices], + ); + + const slices: CategoricalSlice[] = useMemo( + () => + sortedData.map((row, i) => { + const value = Number(row[valKey]) || 0; + return { + label: String(row[catKey]), + value, + color: colors[i] ?? "#000", + percentage: total > 0 ? (value / total) * 100 : 0, + }; + }), + [sortedData, catKey, valKey, colors, total], + ); + + const toggleSlice = useCallback( + (key: string) => { + setHiddenSlices((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + if (next.size < sortedData.length - 1) { + next.add(key); + } + } + return next; + }); + }, + [sortedData.length], + ); + + const legendItems: LegendItem[] = useMemo( + () => + slices.map((s) => ({ + key: s.label, + label: s.label, + color: s.color, + percentage: format === "percentage" ? s.percentage : undefined, + })), + [slices, format], + ); + + const chartStyle = useMemo(() => { + return slices.reduce( + (styles, s, i) => ({ + ...styles, + [`--slice-color-${i}`]: s.color, + }), + {} as Record, + ); + }, [slices]); + + return { + catKey, + valKey, + sortedData, + slices, + total, + hiddenSlices, + toggleSlice, + legendItems, + chartStyle, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts new file mode 100644 index 000000000..bea533aae --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts @@ -0,0 +1,91 @@ +import React, { useMemo } from "react"; + +import { get2dChartConfig, getDataKeys, getLegendItems } from "../../utils/dataUtils"; +import { useChartPalette } from "../../utils/paletteUtils"; +import { useSeriesVisibility } from "./useSeriesVisibility"; +import { useTransformedKeys } from "./useTransformedKeys"; + +import type { ChartData } from "../../types"; +import type { PaletteName } from "../../utils/paletteUtils"; + +export interface UseChartDataParams { + data: T; + categoryKey: keyof T[number]; + chartThemeName: PaletteName; + customPalette?: string[]; + icons?: Partial>; +} + +export function useChartData({ + data, + categoryKey, + chartThemeName, + customPalette, + icons, +}: UseChartDataParams) { + const catKey = String(categoryKey); + const allDataKeys = useMemo(() => getDataKeys(data, catKey), [data, catKey]); + + const { hiddenSeries, toggleSeries } = useSeriesVisibility(allDataKeys); + const dataKeys = useMemo( + () => allDataKeys.filter((k) => !hiddenSeries.has(k)), + [allDataKeys, hiddenSeries], + ); + + const colors = useChartPalette({ + chartThemeName, + customPalette, + themePaletteName: "defaultChartPalette", + dataLength: allDataKeys.length, + }); + + const transformedKeys = useTransformedKeys(allDataKeys); + + const chartConfig = useMemo( + () => get2dChartConfig(allDataKeys, colors, transformedKeys, undefined, icons), + [allDataKeys, colors, transformedKeys, icons], + ); + + const colorMap = useMemo(() => { + return allDataKeys.reduce( + (map, key) => { + map[key] = chartConfig[key]?.color ?? "#000"; + return map; + }, + {} as Record, + ); + }, [allDataKeys, chartConfig]); + + const chartStyle = useMemo(() => { + return allDataKeys.reduce( + (styles, key) => { + const transformedKey = transformedKeys[key]; + const color = chartConfig[key]?.color; + return { + ...styles, + [`--color-${transformedKey}`]: color, + }; + }, + {} as Record, + ); + }, [allDataKeys, transformedKeys, chartConfig]); + + const legendItems = useMemo( + () => getLegendItems(allDataKeys, colors, icons), + [allDataKeys, colors, icons], + ); + + return { + catKey, + allDataKeys, + dataKeys, + hiddenSeries, + toggleSeries, + colors, + transformedKeys, + chartConfig, + colorMap, + chartStyle, + legendItems, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useChartHover.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartHover.ts new file mode 100644 index 000000000..091c1c02e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartHover.ts @@ -0,0 +1,62 @@ +import { pointer } from "d3-selection"; +import React, { useCallback, useState } from "react"; + +import type { ChartData } from "../../types"; + +export interface UseChartHoverParams { + data: T; + onClick?: (row: T[number], index: number) => void; +} + +export function useChartHover({ data, onClick }: UseChartHoverParams) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + + const createMouseHandlers = useCallback( + (findIndex: (mouseX: number) => number) => { + const handleMouseMove = (event: React.MouseEvent) => { + const [mouseX] = pointer(event.nativeEvent, event.currentTarget); + setHoveredIndex(findIndex(mouseX)); + setMousePos({ x: event.clientX, y: event.clientY }); + }; + + const handleMouseLeave = () => { + setHoveredIndex(null); + setMousePos(null); + }; + + const handleTouchMove = (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const svgRect = event.currentTarget.getBoundingClientRect(); + const mouseX = touch.clientX - svgRect.left; + setHoveredIndex(findIndex(mouseX)); + setMousePos({ x: touch.clientX, y: touch.clientY }); + }; + + const handleTouchEnd = () => { + setHoveredIndex(null); + setMousePos(null); + }; + + const handleClick = onClick + ? (event: React.MouseEvent) => { + const [mouseX] = pointer(event.nativeEvent, event.currentTarget); + const idx = findIndex(mouseX); + if (idx >= 0 && idx < data.length) { + onClick(data[idx]!, idx); + } + } + : undefined; + + return { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick }; + }, + [onClick, data], + ); + + return { + hoveredIndex, + mousePos, + createMouseHandlers, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useContainerSize.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useContainerSize.ts new file mode 100644 index 000000000..eebea2207 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useContainerSize.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +export function useContainerSize( + ref: React.RefObject, + fixedWidth?: number | string, + fixedHeight?: number | string, +): { width: number; height: number } { + const [size, setSize] = useState({ width: 0, height: 0 }); + + const numericWidth = typeof fixedWidth === "number" ? fixedWidth : undefined; + const numericHeight = typeof fixedHeight === "number" ? fixedHeight : undefined; + + useEffect(() => { + if ((numericWidth && numericHeight) || !ref.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + + observer.observe(ref.current); + const rect = ref.current.getBoundingClientRect(); + setSize({ width: rect.width, height: rect.height }); + + return () => observer.disconnect(); + }, [numericWidth, numericHeight, ref]); + + return { + width: numericWidth ?? size.width, + height: numericHeight ?? (typeof fixedHeight === "string" ? size.height : 0), + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useLegendHeight.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useLegendHeight.ts new file mode 100644 index 000000000..1011b599a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useLegendHeight.ts @@ -0,0 +1,26 @@ +import React, { useEffect, useState } from "react"; + +export function useLegendHeight( + legendRef: React.RefObject, + showLegend: boolean, +): number { + const [legendHeight, setLegendHeight] = useState(0); + + useEffect(() => { + const el = legendRef.current; + if (!el) { + setLegendHeight(0); + return; + } + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setLegendHeight(entry.contentRect.height); + } + }); + observer.observe(el); + setLegendHeight(el.getBoundingClientRect().height); + return () => observer.disconnect(); + }, [showLegend, legendRef]); + + return legendHeight; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/usePrintContext.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/usePrintContext.ts new file mode 100644 index 000000000..40915abad --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/usePrintContext.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; + +/** + * Detects when the browser is in print mode (Ctrl+P / Cmd+P). + * Useful for disabling animations and ensuring the chart renders + * its final state for print output. + */ +export const usePrintContext = (): boolean => { + const [isPrinting, setIsPrinting] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + + const mediaQuery = window.matchMedia("print"); + + const handleChange = (e: MediaQueryListEvent) => { + setIsPrinting(e.matches); + }; + + setIsPrinting(mediaQuery.matches); + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, []); + + return isPrinting; +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useSeriesVisibility.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useSeriesVisibility.ts new file mode 100644 index 000000000..46782563c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useSeriesVisibility.ts @@ -0,0 +1,23 @@ +import { useCallback, useState } from "react"; + +export function useSeriesVisibility(seriesKeys: string[]) { + const [hiddenSeries, setHiddenSeries] = useState>(new Set()); + + const toggleSeries = useCallback( + (key: string) => { + setHiddenSeries((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + if (next.size >= seriesKeys.length - 1) return prev; + next.add(key); + } + return next; + }); + }, + [seriesKeys.length], + ); + + return { hiddenSeries, toggleSeries }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useTooltipPayload.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useTooltipPayload.ts new file mode 100644 index 000000000..ca7ba8037 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useTooltipPayload.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react"; +import type { TooltipItem } from "../../shared/core/PortalTooltip/ChartTooltip"; +import type { ChartData } from "../../types"; + +export interface TooltipPayload { + label: string; + items: TooltipItem[]; +} + +export function useTooltipPayload( + hoveredIndex: number | null, + data: T, + dataKeys: string[], + catKey: string, + chartConfig: Record, +): TooltipPayload | null { + return useMemo(() => { + if (hoveredIndex === null || hoveredIndex >= data.length) return null; + const row = data[hoveredIndex]!; + return { + label: String(row[catKey]), + items: dataKeys.map((key) => ({ + name: key, + value: Number(row[key]) || 0, + color: chartConfig[key]?.color ?? "#000", + })), + }; + }, [hoveredIndex, data, dataKeys, catKey, chartConfig]); +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useTransformedKeys.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useTransformedKeys.ts new file mode 100644 index 000000000..b124a8cb4 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useTransformedKeys.ts @@ -0,0 +1,19 @@ +import { useMemo, useRef } from "react"; + +export const useTransformedKeys = (keys: string[]) => { + const nextIdRef = useRef(0); + const cacheRef = useRef>({}); + + return useMemo(() => { + return keys.reduce( + (acc, key) => { + if (!cacheRef.current[key]) { + cacheRef.current[key] = `tk-${nextIdRef.current++}`; + } + acc[key] = cacheRef.current[key]; + return acc; + }, + {} as Record, + ); + }, [keys]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/index.ts new file mode 100644 index 000000000..bd686f96d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./cartesian"; +export * from "./core"; +export * from "./polar"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts new file mode 100644 index 000000000..d3ffe0671 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts @@ -0,0 +1,3 @@ +export * from "./useCategoricalChartOrchestrator"; +export * from "./useRadarChartOrchestrator"; +export * from "./useRadarHover"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/polar/useCategoricalChartOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/polar/useCategoricalChartOrchestrator.ts new file mode 100644 index 000000000..50049b1bf --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/polar/useCategoricalChartOrchestrator.ts @@ -0,0 +1,156 @@ +import React, { useCallback, useMemo, useRef, useState } from "react"; + +import type { TooltipItem } from "../../shared/core/PortalTooltip/ChartTooltip"; +import type { ChartData } from "../../types"; +import { buildContainerStyle } from "../../utils/buildContainerStyle"; +import type { PaletteName } from "../../utils/paletteUtils"; +import { formatPercentage } from "../../utils/polarUtils"; +import { useCategoricalChartData } from "../core/useCategoricalChartData"; +import { useContainerSize } from "../core/useContainerSize"; +import { useLegendHeight } from "../core/useLegendHeight"; +import { usePrintContext } from "../core/usePrintContext"; + +export interface UseCategoricalChartOrchestratorParams { + data: T; + categoryKey: keyof T[number]; + dataKey: keyof T[number]; + chartThemeName: PaletteName; + customPalette?: string[]; + format?: "number" | "percentage"; + showLegend: boolean; + isSemiCircular: boolean; + maxChartSize: number; + minChartSize: number; + height?: number | string; + width?: number | string; + fitLegendInHeight?: boolean; +} + +export function useCategoricalChartOrchestrator({ + data, + categoryKey, + dataKey, + chartThemeName, + customPalette, + format = "number", + showLegend, + isSemiCircular, + maxChartSize, + minChartSize, + height, + width, + fitLegendInHeight, +}: UseCategoricalChartOrchestratorParams) { + const containerRef = useRef(null); + const legendRef = useRef(null); + const [legendExpanded, setLegendExpanded] = useState(false); + + const isPrinting = usePrintContext(); + const { width: containerWidth, height: containerHeight } = useContainerSize( + containerRef, + width, + height, + ); + const legendHeight = useLegendHeight(legendRef, showLegend); + + const shouldFitLegend = fitLegendInHeight ?? height !== undefined; + + const { slices, total, hiddenSlices, toggleSlice, legendItems, chartStyle, sortedData } = + useCategoricalChartData({ + data, + categoryKey, + dataKey, + chartThemeName, + customPalette, + format, + }); + + // Dimensions + const legendDeduction = showLegend && shouldFitLegend ? legendHeight : 0; + const availableHeight = (containerHeight || 300) - legendDeduction; + const availableWidth = containerWidth || 300; + + const chartSize = Math.max( + minChartSize, + Math.min(maxChartSize, availableWidth, isSemiCircular ? availableHeight * 2 : availableHeight), + ); + + const svgWidth = chartSize; + const svgHeight = isSemiCircular ? chartSize / 2 + 10 : chartSize; + const centerX = svgWidth / 2; + const centerY = isSemiCircular ? svgHeight - 10 : svgHeight / 2; + + // Visible slices + const visibleSlices = useMemo( + () => slices.filter((s) => !hiddenSlices.has(s.label)), + [slices, hiddenSlices], + ); + + // Hover + const [hoveredIndex, setHoveredIndex] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + + const handleMouseMove = useCallback((event: React.MouseEvent, index: number) => { + setHoveredIndex(index); + setMousePos({ x: event.clientX, y: event.clientY }); + }, []); + + const handleMouseLeave = useCallback(() => { + setHoveredIndex(null); + setMousePos(null); + }, []); + + // Tooltip + const tooltipPayload = useMemo(() => { + if (hoveredIndex === null) return null; + const slice = visibleSlices[hoveredIndex]; + if (!slice) return null; + + const items: TooltipItem[] = [ + { + name: slice.label, + value: + format === "percentage" ? parseFloat(formatPercentage(slice.value, total)) : slice.value, + color: slice.color, + }, + ]; + + return { label: slice.label, items }; + }, [hoveredIndex, visibleSlices, format, total]); + + // Container style + const containerStyle = useMemo( + () => buildContainerStyle(chartStyle, width, height), + [chartStyle, width, height], + ); + + return { + refs: { containerRef, legendRef }, + data: { + slices, + visibleSlices, + total, + hiddenSlices, + toggleSlice, + sortedData, + catKey: String(categoryKey), + valKey: String(dataKey), + legendItems, + }, + dimensions: { + containerWidth, + availableWidth, + availableHeight, + chartSize, + svgWidth, + svgHeight, + centerX, + centerY, + }, + isPrinting, + hover: { hoveredIndex, mousePos, handleMouseMove, handleMouseLeave }, + legend: { legendExpanded, setLegendExpanded }, + tooltip: { tooltipPayload }, + style: { containerStyle }, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/polar/useRadarChartOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/polar/useRadarChartOrchestrator.ts new file mode 100644 index 000000000..f3e22d51c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/polar/useRadarChartOrchestrator.ts @@ -0,0 +1,143 @@ +import { scaleLinear } from "d3-scale"; +import { useMemo, useRef, useState } from "react"; + +import type { ChartData } from "../../types"; +import { buildContainerStyle } from "../../utils/buildContainerStyle"; +import type { PaletteName } from "../../utils/paletteUtils"; +import { useChartData } from "../core/useChartData"; +import { useContainerSize } from "../core/useContainerSize"; +import { useLegendHeight } from "../core/useLegendHeight"; +import { usePrintContext } from "../core/usePrintContext"; +import { useTooltipPayload } from "../core/useTooltipPayload"; +import { useRadarHover } from "./useRadarHover"; + +export interface UseRadarChartOrchestratorParams { + data: T; + categoryKey: keyof T[number]; + chartThemeName: PaletteName; + customPalette?: string[]; + icons?: Partial>; + showLegend: boolean; + maxChartSize: number; + minChartSize: number; + height?: number | string; + width?: number | string; + fitLegendInHeight?: boolean; + onClick?: (row: T[number], index: number) => void; +} + +export function useRadarChartOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + icons, + showLegend, + maxChartSize, + minChartSize, + height, + width, + fitLegendInHeight, + onClick, +}: UseRadarChartOrchestratorParams) { + const containerRef = useRef(null); + const legendRef = useRef(null); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + + const isPrinting = usePrintContext(); + const { width: containerWidth, height: containerHeight } = useContainerSize( + containerRef, + width, + height, + ); + const legendHeight = useLegendHeight(legendRef, showLegend); + + const shouldFitLegend = fitLegendInHeight ?? height !== undefined; + + const { + catKey, + allDataKeys, + dataKeys, + hiddenSeries, + toggleSeries, + chartConfig, + colorMap, + chartStyle, + legendItems, + } = useChartData({ data, categoryKey, chartThemeName, customPalette, icons }); + + // Dimensions + const legendDeduction = showLegend && shouldFitLegend ? legendHeight : 0; + const availableHeight = (containerHeight || 300) - legendDeduction; + const availableWidth = containerWidth || 300; + + const chartSize = Math.max(minChartSize, Math.min(maxChartSize, availableWidth, availableHeight)); + + const svgWidth = chartSize; + const svgHeight = chartSize; + const centerX = chartSize / 2; + const centerY = chartSize / 2; + const maxRadius = chartSize * 0.35; + + // Radial scale + const maxValue = useMemo(() => { + let max = 0; + for (const row of data) { + for (const key of dataKeys) { + const val = Number(row[key]) || 0; + if (val > max) max = val; + } + } + return max; + }, [data, dataKeys]); + + const radialScale = useMemo( + () => + scaleLinear() + .domain([0, maxValue || 1]) + .range([0, maxRadius]), + [maxValue, maxRadius], + ); + + // Hover + const hover = useRadarHover({ data, onClick }); + + // Tooltip + const tooltipPayload = useTooltipPayload(hover.hoveredIndex, data, dataKeys, catKey, chartConfig); + + // Container style + const containerStyle = useMemo( + () => buildContainerStyle(chartStyle, width, height), + [chartStyle, width, height], + ); + + return { + refs: { containerRef, legendRef }, + data: { catKey, allDataKeys, dataKeys, colorMap, chartConfig }, + dimensions: { + containerWidth, + chartSize, + svgWidth, + svgHeight, + centerX, + centerY, + maxRadius, + radialScale, + }, + isPrinting, + hover: { + hoveredIndex: hover.hoveredIndex, + mousePos: hover.mousePos, + createMouseHandlers: hover.createMouseHandlers, + }, + legend: { + legendItems, + hiddenSeries, + isLegendExpanded, + setIsLegendExpanded, + handleLegendItemClick: toggleSeries, + }, + tooltip: { tooltipPayload }, + style: { containerStyle }, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/polar/useRadarHover.ts b/packages/react-ui/src/components/ChartsV2/hooks/polar/useRadarHover.ts new file mode 100644 index 000000000..80c1d4694 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/polar/useRadarHover.ts @@ -0,0 +1,63 @@ +import { pointer } from "d3-selection"; +import React, { useCallback, useState } from "react"; + +import type { ChartData } from "../../types"; + +export interface UseRadarHoverParams { + data: T; + onClick?: (row: T[number], index: number) => void; +} + +export function useRadarHover({ data, onClick }: UseRadarHoverParams) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + + const createMouseHandlers = useCallback( + (findIndex: (mouseX: number, mouseY: number) => number) => { + const handleMouseMove = (event: React.MouseEvent) => { + const [mouseX, mouseY] = pointer(event.nativeEvent, event.currentTarget); + setHoveredIndex(findIndex(mouseX, mouseY)); + setMousePos({ x: event.clientX, y: event.clientY }); + }; + + const handleMouseLeave = () => { + setHoveredIndex(null); + setMousePos(null); + }; + + const handleTouchMove = (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const svgRect = event.currentTarget.getBoundingClientRect(); + const mouseX = touch.clientX - svgRect.left; + const mouseY = touch.clientY - svgRect.top; + setHoveredIndex(findIndex(mouseX, mouseY)); + setMousePos({ x: touch.clientX, y: touch.clientY }); + }; + + const handleTouchEnd = () => { + setHoveredIndex(null); + setMousePos(null); + }; + + const handleClick = onClick + ? (event: React.MouseEvent) => { + const [mouseX, mouseY] = pointer(event.nativeEvent, event.currentTarget); + const idx = findIndex(mouseX, mouseY); + if (idx >= 0 && idx < data.length) { + onClick(data[idx]!, idx); + } + } + : undefined; + + return { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick }; + }, + [onClick, data], + ); + + return { + hoveredIndex, + mousePos, + createMouseHandlers, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/index.ts b/packages/react-ui/src/components/ChartsV2/index.ts new file mode 100644 index 000000000..d6d968536 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/index.ts @@ -0,0 +1,22 @@ +export { D3AreaChart } from "./D3AreaChart"; +export type { D3AreaChartData, D3AreaChartProps, D3AreaChartVariant } from "./D3AreaChart/types"; + +export { D3LineChart } from "./D3LineChart"; +export type { D3LineChartData, D3LineChartProps, D3LineChartVariant } from "./D3LineChart/types"; + +export { D3BarChart } from "./D3BarChart"; +export type { D3BarChartData, D3BarChartProps, D3BarChartVariant } from "./D3BarChart/types"; + +export { D3PieChart } from "./D3PieChart"; +export type { D3PieChartData, D3PieChartProps } from "./D3PieChart/types"; + +export { D3RadialChart } from "./D3RadialChart"; +export type { D3RadialChartData, D3RadialChartProps } from "./D3RadialChart/types"; + +export { D3RadarChart } from "./D3RadarChart"; +export type { D3RadarChartData, D3RadarChartProps } from "./D3RadarChart/types"; + +export { D3ScatterChart } from "./D3ScatterChart"; +export type { D3ScatterChartData, D3ScatterChartProps } from "./D3ScatterChart/types"; + +export type { BaseChartProps, ChartData, LegendItem, XAxisTickVariant } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/ClipDefs.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ClipDefs.tsx new file mode 100644 index 000000000..7bcd140cd --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ClipDefs.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +const CLIP_OVERFLOW = 6; + +interface ClipDefsProps { + chartId: string; + chartWidth: number; + chartHeight: number; +} + +export const ClipDefs: React.FC = ({ chartId, chartWidth, chartHeight }) => { + return ( + + + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/Grid.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/Grid.tsx new file mode 100644 index 000000000..23ee2f85c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/Grid.tsx @@ -0,0 +1,24 @@ +import type { ScaleLinear } from "d3-scale"; +import React from "react"; + +interface GridProps { + yScale: ScaleLinear; + chartWidth: number; + chartHeight: number; + className?: string; +} + +const MIN_TICK_SPACING = 40; + +export const Grid: React.FC = ({ yScale, chartWidth, chartHeight, className }) => { + const tickCount = Math.max(2, Math.floor(chartHeight / MIN_TICK_SPACING)); + const ticks = yScale.ticks(tickCount); + + return ( + + {ticks.map((tick) => ( + + ))} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/LineDotCrosshair.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/LineDotCrosshair.tsx new file mode 100644 index 000000000..b16e305be --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/LineDotCrosshair.tsx @@ -0,0 +1,60 @@ +import type { ScaleLinear, ScalePoint } from "d3-scale"; +import React from "react"; + +interface LineDotCrosshairProps { + hoveredIndex: number | null; + xScale: ScalePoint; + yScale: ScaleLinear; + data: Array>; + dataKeys: string[]; + categoryKey: string; + colors: Record; + chartHeight: number; + getYValue: (row: Record, key: string, seriesIndex: number) => number; + classPrefix: string; +} + +export const LineDotCrosshair: React.FC = ({ + hoveredIndex, + xScale, + yScale, + data, + dataKeys, + categoryKey, + colors, + chartHeight, + getYValue, + classPrefix, +}) => { + if (hoveredIndex === null || hoveredIndex < 0 || hoveredIndex >= data.length) { + return null; + } + + const row = data[hoveredIndex]!; + const category = String(row[categoryKey]); + const x = xScale(category) ?? 0; + + return ( + + + {dataKeys.map((key, seriesIndex) => { + const yValue = getYValue(row, key, seriesIndex); + const y = yScale(yValue); + const color = colors[key] ?? "#000"; + + return ( + + + + + ); + })} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx new file mode 100644 index 000000000..33f70baa2 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx @@ -0,0 +1,62 @@ +import clsx from "clsx"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import React from "react"; +import { IconButton } from "../../../../IconButton"; + +interface ScrollButtonsHorizontalProps { + dataWidth: number; + effectiveWidth: number; + canScrollLeft: boolean; + canScrollRight: boolean; + onScrollLeft: () => void; + onScrollRight: () => void; +} + +export const ScrollButtonsHorizontal = React.memo( + ({ + dataWidth, + effectiveWidth, + canScrollLeft, + canScrollRight, + onScrollLeft, + onScrollRight, + }: ScrollButtonsHorizontalProps) => { + if (dataWidth <= effectiveWidth) { + return null; + } + + return ( +
+ } + variant="secondary" + onClick={onScrollLeft} + size="2-extra-small" + disabled={!canScrollLeft} + /> + + } + variant="secondary" + size="2-extra-small" + onClick={onScrollRight} + disabled={!canScrollRight} + /> +
+ ); + }, +); + +ScrollButtonsHorizontal.displayName = "ScrollButtonsHorizontal"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss new file mode 100644 index 000000000..c254fcdf7 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss @@ -0,0 +1,34 @@ +@use "../../../../../cssUtils" as cssUtils; + +.openui-chart-horizontal-scroll { + &-buttons-container { + position: relative; + } + + &-button { + position: absolute; + background-color: cssUtils.$foreground; + + &:hover { + background-color: cssUtils.$foreground; + } + + &--left { + top: -23px; + transform: translateY(-50%); + left: 20px; + } + + &--right { + top: -23px; + transform: translateY(-50%); + right: 0px; + } + + &--disabled { + visibility: hidden; + cursor: not-allowed; + transition: visibility 0.1s linear; + } + } +} diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/VerticalGrid.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/VerticalGrid.tsx new file mode 100644 index 000000000..e30c0421e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/VerticalGrid.tsx @@ -0,0 +1,31 @@ +import type { ScaleLinear } from "d3-scale"; +import React from "react"; + +const DEFAULT_MIN_TICK_SPACING = 60; + +interface VerticalGridProps { + xScale: ScaleLinear; + chartWidth: number; + chartHeight: number; + className?: string; + minTickSpacing?: number; +} + +export const VerticalGrid: React.FC = ({ + xScale, + chartWidth, + chartHeight, + className, + minTickSpacing = DEFAULT_MIN_TICK_SPACING, +}) => { + const tickCount = Math.max(2, Math.floor(chartWidth / minTickSpacing)); + const ticks = xScale.ticks(tickCount); + + return ( + + {ticks.map((t) => ( + + ))} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/AngledXAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/AngledXAxis.tsx new file mode 100644 index 000000000..1d8db06e0 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/AngledXAxis.tsx @@ -0,0 +1,52 @@ +import type { ScaleBand, ScalePoint } from "d3-scale"; +import React from "react"; + +const X_AXIS_TOP_GAP = 4; + +type AngledXAxisScale = ScaleBand | ScalePoint; + +function isBandScale(scale: AngledXAxisScale): scale is ScaleBand { + return typeof (scale as ScaleBand).paddingInner === "function"; +} + +interface AngledXAxisProps { + scale: AngledXAxisScale; + angle: number; + xAxisHeight: number; + classPrefix: string; +} + +export const AngledXAxis: React.FC = ({ + scale, + angle, + xAxisHeight: _xAxisHeight, + classPrefix, +}) => { + const domain = scale.domain(); + const band = isBandScale(scale); + const isAngled = angle !== 0; + + return ( + + {domain.map((category) => { + const rawX = scale(category) ?? 0; + const x = band ? rawX + (scale as ScaleBand).bandwidth() / 2 : rawX; + const y = X_AXIS_TOP_GAP; + + return ( + + {String(category)} + + ); + })} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxis.tsx new file mode 100644 index 000000000..16ce676ca --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxis.tsx @@ -0,0 +1,69 @@ +import type { ScaleBand, ScalePoint } from "d3-scale"; +import React from "react"; +import type { XAxisTickVariant } from "../../../types"; +import { XAxisLabel } from "./XAxisLabel"; + +const X_AXIS_TOP_GAP = 4; + +type XAxisScale = ScalePoint | ScaleBand; + +interface XAxisProps { + scale: XAxisScale; + data: Array>; + categoryKey: string; + tickVariant: XAxisTickVariant; + widthOfGroup?: number; + labelHeight: number; + labelInterval?: number; + classPrefix: string; +} + +function isBandScale(scale: XAxisScale): scale is ScaleBand { + return typeof (scale as ScaleBand).paddingInner === "function"; +} + +export const XAxis: React.FC = ({ + scale, + data: _data, + categoryKey: _categoryKey, + tickVariant, + widthOfGroup, + labelHeight, + labelInterval = 1, + classPrefix, +}) => { + const domain = scale.domain(); + const band = isBandScale(scale); + const labelWidth = band ? (scale as ScaleBand).bandwidth() : (widthOfGroup ?? 0); + + return ( + + {domain.map((category, i) => { + const rawX = scale(category) ?? 0; + const x = band ? rawX : rawX - labelWidth / 2; + const label = String(category); + const showLabel = labelInterval <= 1 || i % labelInterval === 0 || i === domain.length - 1; + + return ( + + {showLabel && ( + + )} + + ); + })} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxisLabel.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxisLabel.tsx new file mode 100644 index 000000000..89474cf13 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxisLabel.tsx @@ -0,0 +1,33 @@ +import React, { useRef } from "react"; +import type { XAxisTickVariant } from "../../../types"; +import { LabelTooltip } from "../../core/LabelTooltip/LabelTooltip"; +import { useIsTruncated } from "../../core/useIsTruncated"; + +interface XAxisLabelProps { + label: string; + tickVariant: XAxisTickVariant; + width: number; + multiLineClassName: string; + singleLineClassName: string; +} + +export const XAxisLabel: React.FC = ({ + label, + tickVariant, + width, + multiLineClassName, + singleLineClassName, +}) => { + const labelRef = useRef(null); + const isTruncated = useIsTruncated(labelRef); + + const className = tickVariant === "multiLine" ? multiLineClassName : singleLineClassName; + + return ( + +
+ {label} +
+
+ ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/YAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/YAxis.tsx new file mode 100644 index 000000000..b76d590cc --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/YAxis.tsx @@ -0,0 +1,41 @@ +import type { ScaleLinear } from "d3-scale"; +import React from "react"; +import { numberTickFormatter } from "../../../utils/styleUtils"; + +interface YAxisProps { + scale: ScaleLinear; + width: number; + chartHeight: number; + className?: string; + tickClassName?: string; +} + +const MIN_TICK_SPACING = 40; + +export const YAxis: React.FC = ({ + scale, + width, + chartHeight, + className, + tickClassName, +}) => { + const tickCount = Math.max(2, Math.floor(chartHeight / MIN_TICK_SPACING)); + const ticks = scale.ticks(tickCount); + + return ( + + {ticks.map((tick) => ( + + {numberTickFormatter(tick)} + + ))} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/chartBase.scss b/packages/react-ui/src/components/ChartsV2/shared/cartesian/chartBase.scss new file mode 100644 index 000000000..ca5c7d170 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/chartBase.scss @@ -0,0 +1,102 @@ +@use "../../../../cssUtils" as cssUtils; + +@mixin chart-base($prefix) { + .openui-d3-#{$prefix} { + &-container { + width: 100%; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + } + + &-container-inner { + display: flex; + width: 100%; + flex: 1; + min-height: 0; + } + + &-y-axis-container { + flex-shrink: 0; + } + + &-main-container { + flex: 1; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; + -ms-overflow-style: none; + } + } + + .openui-d3-#{$prefix}-grid line { + stroke: cssUtils.$border-default; + stroke-dasharray: 3 3; + } + + .openui-d3-#{$prefix}-y-tick { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$text-neutral-secondary; + } + + .openui-d3-#{$prefix}-x-tick-multi-line { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-secondary; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-align: center; + word-break: break-word; + } + + .openui-d3-#{$prefix}-x-tick-single-line { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-secondary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + } + + .openui-d3-#{$prefix}-x-tick-angled { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$text-neutral-secondary; + } +} + +.openui-d3-chart-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + + &-text { + @include cssUtils.typography(body, default); + color: cssUtils.$text-neutral-secondary; + } +} + +@mixin crosshair-styles($prefix) { + .openui-d3-#{$prefix}-crosshair-line { + stroke: cssUtils.$border-default; + stroke-width: 1; + stroke-dasharray: 4 4; + } + + .openui-d3-#{$prefix}-active-dot-outer { + fill: cssUtils.$foreground; + stroke: cssUtils.$border-default; + stroke-width: 1; + } + + .openui-d3-#{$prefix}-active-dot-inner { + stroke: none; + } +} diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/CondensedChartLayout.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/CondensedChartLayout.tsx new file mode 100644 index 000000000..406027cbc --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/CondensedChartLayout.tsx @@ -0,0 +1,143 @@ +import clsx from "clsx"; +import type { ScaleLinear } from "d3-scale"; +import React from "react"; + +import type { useChartCondensedOrchestrator } from "../../../hooks"; +import { DefaultLegend } from "../../core/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../../core/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../../core/PortalTooltip/ChartTooltip"; +import { YAxis } from "../axes/YAxis"; +import { Grid } from "../Grid"; + +interface MouseHandlers { + handleMouseMove: React.MouseEventHandler; + handleMouseLeave: React.MouseEventHandler; + handleTouchMove: React.TouchEventHandler; + handleTouchEnd: React.TouchEventHandler; + handleClick?: React.MouseEventHandler; +} + +export interface CondensedChartLayoutProps { + orch: ReturnType; + yScale: ScaleLinear; + mouseHandlers: MouseHandlers; + defs?: React.ReactNode; + series: React.ReactNode; + xAxis: React.ReactNode; + classPrefix: string; + chartType: string; + ariaLabel: string; + showYAxis: boolean; + grid: boolean; + showLegend: boolean; + xAxisLabel?: React.ReactNode; + yAxisLabel?: React.ReactNode; + className?: string; +} + +export function CondensedChartLayout({ + orch, + yScale, + mouseHandlers, + defs, + series, + xAxis, + classPrefix, + chartType, + ariaLabel, + showYAxis, + grid, + showLegend, + xAxisLabel, + yAxisLabel, + className, +}: CondensedChartLayoutProps) { + const prefix = `openui-d3-${classPrefix}`; + + return ( + +
+ + {defs} + + {showYAxis && ( + + + + )} + + + + + {grid && ( + + )} + {series} + + + + + {xAxis} + + + + {showLegend && ( + + )} + + {orch.tooltip.tooltipPayload && orch.hover.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/ScrollableChartLayout.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/ScrollableChartLayout.tsx new file mode 100644 index 000000000..654a10c77 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/ScrollableChartLayout.tsx @@ -0,0 +1,160 @@ +import clsx from "clsx"; +import type { ScaleLinear } from "d3-scale"; +import React from "react"; + +import type { useChartScrollableOrchestrator } from "../../../hooks"; +import { DefaultLegend } from "../../core/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../../core/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../../core/PortalTooltip/ChartTooltip"; +import { Grid } from "../Grid"; +import { ScrollButtonsHorizontal } from "../ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +import { YAxis } from "../axes/YAxis"; + +interface MouseHandlers { + handleMouseMove: React.MouseEventHandler; + handleMouseLeave: React.MouseEventHandler; + handleTouchMove: React.TouchEventHandler; + handleTouchEnd: React.TouchEventHandler; + handleClick?: React.MouseEventHandler; +} + +export interface ScrollableChartLayoutProps { + orch: ReturnType; + yScale: ScaleLinear; + mouseHandlers: MouseHandlers; + defs?: React.ReactNode; + series: React.ReactNode; + xAxis: React.ReactNode; + classPrefix: string; + chartType: string; + ariaLabel: string; + showYAxis: boolean; + grid: boolean; + showLegend: boolean; + xAxisLabel?: React.ReactNode; + yAxisLabel?: React.ReactNode; + className?: string; +} + +export function ScrollableChartLayout({ + orch, + yScale, + mouseHandlers, + defs, + series, + xAxis, + classPrefix, + chartType, + ariaLabel, + showYAxis, + grid, + showLegend, + xAxisLabel, + yAxisLabel, + className, +}: ScrollableChartLayoutProps) { + const prefix = `openui-d3-${classPrefix}`; + + return ( + +
+
+ {showYAxis && ( +
+ + + + + +
+ )} + +
+ + {defs} + + {grid && ( + + )} + {series} + + + {xAxis} + + +
+
+ + orch.scroll.scrollTo("left")} + onScrollRight={() => orch.scroll.scrollTo("right")} + /> + + {showLegend && ( + + )} + + {orch.tooltip.tooltipPayload && orch.hover.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/DefaultLegend.tsx b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/DefaultLegend.tsx new file mode 100644 index 000000000..06c5d9920 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/DefaultLegend.tsx @@ -0,0 +1,148 @@ +import clsx from "clsx"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import React, { memo, useCallback, useState } from "react"; +import { Button } from "../../../../Button/Button"; +import { type LegendItem } from "../../../types"; +import { useDefaultLegend } from "./hooks/useDefaultLegend"; + +interface DefaultLegendProps { + items: LegendItem[]; + className?: string; + yAxisLabel?: React.ReactNode; + xAxisLabel?: React.ReactNode; + containerWidth?: number; + isExpanded: boolean; + setIsExpanded: (isExpanded: boolean) => void; + style?: React.CSSProperties; + onItemClick?: (key: string) => void; + hiddenSeries?: Set; +} + +const DefaultLegend = memo( + React.forwardRef( + ( + { + items, + className, + yAxisLabel, + xAxisLabel, + containerWidth, + isExpanded, + setIsExpanded, + style, + onItemClick, + hiddenSeries, + }, + ref, + ) => { + const [buttonWidth, setButtonWidth] = useState(0); + const { displayItems, hasMoreItems, toggleButtonText } = useDefaultLegend({ + items, + containerWidth, + buttonWidth, + isExpanded, + }); + + const buttonRef = useCallback( + (node: HTMLButtonElement | null) => { + if (node) { + if (node.clientWidth !== buttonWidth) { + setButtonWidth(node.clientWidth); + } + } + }, + [buttonWidth], + ); + + const handleToggleExpanded = () => { + setIsExpanded(!isExpanded); + }; + + const showToggleButton = hasMoreItems; + + return ( +
+ {(xAxisLabel || yAxisLabel) && ( +
+ {xAxisLabel && ( + + X-Axis: {xAxisLabel} + + )} + {yAxisLabel && ( + + Y-Axis: {yAxisLabel} + + )} +
+ )} +
+ {displayItems.map((item) => { + const isHidden = hiddenSeries?.has(item.key); + return ( +
onItemClick(item.key) : undefined} + > + {item.icon ? ( + + ) : ( +
+ )} +
+ {item.label} + {item.percentage !== undefined && ( + + {item.percentage.toFixed(1)}% + + )} +
+
+ ); + })} + + {showToggleButton && ( + + )} +
+
+ ); + }, + ), +); + +DefaultLegend.displayName = "DefaultLegend"; +export { DefaultLegend }; +export type { DefaultLegendProps }; diff --git a/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/defaultLegend.scss b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/defaultLegend.scss new file mode 100644 index 000000000..8116146e0 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/defaultLegend.scss @@ -0,0 +1,97 @@ +@use "../../../../../cssUtils" as cssUtils; + +.openui-chart-legend-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: cssUtils.$space-s; +} + +.openui-chart-legend-axis-label-container { + display: flex; + align-items: center; + justify-content: center; + gap: cssUtils.$space-m; + flex-wrap: wrap; +} + +.openui-chart-legend-axis-label { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-secondary; + + &-text { + color: cssUtils.$text-neutral-primary; + } +} + +.openui-chart-legend { + display: flex; + align-items: center; + justify-content: center; + gap: cssUtils.$space-m; + text-transform: capitalize; + flex-wrap: wrap; + + &--bottom { + padding-top: cssUtils.$space-m; + } + + &--collapsed { + flex-wrap: nowrap; + overflow: hidden; + } + + &--expanded { + flex-wrap: wrap; + } + + &-item { + display: flex; + align-items: center; + gap: cssUtils.$space-xs; + + svg { + height: 10px; + width: 10px; + color: cssUtils.$text-neutral-primary; + } + + &-indicator { + height: 10px; + width: 10px; + flex-shrink: 0; + border-radius: cssUtils.$radius-2xs; + background-color: var(--color-bg); + } + + &-label { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-primary; + } + } + + &-toggle-button { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-primary; + padding-left: cssUtils.$space-2xs; + padding-right: cssUtils.$space-3xs; + + &-icon { + width: 1em; + height: 1em; + color: cssUtils.$text-neutral-primary; + } + } + + &-item-label-container { + display: flex; + align-items: center; + gap: cssUtils.$space-2xs; + } + + &-item-percentage { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-secondary; + } +} diff --git a/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/hooks/useDefaultLegend.ts b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/hooks/useDefaultLegend.ts new file mode 100644 index 000000000..a3f58e1aa --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/hooks/useDefaultLegend.ts @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import { useCanvasContextForLabelSize } from "../../../../hooks"; +import { LegendItem } from "../../../../types"; + +const CHARACTER_WIDTH = 7; +const INDICATOR_WIDTH = 10; +const GAP_WIDTH = 12; + +interface UseDefaultLegendProps { + items: LegendItem[]; + containerWidth?: number; + buttonWidth?: number; + isExpanded: boolean; +} + +interface UseDefaultLegendResult { + displayItems: LegendItem[]; + hasMoreItems: boolean; + toggleButtonText: string; +} + +export const useDefaultLegend = ({ + items, + containerWidth, + buttonWidth, + isExpanded, +}: UseDefaultLegendProps): UseDefaultLegendResult => { + const canvasContext = useCanvasContextForLabelSize(); + + const calculateItemWidth = useMemo( + () => + (item: LegendItem): number => { + let displayText = item.label; + if (item.percentage !== undefined) { + displayText += ` (${item.percentage.toFixed(1)}%)`; + } + if (canvasContext) { + return canvasContext.measureText(displayText).width + INDICATOR_WIDTH + GAP_WIDTH; + } + return displayText.length * CHARACTER_WIDTH + INDICATOR_WIDTH + GAP_WIDTH; + }, + [canvasContext], + ); + + const { visibleItems, hasMoreItems } = useMemo(() => { + if (!containerWidth || items.length === 0) { + return { visibleItems: items, hasMoreItems: false }; + } + + const availableWidth = containerWidth - (buttonWidth ?? 0); + let currentWidth = 0; + let visibleCount = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item) continue; + const itemWidth = calculateItemWidth(item); + const requiredWidth = visibleCount > 0 ? itemWidth + GAP_WIDTH : itemWidth; + if (currentWidth + requiredWidth <= availableWidth) { + currentWidth += requiredWidth; + visibleCount++; + } else { + break; + } + } + + if (visibleCount === items.length) { + return { visibleItems: items, hasMoreItems: false }; + } + + if (visibleCount === 0 && items[0]) { + return { + visibleItems: [items[0]], + hasMoreItems: items.length > 1, + }; + } + + return { + visibleItems: items.slice(0, visibleCount), + hasMoreItems: items.length > visibleCount, + }; + }, [items, containerWidth, buttonWidth, calculateItemWidth]); + + const displayItems = useMemo( + () => (isExpanded ? items : visibleItems), + [isExpanded, items, visibleItems], + ); + + const toggleButtonText = useMemo(() => { + if (isExpanded) { + return "Show Less"; + } + const hiddenCount = items.length - visibleItems.length; + return `${hiddenCount} more`; + }, [isExpanded, items.length, visibleItems.length]); + + return { + displayItems, + hasMoreItems, + toggleButtonText, + }; +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/core/LabelTooltip/LabelTooltip.tsx b/packages/react-ui/src/components/ChartsV2/shared/core/LabelTooltip/LabelTooltip.tsx new file mode 100644 index 000000000..46e7bae31 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/core/LabelTooltip/LabelTooltip.tsx @@ -0,0 +1,85 @@ +import * as Tooltip from "@radix-ui/react-tooltip"; +import React from "react"; + +interface LabelTooltipProviderProps { + children: React.ReactNode; + delayDuration?: number; + skipDelayDuration?: number; + disableHoverableContent?: boolean; +} + +interface LabelTooltipProps { + children: React.ReactNode; + content: string; + side?: "top" | "bottom" | "left" | "right"; + sideOffset?: number; + delayDuration?: number; + className?: string; + disabled?: boolean; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +const DEFAULT_DELAY_DURATION = 300; +const DEFAULT_SKIP_DELAY_DURATION = 300; +const DEFAULT_DISABLE_HOVERABLE_CONTENT = false; + +const LabelTooltipProvider: React.FC = (props) => { + const { + children, + delayDuration = DEFAULT_DELAY_DURATION, + skipDelayDuration = DEFAULT_SKIP_DELAY_DURATION, + disableHoverableContent = DEFAULT_DISABLE_HOVERABLE_CONTENT, + } = props; + + return ( + + {children} + + ); +}; + +const LabelTooltip = React.forwardRef((props, ref) => { + const { + children, + content, + side = "top", + sideOffset = 1, + delayDuration = DEFAULT_DELAY_DURATION, + className = "openui-chart-label-tooltip", + disabled = false, + open, + defaultOpen, + onOpenChange, + } = props; + + if (disabled) { + return children; + } + + return ( + + {children} + + + {content} + + + + ); +}); + +LabelTooltip.displayName = "LabelTooltip"; + +export { LabelTooltip, LabelTooltipProvider }; +export type { LabelTooltipProps, LabelTooltipProviderProps }; diff --git a/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/ChartTooltip.tsx b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/ChartTooltip.tsx new file mode 100644 index 000000000..db5c46be3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/ChartTooltip.tsx @@ -0,0 +1,113 @@ +import { flip, offset, shift, useFloating } from "@floating-ui/react-dom"; +import clsx from "clsx"; +import React, { memo, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useTheme } from "../../../../ThemeProvider"; +import { tooltipNumberFormatter } from "./utils"; + +export interface TooltipItem { + name: string; + value: number; + color: string; +} + +interface ChartTooltipProps { + label: string; + items: TooltipItem[]; + viewportPosition: { x: number; y: number }; + className?: string; +} + +function ChartTooltipRender({ label, items, viewportPosition, className }: ChartTooltipProps) { + const { portalThemeClassName } = useTheme(); + + const virtualEl = useMemo( + () => ({ + getBoundingClientRect: () => ({ + x: viewportPosition.x, + y: viewportPosition.y, + width: 0, + height: 0, + top: viewportPosition.y, + left: viewportPosition.x, + right: viewportPosition.x, + bottom: viewportPosition.y, + }), + }), + [viewportPosition.x, viewportPosition.y], + ); + + const { refs, floatingStyles } = useFloating({ + placement: "right-start", + middleware: [offset(20), flip(), shift({ padding: 8 })], + elements: { reference: virtualEl }, + }); + + const isGreaterThanTen = items.length > 10; + const remainingItems = isGreaterThanTen ? items.length - 5 : 0; + const displayItems = isGreaterThanTen ? items.slice(0, 5) : items; + const isTwoItemsLayout = items.length <= 2; + + return createPortal( +
+
+
{label}
+
+
+ {displayItems.map((item, index) => ( +
+
+
+
+ {item.name} +
+ + {tooltipNumberFormatter(item.value)} + +
+
+
+ ))} +
+ {isGreaterThanTen &&
} + {isGreaterThanTen && ( +
+ Click to view all {remainingItems} +
+ )} +
+
, + document.body, + ); +} + +export const ChartTooltip = memo(ChartTooltipRender); +ChartTooltip.displayName = "ChartTooltip"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/portalTooltip.scss b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/portalTooltip.scss new file mode 100644 index 000000000..7d6abf9e3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/portalTooltip.scss @@ -0,0 +1,156 @@ +@use "../../../../../cssUtils" as cssUtils; + +.openui-portal-tooltip { + pointer-events: none; + z-index: 1000; + position: absolute; +} + +.openui-chart-tooltip { + display: grid; + align-items: start; + min-width: 128px; + max-width: 240px; + gap: cssUtils.$space-xs; + padding: cssUtils.$space-xs; + color: cssUtils.$text-neutral-primary; + @include cssUtils.typography(label, extra-small); + border-radius: cssUtils.$radius-l; + border: 1px solid cssUtils.$border-default; + background-color: cssUtils.$foreground; + box-shadow: cssUtils.$shadow-s; + text-transform: capitalize; + + &-label { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-primary; + overflow-wrap: break-word; + word-break: break-word; + } + + &-label-heavy { + @include cssUtils.typography(label, extra-small-heavy); + color: cssUtils.$text-neutral-primary; + overflow-wrap: break-word; + word-break: break-word; + } + + &-content { + display: grid; + align-items: start; + min-width: 128px; + gap: cssUtils.$space-xs; + color: cssUtils.$text-neutral-primary; + @include cssUtils.typography(label, extra-small); + background-color: cssUtils.$foreground; + text-transform: capitalize; + + &-item { + display: flex; + width: 100%; + flex-wrap: wrap; + gap: cssUtils.$space-xs; + align-items: flex-start; + &--dot { + align-items: center; + } + + svg { + height: 10px; + width: 10px; + color: cssUtils.$text-neutral-primary; + } + } + + &-indicator { + flex-shrink: 0; + border-radius: cssUtils.$radius-2xs; + + &--dot { + height: 10px; + width: 10px; + background-color: var(--color-bg); + border-color: var(--color-border); + } + + &--line { + width: 4px; + background-color: var(--color-bg); + border-color: var(--color-border); + } + + &--dashed { + width: 0; + border: 1.5px dashed var(--color-border); + background-color: transparent; + } + + &--two-items { + margin-top: 2px; + } + } + + &-value-wrapper { + display: flex; + flex: 1; + gap: cssUtils.$space-s; + justify-content: space-between; + line-height: 1; + + &--nested { + align-items: flex-end; + } + + &--standard { + align-items: center; + } + + &--vertical { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: cssUtils.$space-2xs; + } + } + + &-label { + display: grid; + gap: cssUtils.$space-xs; + color: cssUtils.$text-neutral-secondary; + @include cssUtils.typography(label, extra-small); + + span { + display: block; + overflow-wrap: break-word; + word-break: break-word; + } + } + + &-value { + font-variant-numeric: tabular-nums; + color: cssUtils.$text-neutral-primary; + @include cssUtils.typography(label, extra-small); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &-item-separator { + width: 100%; + height: 1px; + background-color: cssUtils.$border-default; + margin: 0; + } + &-item:last-child &-item-separator { + display: none; + } + + &-view-more { + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-primary; + text-align: left; + overflow-wrap: break-word; + word-break: break-word; + } + } +} diff --git a/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/utils/index.ts b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/utils/index.ts new file mode 100644 index 000000000..9de8b1951 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/utils/index.ts @@ -0,0 +1,23 @@ +const tooltipNumberFormatter = (value: number) => { + const isNegative = value < 0; + const absValue = Math.abs(value); + + if (absValue < 100000) { + return (isNegative ? "-" : "") + absValue.toLocaleString(); + } + + const units = ["", "K", "M", "B", "T"]; + let unitIndex = 0; + let scaledValue = absValue; + + while (scaledValue >= 1000 && unitIndex < units.length - 1) { + scaledValue /= 1000; + unitIndex++; + } + + const formattedValue = Math.floor(scaledValue * 10) / 10; + + return (isNegative ? "-" : "") + `${formattedValue}${units[unitIndex]}`; +}; + +export { tooltipNumberFormatter }; diff --git a/packages/react-ui/src/components/ChartsV2/shared/core/useIsTruncated.ts b/packages/react-ui/src/components/ChartsV2/shared/core/useIsTruncated.ts new file mode 100644 index 000000000..8b4f8dd74 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/core/useIsTruncated.ts @@ -0,0 +1,23 @@ +import React from "react"; + +export function useIsTruncated(ref: React.RefObject): boolean { + const [truncated, setTruncated] = React.useState(false); + + React.useEffect(() => { + const el = ref.current; + if (!el) return; + + const check = () => { + setTruncated(el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight); + }; + + check(); + + const observer = new ResizeObserver(check); + observer.observe(el); + + return () => observer.disconnect(); + }, [ref]); + + return truncated; +} diff --git a/packages/react-ui/src/components/ChartsV2/shared/index.ts b/packages/react-ui/src/components/ChartsV2/shared/index.ts new file mode 100644 index 000000000..099b8d040 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/index.ts @@ -0,0 +1,17 @@ +// Core — used by all chart types +export { DefaultLegend } from "./core/DefaultLegend/DefaultLegend"; +export { LabelTooltip, LabelTooltipProvider } from "./core/LabelTooltip/LabelTooltip"; +export { ChartTooltip } from "./core/PortalTooltip/ChartTooltip"; +export type { TooltipItem } from "./core/PortalTooltip/ChartTooltip"; +export { useIsTruncated } from "./core/useIsTruncated"; + +// Cartesian — used by Area, Bar, Line (+ Scatter for Grid/YAxis) +export { AngledXAxis } from "./cartesian/axes/AngledXAxis"; +export { XAxis } from "./cartesian/axes/XAxis"; +export { XAxisLabel } from "./cartesian/axes/XAxisLabel"; +export { YAxis } from "./cartesian/axes/YAxis"; +export { ClipDefs } from "./cartesian/ClipDefs"; +export { Grid } from "./cartesian/Grid"; +export { LineDotCrosshair } from "./cartesian/LineDotCrosshair"; +export { ScrollButtonsHorizontal } from "./cartesian/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +export { VerticalGrid } from "./cartesian/VerticalGrid"; diff --git a/packages/react-ui/src/components/ChartsV2/types/common.ts b/packages/react-ui/src/components/ChartsV2/types/common.ts new file mode 100644 index 000000000..b3816e69e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/types/common.ts @@ -0,0 +1,38 @@ +import type { PaletteName } from "../utils/paletteUtils"; + +export interface LegendItem { + key: string; + label: string; + color: string; + icon?: React.ComponentType; + percentage?: number; +} + +export type XAxisTickVariant = "singleLine" | "multiLine"; + +export type ChartData = Array>; + +export interface BaseChartProps { + data: T; + categoryKey: keyof T[number]; + theme?: PaletteName; + customPalette?: string[]; + tickVariant?: XAxisTickVariant; + grid?: boolean; + legend?: boolean; + icons?: Partial>; + isAnimationActive?: boolean; + showYAxis?: boolean; + xAxisLabel?: React.ReactNode; + yAxisLabel?: React.ReactNode; + className?: string; + height?: number | string; + width?: number | string; + /** When true, legend height is subtracted from container height so chart + legend + * fit within specified dimensions. Defaults to true when `height` is set, false otherwise. */ + fitLegendInHeight?: boolean; + /** When true, all data fits within the container width (no scrolling). Default false. */ + condensed?: boolean; + /** Controls spacing between data points in scrollable mode. Default "default". */ + density?: "compact" | "default" | "spacious"; +} diff --git a/packages/react-ui/src/components/ChartsV2/types/index.ts b/packages/react-ui/src/components/ChartsV2/types/index.ts new file mode 100644 index 000000000..6b5716089 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/types/index.ts @@ -0,0 +1 @@ +export * from "./common"; diff --git a/packages/react-ui/src/components/ChartsV2/utils/buildContainerStyle.ts b/packages/react-ui/src/components/ChartsV2/utils/buildContainerStyle.ts new file mode 100644 index 000000000..2d0b1c15b --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/buildContainerStyle.ts @@ -0,0 +1,10 @@ +export function buildContainerStyle( + chartStyle: Record, + fixedWidth?: number | string, + height?: number | string, +): Record { + const s: Record = { ...chartStyle }; + if (typeof fixedWidth === "string") s["width"] = fixedWidth; + if (typeof height === "string") s["height"] = height; + return s; +} diff --git a/packages/react-ui/src/components/ChartsV2/utils/constants.ts b/packages/react-ui/src/components/ChartsV2/utils/constants.ts new file mode 100644 index 000000000..054bc8cf4 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/constants.ts @@ -0,0 +1,4 @@ +export const CHART_MARGIN_TOP = 10; +export const DEFAULT_CHART_HEIGHT = 296; +export const SINGLE_LINE_BREAKPOINT = 300; +export const ANGLED_LABEL_THRESHOLD = 100; diff --git a/packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts new file mode 100644 index 000000000..96450f15d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts @@ -0,0 +1,62 @@ +import { LegendItem } from "../types"; + +export interface ChartConfig { + [key: string]: { + label: string; + icon?: React.ComponentType; + color?: string; + secondaryColor?: string; + transformed?: string; + }; +} + +export const getDataKeys = ( + data: Array>, + categoryKey: string, +): string[] => { + return Object.keys(data[0] || {}).filter((key) => key !== categoryKey); +}; + +export const get2dChartConfig = ( + dataKeys: string[], + colors: string[], + transformedKeys: Record, + secondaryColors?: string[], + icons?: Partial>, +): ChartConfig => { + return dataKeys.reduce( + (config, key, index) => ({ + ...config, + [key]: { + label: key, + icon: icons?.[key], + color: colors[index], + secondaryColor: secondaryColors?.[index] || colors[dataKeys.length - index - 1], + transformed: transformedKeys[key], + }, + }), + {}, + ); +}; + +export const getLegendItems = ( + dataKeys: string[], + colors: string[], + icons?: Partial>, +): LegendItem[] => { + return dataKeys.map((key, index) => ({ + key, + label: key, + color: colors[index] ?? "#000000", + icon: icons?.[key] as React.ComponentType | undefined, + })); +}; + +export const getColorForDataKey = ( + dataKey: string, + dataKeys: string[], + colors: string[], +): string => { + const index = dataKeys.indexOf(dataKey); + return colors[index] ?? "#000000"; +}; diff --git a/packages/react-ui/src/components/ChartsV2/utils/index.ts b/packages/react-ui/src/components/ChartsV2/utils/index.ts new file mode 100644 index 000000000..ff1bcdbc3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/index.ts @@ -0,0 +1,8 @@ +export * from "./buildContainerStyle"; +export * from "./constants"; +export * from "./dataUtils"; +export * from "./mouseUtils"; +export * from "./paletteUtils"; +export * from "./polarUtils"; +export * from "./scrollUtils"; +export * from "./styleUtils"; diff --git a/packages/react-ui/src/components/ChartsV2/utils/mouseUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/mouseUtils.ts new file mode 100644 index 000000000..4a0fa3d87 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/mouseUtils.ts @@ -0,0 +1,25 @@ +import type { ScaleBand, ScalePoint } from "d3-scale"; + +export function findNearestDataIndex(xScale: ScalePoint, mouseX: number): number { + const domain = xScale.domain(); + let nearestIdx = 0; + let minDist = Infinity; + domain.forEach((cat, idx) => { + const catX = xScale(cat) ?? 0; + const dist = Math.abs(mouseX - catX); + if (dist < minDist) { + minDist = dist; + nearestIdx = idx; + } + }); + return nearestIdx; +} + +export function findBandIndex(xScale: ScaleBand, mouseX: number): number { + const domain = xScale.domain(); + const step = xScale.step(); + if (step === 0) return 0; + const paddingOuter = xScale.paddingOuter() * step; + const index = Math.floor((mouseX - paddingOuter) / step); + return Math.max(0, Math.min(domain.length - 1, index)); +} diff --git a/packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts new file mode 100644 index 000000000..3b3faef8f --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts @@ -0,0 +1,181 @@ +import { useMemo } from "react"; +import invariant from "tiny-invariant"; +import { ChartColorPalette, useTheme } from "../../ThemeProvider"; + +export type ColorPalette = { + name: string; + colors: string[]; +}; + +export type PaletteName = "ocean" | "orchid" | "emerald" | "spectrum" | "sunset" | "vivid"; + +type PaletteMap = Record; + +const colorPalettes: PaletteMap = { + ocean: { + name: "Ocean", + colors: [ + "#0D47A1", + "#1565C0", + "#1976D2", + "#1E88E5", + "#2196F3", + "#42A5F5", + "#64B5F6", + "#90CAF9", + "#BBDEFB", + "#E3F2FD", + "#EFF8FF", + ], + }, + orchid: { + name: "Orchid", + colors: [ + "#3A365B", + "#482E77", + "#552594", + "#631DB0", + "#7014CC", + "#883BD5", + "#A062DD", + "#B88AE6", + "#CFB1EE", + "#E7D8F7", + "#F7EFFF", + ], + }, + emerald: { + name: "Emerald", + colors: [ + "#10451D", + "#155D27", + "#1A7431", + "#208B3A", + "#25A244", + "#2DC653", + "#4AD66D", + "#6EDE8A", + "#92E6A7", + "#B7EFC5", + "#DCFFE5", + ], + }, + spectrum: { + name: "Spectrum", + colors: [ + "#2171BC", + "#2681D7", + "#72A4EB", + "#A0C0F7", + "#C2D4F7", + "#EADDE8", + "#EEB3B1", + "#E99492", + "#E17475", + "#D75259", + "#CB253E", + ], + }, + sunset: { + name: "Sunset", + colors: [ + "#0D0887", + "#42049E", + "#6A00A8", + "#900DA4", + "#B12A90", + "#CC4678", + "#E16462", + "#F1844B", + "#FCA636", + "#FCCE25", + "#FFE06E", + ], + }, + vivid: { + name: "Vivid", + colors: [ + "#FF595E", + "#FF924C", + "#FFCA3A", + "#C5CA30", + "#8AC926", + "#36949D", + "#1982C4", + "#4267AC", + "#565AA0", + "#6A4C93", + "#63438F", + ], + }, +}; + +export type PaletteKey = keyof typeof colorPalettes; + +export const getPalette = (key: PaletteKey): ColorPalette => { + const palette = colorPalettes[key]; + invariant(palette, `Palette ${key} not found`); + return palette; +}; + +export const getAllPalettes = (): ColorPalette[] => { + return Object.values(colorPalettes); +}; + +export const getPaletteKeys = (): PaletteKey[] => { + return Object.keys(colorPalettes) as PaletteKey[]; +}; + +export const getDistributedColors = (colors: string[], dataLength: number): string[] => { + const midIndex = Math.floor(colors.length / 2); + + if (dataLength === 1) { + return [colors[midIndex]!]; + } + + if (dataLength === 2) { + return [colors[midIndex - 1]!, colors[midIndex + 1]!]; + } + + const result: string[] = []; + const offset = Math.floor((dataLength - 1) / 2); + + for (let i = 0; i < dataLength; i++) { + const index = midIndex + (i - offset); + + let actualIndex: number; + if (index < 0) { + actualIndex = colors.length + (index % colors.length); + } else if (index >= colors.length) { + actualIndex = index % colors.length; + } else { + actualIndex = index; + } + + result.push(colors[actualIndex]!); + } + + return result; +}; + +export const useChartPalette = ({ + chartThemeName, + customPalette, + themePaletteName, + dataLength, +}: { + chartThemeName: PaletteName; + customPalette?: string[]; + themePaletteName: keyof ChartColorPalette; + dataLength: number; +}) => { + const { theme } = useTheme(); + const paletteFromTheme = theme[themePaletteName] || theme.defaultChartPalette; + const paletteFromChartTheme = getPalette(chartThemeName); + + const palette = customPalette || paletteFromTheme || paletteFromChartTheme.colors; + + return useMemo(() => { + return getDistributedColors(palette, dataLength); + }, [palette, dataLength]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts new file mode 100644 index 000000000..5cf2e0368 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts @@ -0,0 +1,33 @@ +import type { ChartData } from "../types"; + +export function sortByValueDescending(data: T, dataKey: string): T { + return [...data].sort((a, b) => { + const aVal = Number(a[dataKey]) || 0; + const bVal = Number(b[dataKey]) || 0; + return bVal - aVal; + }) as T; +} + +export function getSliceStyle(index: number, hoveredIndex: number | null): React.CSSProperties { + if (hoveredIndex === null) return {}; + if (index === hoveredIndex) { + return { opacity: 1, filter: "brightness(1.08)" }; + } + return { opacity: 0.4 }; +} + +export function formatPercentage(value: number, total: number): string { + if (total === 0) return "0%"; + return `${((value / total) * 100).toFixed(1)}%`; +} + +export function radarAxisAngle(index: number, total: number): number { + return (2 * Math.PI * index) / total - Math.PI / 2; +} + +export function radarLabelAnchor(angle: number): "start" | "middle" | "end" { + const EPSILON = 0.1; + const cos = Math.cos(angle); + if (Math.abs(cos) < EPSILON) return "middle"; + return cos > 0 ? "start" : "end"; +} diff --git a/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts new file mode 100644 index 000000000..d5dc5cb46 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts @@ -0,0 +1,70 @@ +type ChartData = Array>; + +export type ChartDensity = "compact" | "default" | "spacious"; + +const DENSITY_SPACING: Record = { + compact: 48, + default: 72, + spacious: 96, +}; +const MIN_SINGLE_POINT_WIDTH = 200; + +export const getWidthOfData = ( + data: ChartData, + containerWidth: number, + density: ChartDensity = "default", +) => { + if (data.length === 0) { + return containerWidth; + } + const width = data.length * getWidthOfGroup(density); + + if (containerWidth >= width) { + return containerWidth; + } + + if (data.length === 1) { + return Math.max(width, MIN_SINGLE_POINT_WIDTH); + } + + return width; +}; + +export const findNearestSnapPosition = ( + snapPositions: number[], + currentScroll: number, + direction: "left" | "right", +): number => { + let currentIndex = 0; + for (let i = 0; i < snapPositions.length; i++) { + const snapPosition = snapPositions[i]; + if (snapPosition !== undefined && currentScroll >= snapPosition) { + currentIndex = i; + } else { + break; + } + } + + if (direction === "left") { + return Math.max(0, currentIndex - 1); + } else { + return Math.min(snapPositions.length - 1, currentIndex + 1); + } +}; + +export const getWidthOfGroup = (density: ChartDensity = "default"): number => { + return DENSITY_SPACING[density]; +}; + +export const getSnapPositions = (data: ChartData, density: ChartDensity = "default"): number[] => { + if (data.length === 0) return [0]; + + const positions = [0]; + const groupWidthValue = getWidthOfGroup(density); + + for (let i = 1; i < data.length; i++) { + positions.push(i * groupWidthValue); + } + + return positions; +}; diff --git a/packages/react-ui/src/components/ChartsV2/utils/styleUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/styleUtils.ts new file mode 100644 index 000000000..c76566764 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/styleUtils.ts @@ -0,0 +1,45 @@ +const numberTickFormatter = (value: number) => { + if (typeof value === "number") { + const absValue = Math.abs(value); + + if (absValue >= 1e12) { + return (value / 1e12).toFixed(absValue >= 10e12 ? 0 : 1) + "T"; + } else if (absValue >= 1e9) { + return (value / 1e9).toFixed(absValue >= 10e9 ? 0 : 1) + "B"; + } else if (absValue >= 1e6) { + return (value / 1e6).toFixed(absValue >= 10e6 ? 0 : 1) + "M"; + } else if (absValue >= 1e3) { + return (value / 1e3).toFixed(absValue >= 10e3 ? 0 : 1) + "K"; + } else { + if (value % 1 !== 0) { + return value.toFixed(2); + } + return value.toString(); + } + } + return String(value); +}; + +const DEFAULT_MIN_Y_AXIS_WIDTH = 20; +const DEFAULT_MAX_Y_AXIS_WIDTH = 200; +const DEFAULT_Y_AXIS_PADDING = 10; + +const measureYAxisWidth = ( + ticks: number[], + context: CanvasRenderingContext2D, + options?: { minWidth?: number; maxWidth?: number; padding?: number }, +): number => { + const minWidth = options?.minWidth ?? DEFAULT_MIN_Y_AXIS_WIDTH; + const maxWidth = options?.maxWidth ?? DEFAULT_MAX_Y_AXIS_WIDTH; + const padding = options?.padding ?? DEFAULT_Y_AXIS_PADDING; + + let maxTextWidth = 0; + for (const tick of ticks) { + const w = context.measureText(numberTickFormatter(tick)).width; + if (w > maxTextWidth) maxTextWidth = w; + } + + return Math.max(minWidth, Math.min(maxWidth, Math.ceil(maxTextWidth) + padding)); +}; + +export { measureYAxisWidth, numberTickFormatter }; diff --git a/packages/react-ui/src/components/index.scss b/packages/react-ui/src/components/index.scss index 90ad81d42..a9775b881 100644 --- a/packages/react-ui/src/components/index.scss +++ b/packages/react-ui/src/components/index.scss @@ -46,5 +46,6 @@ @forward "./ToolCall/toolCall.scss"; @forward "./ToolResult/toolResult.scss"; @forward "./Charts/charts.scss"; +@forward "./ChartsV2/chartsV2.scss"; @forward "./Separator/separator.scss"; @forward "./Callout/callout.scss"; diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index 463ba3773..3c1f2db9c 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -10,6 +10,7 @@ export * from "./components/CardHeader"; export * from "./components/Carousel"; export * from "./components/Charts"; export type { ExportChartData } from "./components/Charts/Charts"; +export * from "./components/ChartsV2"; export * from "./components/CheckBoxGroup"; export * from "./components/CheckBoxItem"; export * from "./components/CodeBlock"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 772135c22..408753048 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,7 +122,7 @@ importers: version: 19.2.3(@types/react@19.2.14) eslint-config-next: specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -531,6 +531,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + d3: + specifier: ^7.9.0 + version: 7.9.0 + d3-scale: + specifier: ^4.0.2 + version: 4.0.2 + d3-selection: + specifier: ^3.0.0 + version: 3.0.0 + d3-shape: + specifier: ^3.2.0 + version: 3.2.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -619,6 +631,18 @@ importers: '@storybook/theming': specifier: ^8.5.3 version: 8.6.14(storybook@8.6.14(prettier@3.5.3)) + '@types/d3': + specifier: ^7.4.3 + version: 7.4.3 + '@types/d3-scale': + specifier: ^4.0.9 + version: 4.0.9 + '@types/d3-selection': + specifier: ^3.0.11 + version: 3.0.11 + '@types/d3-shape': + specifier: ^3.1.8 + version: 3.1.8 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -1817,18 +1841,10 @@ packages: resolution: {integrity: sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-array@0.23.2': - resolution: {integrity: sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.2.3': resolution: {integrity: sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.5.2': - resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.14.0': resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1837,10 +1853,6 @@ packages: resolution: {integrity: sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@1.1.0': - resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1853,18 +1865,10 @@ packages: resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@3.0.2': - resolution: {integrity: sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.3.2': resolution: {integrity: sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.6.0': - resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4332,23 +4336,80 @@ packages: '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + '@types/d3-interpolate@3.0.4': resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} '@types/d3-path@3.1.1': resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - '@types/d3-shape@3.1.7': - resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} '@types/d3-time@3.0.4': resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} @@ -4356,6 +4417,15 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -4371,9 +4441,6 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -4383,6 +4450,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -4796,9 +4866,6 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -5381,18 +5448,67 @@ packages: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + d3-format@3.1.0: resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} engines: {node: '>=12'} + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} @@ -5401,10 +5517,30 @@ packages: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + d3-scale@4.0.2: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -5421,6 +5557,20 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -5519,6 +5669,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -5871,10 +6024,6 @@ packages: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-scope@9.1.1: - resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5887,16 +6036,6 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.0.2: - resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - eslint@9.29.0: resolution: {integrity: sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5911,10 +6050,6 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - espree@11.1.1: - resolution: {integrity: sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -5924,10 +6059,6 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -6572,6 +6703,10 @@ packages: hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -8472,6 +8607,9 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rollup@4.43.0: resolution: {integrity: sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -8480,6 +8618,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} @@ -10602,11 +10743,6 @@ snapshots: eslint: 9.29.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.2(jiti@2.6.1))': - dependencies: - eslint: 10.0.2(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.29.0(jiti@2.6.1))': dependencies: eslint: 9.29.0(jiti@2.6.1) @@ -10624,20 +10760,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-array@0.23.2': - dependencies: - '@eslint/object-schema': 3.0.2 - debug: 4.4.3 - minimatch: 10.2.2 - transitivePeerDependencies: - - supports-color - '@eslint/config-helpers@0.2.3': {} - '@eslint/config-helpers@0.5.2': - dependencies: - '@eslint/core': 1.1.0 - '@eslint/core@0.14.0': dependencies: '@types/json-schema': 7.0.15 @@ -10646,10 +10770,6 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/core@1.1.0': - dependencies: - '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 @@ -10668,18 +10788,11 @@ snapshots: '@eslint/object-schema@2.1.6': {} - '@eslint/object-schema@3.0.2': {} - '@eslint/plugin-kit@0.3.2': dependencies: '@eslint/core': 0.15.0 levn: 0.4.1 - '@eslint/plugin-kit@0.6.0': - dependencies: - '@eslint/core': 1.1.0 - levn: 0.4.1 - '@exodus/bytes@1.15.0': optional: true @@ -14020,28 +14133,121 @@ snapshots: '@types/d3-array@3.2.1': {} + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + '@types/d3-color@3.1.3': {} + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.1 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + '@types/d3-ease@3.0.2': {} + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + '@types/d3-interpolate@3.0.4': dependencies: '@types/d3-color': 3.1.3 '@types/d3-path@3.1.1': {} + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + '@types/d3-scale@4.0.9': dependencies: '@types/d3-time': 3.0.4 - '@types/d3-shape@3.1.7': + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 + '@types/d3-time-format@4.0.3': {} + '@types/d3-time@3.0.4': {} '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -14060,8 +14266,6 @@ snapshots: '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 - '@types/esrecurse@4.3.1': {} - '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -14070,6 +14274,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 22.15.32 @@ -14166,22 +14372,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 10.0.2(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -14198,18 +14388,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3 - eslint: 10.0.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.1 @@ -14240,18 +14418,6 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 10.0.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.1 @@ -14281,17 +14447,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 10.0.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.29.0(jiti@2.6.1)) @@ -14582,13 +14737,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -14927,7 +15075,7 @@ snapshots: browserslist@4.25.0: dependencies: - caniuse-lite: 1.0.30001723 + caniuse-lite: 1.0.30001778 electron-to-chromium: 1.5.170 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.0) @@ -15240,18 +15388,80 @@ snapshots: dependencies: internmap: 2.0.3 + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + d3-color@3.1.0: {} + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + d3-ease@3.0.1: {} + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + d3-format@3.1.0: {} + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 d3-path@3.1.0: {} + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + d3-scale@4.0.2: dependencies: d3-array: 3.2.4 @@ -15260,6 +15470,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -15274,6 +15486,56 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.0 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + damerau-levenshtein@1.0.8: {} data-urls@7.0.0: @@ -15357,6 +15619,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -15698,26 +15964,6 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.1.6 - eslint: 10.0.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@10.0.2(jiti@2.6.1)) - globals: 16.4.0 - typescript-eslint: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.6 @@ -15750,21 +15996,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.2(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 10.0.2(jiti@2.6.1) - get-tsconfig: 4.10.1 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -15780,17 +16011,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -15802,35 +16022,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 10.0.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -15860,25 +16051,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@10.0.2(jiti@2.6.1)): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.1 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 10.0.2(jiti@2.6.1) - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - eslint-plugin-jsx-a11y@6.10.2(eslint@9.29.0(jiti@2.6.1)): dependencies: aria-query: 5.3.2 @@ -15908,17 +16080,6 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.29.0(jiti@2.6.1)) - eslint-plugin-react-hooks@7.0.1(eslint@10.0.2(jiti@2.6.1)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - eslint: 10.0.2(jiti@2.6.1) - hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color - eslint-plugin-react-hooks@7.0.1(eslint@9.29.0(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 @@ -15934,28 +16095,6 @@ snapshots: dependencies: eslint: 9.29.0(jiti@2.6.1) - eslint-plugin-react@7.37.5(eslint@10.0.2(jiti@2.6.1)): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 10.0.2(jiti@2.6.1) - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.6 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.29.0(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -16003,56 +16142,12 @@ snapshots: esrecurse: 4.3.0 estraverse: 5.3.0 - eslint-scope@9.1.1: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 - esrecurse: 4.3.0 - estraverse: 5.3.0 - eslint-visitor-keys@3.4.3: {} eslint-visitor-keys@4.2.1: {} eslint-visitor-keys@5.0.1: {} - eslint@10.0.2(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.2 - '@eslint/config-helpers': 0.5.2 - '@eslint/core': 1.1.0 - '@eslint/plugin-kit': 0.6.0 - '@humanfs/node': 0.16.6 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.1 - eslint-visitor-keys: 5.0.1 - espree: 11.1.1 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - eslint@9.29.0(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.6.1)) @@ -16101,22 +16196,12 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - espree@11.1.1: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - esprima@4.0.1: {} esquery@1.6.0: dependencies: estraverse: 5.3.0 - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -16891,6 +16976,10 @@ snapshots: hyphenate-style-name@1.1.0: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -18415,7 +18504,7 @@ snapshots: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001723 + caniuse-lite: 1.0.30001778 postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -18442,7 +18531,7 @@ snapshots: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001723 + caniuse-lite: 1.0.30001778 postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -19618,6 +19707,8 @@ snapshots: dependencies: glob: 7.2.3 + robust-predicates@3.0.2: {} + rollup@4.43.0: dependencies: '@types/estree': 1.0.7 @@ -19648,6 +19739,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + rxjs@7.8.1: dependencies: tslib: 2.8.1 @@ -20305,17 +20398,6 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - typescript-eslint@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) @@ -20552,7 +20634,7 @@ snapshots: '@types/d3-ease': 3.0.2 '@types/d3-interpolate': 3.0.4 '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.7 + '@types/d3-shape': 3.1.8 '@types/d3-time': 3.0.4 '@types/d3-timer': 3.0.2 d3-array: 3.2.4