From eae1bd18f38a697ffdf504f207711913598b6b20 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Fri, 6 Mar 2026 15:19:33 +0530 Subject: [PATCH 01/23] feat(charts): add D3-based AreaChart component (ChartsV2) Replace Recharts dependency with a pure D3 + React JSX approach for area charts. D3 handles computation only (scales, paths, stacking); React owns all SVG rendering via JSX. Key features: - Stacked and overlapping area modes via d3.stack() - 6 color palettes with distributed color selection - Hover crosshair with active dots + @floating-ui portal tooltip - Legend with expand/collapse and click-to-toggle series visibility - Horizontal scroll with snap positions for dense datasets - SVG clipPath to prevent area overflow - CSS stroke-dasharray animation for initial draw - Touch event support for mobile crosshair/tooltip - Dynamic height/width props (number or CSS string like "100%") - Adaptive Y-axis tick density based on available chart height - 16 Storybook stories covering all data variations New dependencies: d3, d3-scale, d3-selection, d3-shape (+ @types) Made-with: Cursor --- packages/react-ui/package.json | 8 + .../ChartsV2/D3AreaChart/D3AreaChart.tsx | 434 +++++++++ .../components/ChartsV2/D3AreaChart/DESIGN.md | 877 ++++++++++++++++++ .../ChartsV2/D3AreaChart/d3AreaChart.scss | 106 +++ .../components/ChartsV2/D3AreaChart/index.ts | 2 + .../ChartsV2/D3AreaChart/parts/AreaSeries.tsx | 147 +++ .../ChartsV2/D3AreaChart/parts/Crosshair.tsx | 87 ++ .../D3AreaChart/parts/GradientDefs.tsx | 44 + .../ChartsV2/D3AreaChart/parts/Grid.tsx | 29 + .../ChartsV2/D3AreaChart/parts/XAxis.tsx | 87 ++ .../ChartsV2/D3AreaChart/parts/YAxis.tsx | 33 + .../stories/d3AreaChart.stories.tsx | 609 ++++++++++++ .../components/ChartsV2/D3AreaChart/types.ts | 26 + .../src/components/ChartsV2/chartsV2.scss | 4 + .../src/components/ChartsV2/hooks/index.ts | 5 + .../hooks/useCanvasContextForLabelSize.ts | 15 + .../ChartsV2/hooks/useContainerSize.ts | 36 + .../ChartsV2/hooks/useTransformedKeys.ts | 18 + .../ChartsV2/hooks/useXAxisHeight.ts | 65 ++ .../ChartsV2/hooks/useYAxisWidth.ts | 58 ++ .../react-ui/src/components/ChartsV2/index.ts | 2 + .../shared/DefaultLegend/DefaultLegend.tsx | 150 +++ .../shared/DefaultLegend/defaultLegend.scss | 97 ++ .../DefaultLegend/hooks/useDefaultLegend.ts | 102 ++ .../shared/LabelTooltip/LabelTooltip.tsx | 85 ++ .../PortalTooltip/CustomTooltipContent.tsx | 166 ++++ .../shared/PortalTooltip/FloatingUIPortal.tsx | 64 ++ .../shared/PortalTooltip/portalTooltip.scss | 156 ++++ .../shared/PortalTooltip/utils/index.ts | 23 + .../ScrollButtonsHorizontal.tsx | 62 ++ .../scrollButtonsHorizontal.scss | 34 + .../src/components/ChartsV2/shared/index.ts | 5 + .../src/components/ChartsV2/types/common.ts | 9 + .../src/components/ChartsV2/types/index.ts | 1 + .../components/ChartsV2/utils/dataUtils.ts | 62 ++ .../src/components/ChartsV2/utils/index.ts | 4 + .../components/ChartsV2/utils/paletteUtils.ts | 127 +++ .../components/ChartsV2/utils/scrollUtils.ts | 61 ++ .../components/ChartsV2/utils/styleUtils.ts | 23 + packages/react-ui/src/components/index.scss | 1 + packages/react-ui/src/cssUtils.scss | 129 +-- pnpm-lock.yaml | 796 +++++++++------- 42 files changed, 4387 insertions(+), 462 deletions(-) create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/YAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/types.ts create mode 100644 packages/react-ui/src/components/ChartsV2/chartsV2.scss create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useCanvasContextForLabelSize.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts create mode 100644 packages/react-ui/src/components/ChartsV2/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/defaultLegend.scss create mode 100644 packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts create mode 100644 packages/react-ui/src/components/ChartsV2/shared/LabelTooltip/LabelTooltip.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/FloatingUIPortal.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss create mode 100644 packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/utils/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss create mode 100644 packages/react-ui/src/components/ChartsV2/shared/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/types/common.ts create mode 100644 packages/react-ui/src/components/ChartsV2/types/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts create mode 100644 packages/react-ui/src/components/ChartsV2/utils/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts create mode 100644 packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts create mode 100644 packages/react-ui/src/components/ChartsV2/utils/styleUtils.ts diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index c7a2cb812..dbf966920 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -80,6 +80,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", @@ -108,6 +112,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..9d5aab7ce --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx @@ -0,0 +1,434 @@ +import clsx from "clsx"; +import { scaleLinear, scalePoint } from "d3-scale"; +import { pointer } from "d3-selection"; +import { stack, stackOffsetNone, stackOrderNone } from "d3-shape"; +import React, { useCallback, useMemo, useRef, useState } from "react"; + +import { useContainerSize } from "../hooks/useContainerSize"; +import { useTransformedKeys } from "../hooks/useTransformedKeys"; +import { useXAxisHeight } from "../hooks/useXAxisHeight"; +import { useYAxisWidth } from "../hooks/useYAxisWidth"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { CustomTooltipContent } from "../shared/PortalTooltip/CustomTooltipContent"; +import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +import { get2dChartConfig, getDataKeys, getLegendItems } from "../utils/dataUtils"; +import { useChartPalette } from "../utils/paletteUtils"; +import { + findNearestSnapPosition, + getSnapPositions, + getWidthOfData, + getWidthOfGroup, +} from "../utils/scrollUtils"; + +import { AreaSeries } from "./parts/AreaSeries"; +import { Crosshair } from "./parts/Crosshair"; +import { GradientDefs } from "./parts/GradientDefs"; +import { Grid } from "./parts/Grid"; +import { XAxis } from "./parts/XAxis"; +import { YAxis } from "./parts/YAxis"; + +import type { D3AreaChartData, D3AreaChartProps } from "./types"; + +const MARGIN_TOP = 10; +const DEFAULT_CHART_HEIGHT = 296; + +export function D3AreaChart({ + 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, +}: D3AreaChartProps) { + const containerRef = useRef(null); + const mainContainerRef = useRef(null); + const chartId = useMemo(() => crypto.randomUUID(), []); + + const { width: containerWidth, height: containerHeight } = useContainerSize( + containerRef, + fixedWidth, + height, + ); + + const catKey = String(categoryKey); + const allDataKeys = useMemo(() => getDataKeys(data, catKey), [data, catKey]); + + const [hiddenSeries, setHiddenSeries] = useState>(new Set()); + 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 widthOfGroup = getWidthOfGroup(data); + const tickVariant = containerWidth < 300 ? ("singleLine" as const) : tickVariantProp; + const xAxisHeight = useXAxisHeight(data, catKey, tickVariant, widthOfGroup); + const { yAxisWidth } = useYAxisWidth(data, dataKeys); + + const effectiveYAxisWidth = showYAxis ? yAxisWidth : 0; + const chartConfig = useMemo( + () => get2dChartConfig(allDataKeys, colors, transformedKeys, undefined, icons as any), + [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 dataWidth = useMemo( + () => getWidthOfData(data, containerWidth - effectiveYAxisWidth), + [data, containerWidth, effectiveYAxisWidth], + ); + const needsScroll = dataWidth > containerWidth - effectiveYAxisWidth; + + const resolvedHeight = + typeof height === "number" + ? height + : containerHeight > 0 + ? containerHeight + : DEFAULT_CHART_HEIGHT; + const chartInnerHeight = resolvedHeight - MARGIN_TOP - xAxisHeight; + const totalHeight = resolvedHeight; + const svgWidth = needsScroll ? dataWidth : containerWidth - effectiveYAxisWidth; + + const xScale = useMemo(() => { + return scalePoint() + .domain(data.map((d) => String(d[catKey]))) + .range([widthOfGroup / 2, svgWidth - widthOfGroup / 2]) + .padding(0); + }, [data, catKey, svgWidth, widthOfGroup]); + + const yScale = useMemo(() => { + let maxVal = 0; + + if (stacked && dataKeys.length > 0) { + const stackGenerator = stack>() + .keys(dataKeys) + .order(stackOrderNone) + .offset(stackOffsetNone); + const stackedData = stackGenerator(data as Iterable<{ [key: string]: number }>); + 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, stacked, chartInnerHeight]); + + const [hoveredIndex, setHoveredIndex] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const svgElement = event.currentTarget; + const [mouseX] = pointer(event.nativeEvent, svgElement); + + 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; + } + }); + + setHoveredIndex(nearestIdx); + + const containerRect = mainContainerRef.current?.getBoundingClientRect(); + if (containerRect) { + setMousePos({ + x: event.clientX - containerRect.left + (mainContainerRef.current?.scrollLeft ?? 0), + y: event.clientY - containerRect.top, + }); + } + }, + [xScale], + ); + + const handleMouseLeave = useCallback(() => { + setHoveredIndex(null); + setMousePos(null); + }, []); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const svgElement = event.currentTarget; + const svgRect = svgElement.getBoundingClientRect(); + const mouseX = touch.clientX - svgRect.left; + + 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; + } + }); + + setHoveredIndex(nearestIdx); + + const containerRect = mainContainerRef.current?.getBoundingClientRect(); + if (containerRect) { + setMousePos({ + x: touch.clientX - containerRect.left + (mainContainerRef.current?.scrollLeft ?? 0), + y: touch.clientY - containerRect.top, + }); + } + }, + [xScale], + ); + + const handleTouchEnd = useCallback(() => { + setHoveredIndex(null); + setMousePos(null); + }, []); + + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(needsScroll); + + const handleScroll = useCallback(() => { + const el = mainContainerRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 1); + setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1); + }, []); + + const scrollTo = useCallback( + (direction: "left" | "right") => { + const el = mainContainerRef.current; + if (!el) return; + const snaps = getSnapPositions(data); + const idx = findNearestSnapPosition(snaps, el.scrollLeft, direction); + const target = snaps[idx] ?? 0; + el.scrollTo({ left: target, behavior: "smooth" }); + }, + [data], + ); + + const legendItems = useMemo( + () => getLegendItems(allDataKeys, colors, icons as any), + [allDataKeys, colors, icons], + ); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + + const handleLegendItemClick = useCallback( + (key: string) => { + setHiddenSeries((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + if (next.size < allDataKeys.length - 1) { + next.add(key); + } + } + return next; + }); + }, + [allDataKeys.length], + ); + + const tooltipPayload = useMemo(() => { + if (hoveredIndex === null || hoveredIndex >= data.length) return null; + const row = data[hoveredIndex]!; + return { + active: true, + label: String(row[catKey]), + payload: dataKeys.map((key) => ({ + name: key, + dataKey: key, + value: Number(row[key]) || 0, + color: chartConfig[key]?.color ?? "#000", + payload: row, + })), + }; + }, [hoveredIndex, data, dataKeys, catKey, chartConfig]); + + if (!data || data.length === 0) { + return null; + } + + const containerStyle = useMemo(() => { + const s: Record = { ...chartStyle }; + if (typeof fixedWidth === "string") s["width"] = fixedWidth; + if (typeof height === "string") s["height"] = height; + return s; + }, [chartStyle, fixedWidth, height]); + + return ( + +
+
+ {showYAxis && ( +
+ + + + + +
+ )} + +
+ + + + {grid && } + + + + + + + +
+
+ + scrollTo("left")} + onScrollRight={() => scrollTo("right")} + /> + + {showLegend && ( + + )} + + {tooltipPayload && mousePos && ( + + )} +
+
+ ); +} 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..7259d7d01 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md @@ -0,0 +1,877 @@ +# 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 +│ │ ├── CustomTooltipContent.tsx ← adapted for D3 payload shape +│ │ ├── FloatingUIPortal.tsx +│ │ ├── 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 to match the existing `CustomTooltipContent` contract: + +```typescript +// Payload shape for tooltip +interface TooltipPayload { + active: boolean; + label: string; // category value at hovered index + coordinate: { x: number; y: number }; + payload: Array<{ + name: string; // series key + dataKey: string; // series key + value: number; // series value at hovered index + color: string; // series color + payload: DataRow; // full data row + }>; +} +``` + +The tooltip is positioned using the mouse event coordinates relative to the chart container, then `FloatingUIPortal` handles placement and flipping. + +**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**: Decouple `CustomTooltipContent` from `useChart()` recharts context. Instead, accept tooltip data via props. Keep `FloatingUIPortal` as-is. +- **Files**: `CustomTooltipContent.tsx`, `FloatingUIPortal.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 +│ │ +│ │ +│
│ +└─────────────────────────────────────────────┘ +``` + +--- + +## 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` → `FloatingUIPortal` | 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..bdca18216 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss @@ -0,0 +1,106 @@ +@use "../../../cssUtils" as cssUtils; + +.openui-d3-area-chart { + &-container { + width: 100%; + position: relative; + } + + &-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; + } +} + +.openui-d3-area-chart-grid line { + stroke: cssUtils.$border-default; + stroke-dasharray: 3 3; +} + +.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; +} + +.openui-d3-area-chart-crosshair-line { + stroke: cssUtils.$border-default; + stroke-width: 1; + stroke-dasharray: 4 4; +} + +.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-active-dot-outer { + fill: cssUtils.$foreground; + stroke: cssUtils.$border-default; + stroke-width: 1; +} + +.openui-d3-area-chart-active-dot-inner { + stroke: none; +} + +.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..29a7db333 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/index.ts @@ -0,0 +1,2 @@ +export { D3AreaChart } from "./D3AreaChart"; +export type { D3AreaChartData, D3AreaChartVariant, D3AreaChartProps } 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..a616daf72 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx @@ -0,0 +1,147 @@ +import { + area as d3Area, + line as d3Line, + stack, + stackOrderNone, + stackOffsetNone, + curveLinear, + curveMonotoneX, + curveStepAfter, +} from "d3-shape"; +import type { ScaleLinear, ScalePoint } from "d3-scale"; +import React, { useMemo, useRef, useEffect } from "react"; +import { D3AreaChartVariant } from "../types"; + +const curveMap = { + linear: curveLinear, + natural: curveMonotoneX, + step: curveStepAfter, +}; + +interface AreaSeriesProps { + data: Array>; + dataKeys: string[]; + xScale: ScalePoint; + yScale: ScaleLinear; + variant: D3AreaChartVariant; + stacked: boolean; + categoryKey: string; + transformedKeys: Record; + colors: Record; + chartId: string; + isAnimationActive?: boolean; +} + +export const AreaSeries: React.FC = ({ + data, + dataKeys, + xScale, + yScale, + variant, + stacked, + categoryKey, + transformedKeys, + colors, + chartId, + isAnimationActive, +}) => { + const curve = curveMap[variant]; + + const seriesPaths = useMemo(() => { + if (stacked) { + const stackGenerator = stack>() + .keys(dataKeys) + .order(stackOrderNone) + .offset(stackOffsetNone); + + const stackedData = stackGenerator(data as Iterable<{ [key: string]: number }>); + + 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, stacked, 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/Crosshair.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx new file mode 100644 index 000000000..2dca206fc --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx @@ -0,0 +1,87 @@ +import type { ScaleLinear, ScalePoint } from "d3-scale"; +import { stack, stackOrderNone, stackOffsetNone } from "d3-shape"; +import React, { useMemo } from "react"; + +interface CrosshairProps { + hoveredIndex: number | null; + xScale: ScalePoint; + yScale: ScaleLinear; + data: Array>; + dataKeys: string[]; + categoryKey: string; + colors: Record; + stacked: boolean; + chartHeight: number; +} + +export const Crosshair: React.FC = ({ + hoveredIndex, + xScale, + yScale, + data, + dataKeys, + categoryKey, + colors, + stacked, + chartHeight, +}) => { + const stackedData = 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]); + + 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) => { + let yValue: number; + if (stacked && stackedData) { + const series = stackedData[seriesIndex]; + const point = series?.[hoveredIndex]; + yValue = point ? point[1] : 0; + } else { + yValue = Number(row[key]) || 0; + } + + const y = yScale(yValue); + const color = colors[key] ?? "#000"; + + 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..74d7a4039 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +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/parts/Grid.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx new file mode 100644 index 000000000..fb9e77095 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx @@ -0,0 +1,29 @@ +import type { ScaleLinear } from "d3-scale"; +import React from "react"; + +interface GridProps { + yScale: ScaleLinear; + chartWidth: number; + chartHeight: number; +} + +const MIN_TICK_SPACING = 40; + +export const Grid: React.FC = ({ yScale, chartWidth, chartHeight }) => { + 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/D3AreaChart/parts/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx new file mode 100644 index 000000000..d3c5d613d --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx @@ -0,0 +1,87 @@ +import type { ScalePoint } from "d3-scale"; +import React, { useRef } from "react"; +import { LabelTooltip } from "../../shared/LabelTooltip/LabelTooltip"; +import { XAxisTickVariant } from "../../types"; + +interface XAxisProps { + scale: ScalePoint; + data: Array>; + categoryKey: string; + tickVariant: XAxisTickVariant; + widthOfGroup: number; + labelHeight: number; +} + +export const XAxis: React.FC = ({ + scale, + data, + categoryKey, + tickVariant, + widthOfGroup, + labelHeight, +}) => { + const domain = scale.domain(); + + return ( + + {domain.map((category) => { + const x = scale(category) ?? 0; + const label = String(category); + + return ( + + + + ); + })} + + ); +}; + +const XAxisLabel: React.FC<{ + label: string; + tickVariant: XAxisTickVariant; + width: number; +}> = ({ label, tickVariant, width }) => { + const labelRef = useRef(null); + const isTruncated = useIsTruncated(labelRef); + + const className = + tickVariant === "multiLine" + ? "openui-d3-area-chart-x-tick-multi-line" + : "openui-d3-area-chart-x-tick-single-line"; + + return ( + +
+ {label} +
+
+ ); +}; + +function useIsTruncated(ref: React.RefObject): boolean { + const [truncated, setTruncated] = React.useState(false); + + React.useEffect(() => { + const el = ref.current; + if (!el) return; + setTruncated(el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight); + }); + + return truncated; +} diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/YAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/YAxis.tsx new file mode 100644 index 000000000..dbee90de1 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/YAxis.tsx @@ -0,0 +1,33 @@ +import type { ScaleLinear } from "d3-scale"; +import React from "react"; +import { numberTickFormatter } from "../../utils/styleUtils"; + +interface YAxisProps { + scale: ScaleLinear; + width: number; + chartHeight: number; +} + +const MIN_TICK_SPACING = 40; + +export const YAxis: React.FC = ({ scale, width, chartHeight }) => { + 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/D3AreaChart/stories/d3AreaChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx new file mode 100644 index 000000000..0cd9af1d3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx @@ -0,0 +1,609 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + Calendar, + Globe, + Laptop, + Monitor, + Smartphone, + TabletSmartphone, + Tv, + Watch, +} from "lucide-react"; +import { useState } from "react"; +import { Card } from "../../../Card"; +import { D3AreaChart } from "../D3AreaChart"; +import type { D3AreaChartProps, D3AreaChartData } 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}"`} + + + )} +
+
+ + + + +
+ ); + }, +}; 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..116efa782 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/types.ts @@ -0,0 +1,26 @@ +import { PaletteName } from "../utils/paletteUtils"; +import { XAxisTickVariant } from "../types"; + +export type D3AreaChartData = Array>; + +export type D3AreaChartVariant = "linear" | "natural" | "step"; + +export interface D3AreaChartProps { + data: T; + categoryKey: keyof T[number]; + theme?: PaletteName; + customPalette?: string[]; + variant?: D3AreaChartVariant; + tickVariant?: XAxisTickVariant; + stacked?: boolean; + grid?: boolean; + legend?: boolean; + icons?: Partial>; + isAnimationActive?: boolean; + showYAxis?: boolean; + xAxisLabel?: React.ReactNode; + yAxisLabel?: React.ReactNode; + className?: string; + height?: number | string; + width?: number | string; +} 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..fb0843e94 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/chartsV2.scss @@ -0,0 +1,4 @@ +@forward "./D3AreaChart/d3AreaChart"; +@forward "./shared/PortalTooltip/portalTooltip"; +@forward "./shared/DefaultLegend/defaultLegend"; +@forward "./shared/ScrollButtonsHorizontal/scrollButtonsHorizontal"; 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..5376e4e3a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./useContainerSize"; +export * from "./useYAxisWidth"; +export * from "./useXAxisHeight"; +export * from "./useTransformedKeys"; +export * from "./useCanvasContextForLabelSize"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useCanvasContextForLabelSize.ts b/packages/react-ui/src/components/ChartsV2/hooks/useCanvasContextForLabelSize.ts new file mode 100644 index 000000000..ed5811e21 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useCanvasContextForLabelSize.ts @@ -0,0 +1,15 @@ +import { useMemo } from "react"; +import { useTheme } from "../../ThemeProvider"; + +export const useCanvasContextForLabelSize = () => { + const { theme: userTheme } = useTheme(); + + return useMemo(() => { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + + const font = userTheme.textLabelXs ?? "400 10px/12px Inter"; + context.font = font; + return context; + }, [userTheme.textLabelXs]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts b/packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts new file mode 100644 index 000000000..7027dc1b2 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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]); + + return { + width: numericWidth ?? size.width, + height: numericHeight ?? (typeof fixedHeight === "string" ? size.height : 0), + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts b/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts new file mode 100644 index 000000000..964b4d265 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts @@ -0,0 +1,18 @@ +import { useMemo, useRef } from "react"; + +export const useTransformedKeys = (keys: string[]) => { + const cacheRef = useRef>({}); + + return useMemo(() => { + return keys.reduce( + (acc, key) => { + if (!cacheRef.current[key]) { + cacheRef.current[key] = crypto.randomUUID(); + } + acc[key] = cacheRef.current[key]; + return acc; + }, + {} as Record, + ); + }, [keys]); +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts b/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts new file mode 100644 index 000000000..06bf09479 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts @@ -0,0 +1,65 @@ +import { useMemo } from "react"; +import { useTheme } from "../../ThemeProvider"; +import { XAxisTickVariant } from "../types"; + +const DEFAULT_HEIGHT = 30; + +export const useXAxisHeight = ( + data: Record[], + categoryKey: string, + tickVariant: XAxisTickVariant, + widthOfGroup = 70, +) => { + const { theme: userTheme } = useTheme(); + + const maxLabelHeight = useMemo(() => { + if (typeof window === "undefined" || !data || data.length === 0) { + return DEFAULT_HEIGHT; + } + + const largestLabel = data.reduce((max, item) => { + const label = String(item[categoryKey]); + if (max.length < label.length) { + return label; + } + return max; + }, ""); + + const [div1, div2, div3] = [ + document.createElement("div"), + document.createElement("div"), + document.createElement("div"), + ]; + + div1.style.font = userTheme.textLabelXs ?? ""; + div1.style.letterSpacing = userTheme.textLabelXsLetterSpacing ?? ""; + div1.style.opacity = "0"; + div1.style.pointerEvents = "none"; + + div2.innerText = largestLabel; + div3.innerText = "a"; + div1.append(div2, div3); + + div1.style.width = `${widthOfGroup}px`; + div1.style.maxWidth = `${widthOfGroup}px`; + div1.style.wordBreak = "break-word"; + div1.style.position = "absolute"; + div1.style.visibility = "hidden"; + + document.body.append(div1); + + const largestLabelHeight = Math.min( + div2.getBoundingClientRect().height, + div3.getBoundingClientRect().height * 3, + ); + div1.remove(); + + return largestLabelHeight; + }, [data, categoryKey, tickVariant, widthOfGroup]); + + if (tickVariant === "multiLine") { + return Math.max(maxLabelHeight + 13, DEFAULT_HEIGHT); + } else { + return DEFAULT_HEIGHT; + } +}; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts b/packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts new file mode 100644 index 000000000..65052764e --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { numberTickFormatter } from "../utils/styleUtils"; +import { useCanvasContextForLabelSize } from "./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/index.ts b/packages/react-ui/src/components/ChartsV2/index.ts new file mode 100644 index 000000000..d544d4c02 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/index.ts @@ -0,0 +1,2 @@ +export { D3AreaChart } from "./D3AreaChart"; +export type { D3AreaChartData, D3AreaChartVariant, D3AreaChartProps } from "./D3AreaChart/types"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx new file mode 100644 index 000000000..0f5c40ee5 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx @@ -0,0 +1,150 @@ +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/DefaultLegend/defaultLegend.scss b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/defaultLegend.scss new file mode 100644 index 000000000..3e3e23f71 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/DefaultLegend/hooks/useDefaultLegend.ts b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts new file mode 100644 index 000000000..b6a882805 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import { useCanvasContextForLabelSize } from "../../../hooks/useCanvasContextForLabelSize"; +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/LabelTooltip/LabelTooltip.tsx b/packages/react-ui/src/components/ChartsV2/shared/LabelTooltip/LabelTooltip.tsx new file mode 100644 index 000000000..46e7bae31 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/PortalTooltip/CustomTooltipContent.tsx b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx new file mode 100644 index 000000000..703d7028f --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx @@ -0,0 +1,166 @@ +import clsx from "clsx"; +import React, { memo, useEffect, useMemo, useState } from "react"; +import { FloatingUIPortal } from "./FloatingUIPortal"; +import { tooltipNumberFormatter } from "./utils"; + +interface TooltipItem { + name: string; + dataKey: string; + value: number; + color: string; + payload: Record; +} + +interface CustomTooltipContentProps { + active: boolean; + label: string; + items: TooltipItem[]; + position: { x: number; y: number }; + chartId: string; + portalContainer?: React.RefObject; + parentRef: React.RefObject; + className?: string; + chartStyle?: React.CSSProperties; +} + +function CustomTooltipContentRender({ + active, + label, + items, + position, + chartId, + portalContainer, + parentRef, + className, + chartStyle, +}: CustomTooltipContentProps) { + const [forcefullyHideTooltip, setForcefullyHideTooltip] = useState(false); + const [parentScrollPosition, setParentScrollPosition] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + + const isGreaterThanTen = items.length > 10; + const remainingItems = isGreaterThanTen ? items.length - 5 : 0; + + useEffect(() => { + const parent = parentRef.current; + if (!parent) return; + + const touchHandler = (e: TouchEvent) => { + for (let i = 0; i < e.targetTouches.length; i++) { + const target = e.targetTouches[i]!.target as HTMLElement; + if (!parent.contains(target)) { + setForcefullyHideTooltip(true); + return; + } + } + setForcefullyHideTooltip(false); + }; + document.body.addEventListener("touchstart", touchHandler); + + const scrollHandler = () => { + setParentScrollPosition({ + x: parent.scrollLeft, + y: parent.scrollTop, + width: parent.clientWidth, + height: parent.clientHeight, + }); + }; + + parent.addEventListener("scroll", scrollHandler); + setParentScrollPosition({ + x: parent.scrollLeft, + y: parent.scrollTop, + width: parent.clientWidth, + height: parent.clientHeight, + }); + + return () => { + document.body.removeEventListener("touchstart", touchHandler); + parent.removeEventListener("scroll", scrollHandler); + }; + }, [parentRef]); + + if (!active || items.length === 0 || forcefullyHideTooltip) { + return null; + } + + if ( + parentScrollPosition.x > position.x || + parentScrollPosition.y > position.y || + parentScrollPosition.width + parentScrollPosition.x < position.x || + parentScrollPosition.height + parentScrollPosition.y < position.y + ) { + return null; + } + + const displayItems = isGreaterThanTen ? items.slice(0, 5) : items; + const isTwoItemsLayout = items.length <= 2; + + const tooltipContent = ( +
+
{label}
+
+
+ {displayItems.map((item, index) => ( +
+
+
+
+ {item.name} +
+ + {tooltipNumberFormatter(item.value)} + +
+
+
+ ))} +
+ {isGreaterThanTen &&
} + {isGreaterThanTen && ( +
+ Click to view all {remainingItems} +
+ )} +
+ ); + + return ( + + {tooltipContent} + + ); +} + +export const CustomTooltipContent = memo(CustomTooltipContentRender); +CustomTooltipContent.displayName = "CustomTooltipContent"; + +export type { TooltipItem, CustomTooltipContentProps }; diff --git a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/FloatingUIPortal.tsx b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/FloatingUIPortal.tsx new file mode 100644 index 000000000..500b24685 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/FloatingUIPortal.tsx @@ -0,0 +1,64 @@ +import type { Placement } from "@floating-ui/react-dom"; +import { autoUpdate, flip, hide, offset, useFloating } from "@floating-ui/react-dom"; +import clsx from "clsx"; +import React, { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { useTheme } from "../../../ThemeProvider"; + +interface FloatingUIPortalProps { + children: React.ReactNode; + className?: string; + chartId?: string; + portalContainer?: React.RefObject; + position?: Partial<{ x: number; y: number }>; + placement?: Placement; + offsetDistance?: number; +} + +export const FloatingUIPortal: React.FC = ({ + children, + className = "", + chartId, + portalContainer, + position, + placement = "right-start", + offsetDistance = 20, +}) => { + const { refs, floatingStyles, update } = useFloating({ + placement, + middleware: [offset(offsetDistance), flip(), hide()], + whileElementsMounted: autoUpdate, + }); + + const { portalThemeClassName } = useTheme(); + + useEffect(() => { + if (position) { + update(); + } + }, [position, update]); + + return ( + <> +
+ {createPortal( +
+ {children} +
, + portalContainer?.current || document.body, + )} + + ); +}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss new file mode 100644 index 000000000..9baa64573 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss @@ -0,0 +1,156 @@ +@use "../../../../cssUtils.scss" 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/PortalTooltip/utils/index.ts b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/utils/index.ts new file mode 100644 index 000000000..9de8b1951 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx b/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx new file mode 100644 index 000000000..66d189cac --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss b/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss new file mode 100644 index 000000000..cb6300473 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss @@ -0,0 +1,34 @@ +@use "../../../../cssUtils.scss" 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/index.ts b/packages/react-ui/src/components/ChartsV2/shared/index.ts new file mode 100644 index 000000000..2656a98ef --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/index.ts @@ -0,0 +1,5 @@ +export { LabelTooltip, LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; +export { DefaultLegend } from "./DefaultLegend/DefaultLegend"; +export { ScrollButtonsHorizontal } from "./ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +export { CustomTooltipContent } from "./PortalTooltip/CustomTooltipContent"; +export { FloatingUIPortal } from "./PortalTooltip/FloatingUIPortal"; 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..b630e1b22 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/types/common.ts @@ -0,0 +1,9 @@ +export interface LegendItem { + key: string; + label: string; + color: string; + icon?: React.ComponentType; + percentage?: number; +} + +export type XAxisTickVariant = "singleLine" | "multiLine"; 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/dataUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts new file mode 100644 index 000000000..22f05619d --- /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..b1f2a9b38 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./paletteUtils"; +export * from "./dataUtils"; +export * from "./styleUtils"; +export * from "./scrollUtils"; 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..d65b82290 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts @@ -0,0 +1,127 @@ +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/scrollUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts new file mode 100644 index 000000000..adee0da4a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts @@ -0,0 +1,61 @@ +type ChartData = Array>; + +const ELEMENT_SPACING = 72; + +export const getWidthOfData = (data: ChartData, containerWidth: number) => { + if (data.length === 0) { + return containerWidth; + } + const width = data.length * getWidthOfGroup(data); + + if (containerWidth >= width) { + return containerWidth; + } + + if (data.length === 1) { + const minSingleDataWidth = 200; + return Math.max(width, minSingleDataWidth); + } + + 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 = (data: ChartData) => { + if (data.length === 0) return 200; + return ELEMENT_SPACING; +}; + +export const getSnapPositions = (data: ChartData): number[] => { + if (data.length === 0) return [0]; + + const positions = [0]; + const groupWidthValue = getWidthOfGroup(data); + + 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..ac2792f27 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/styleUtils.ts @@ -0,0 +1,23 @@ +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); +}; + +export { 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/cssUtils.scss b/packages/react-ui/src/cssUtils.scss index 00e4b2f1c..12aa0c6c1 100644 --- a/packages/react-ui/src/cssUtils.scss +++ b/packages/react-ui/src/cssUtils.scss @@ -45,18 +45,9 @@ $text-alert-inverted: var(--openui-text-alert-inverted, oklch(0.973 0.069 103.19 $text-danger-primary: var(--openui-text-danger-primary, oklch(0.505 0.19 27.518 / 1)); $text-danger-secondary: var(--openui-text-danger-secondary, oklch(0.711 0.166 22.216 / 1)); $text-danger-tertiary: var(--openui-text-danger-tertiary, oklch(0.808 0.103 19.571 / 1)); -$text-danger-inverted-primary: var( - --openui-text-danger-inverted-primary, - oklch(0.982 0.009 17.303 / 1) -); -$text-danger-inverted-secondary: var( - --openui-text-danger-inverted-secondary, - oklch(0.982 0.009 17.303 / 0.5) -); -$text-danger-inverted-tertiary: var( - --openui-text-danger-inverted-tertiary, - oklch(0.982 0.009 17.303 / 0.3) -); +$text-danger-inverted-primary: var(--openui-text-danger-inverted-primary, oklch(0.982 0.009 17.303 / 1)); +$text-danger-inverted-secondary: var(--openui-text-danger-inverted-secondary, oklch(0.982 0.009 17.303 / 0.5)); +$text-danger-inverted-tertiary: var(--openui-text-danger-inverted-tertiary, oklch(0.982 0.009 17.303 / 0.3)); $text-info-primary: var(--openui-text-info-primary, oklch(0.424 0.181 265.638 / 1)); $text-info-inverted: var(--openui-text-info-inverted, oklch(0.932 0.032 255.585 / 1)); $text-pink-primary: var(--openui-text-pink-primary, oklch(0.459 0.17 3.815 / 1)); @@ -70,38 +61,14 @@ $interactive-accent-default: var(--openui-interactive-accent-default, oklch(0.09 $interactive-accent-hover: var(--openui-interactive-accent-hover, oklch(0.097 0 0 / 0.8)); $interactive-accent-disabled: var(--openui-interactive-accent-disabled, oklch(0.097 0 0 / 0.4)); $interactive-accent-pressed: var(--openui-interactive-accent-pressed, oklch(0.097 0 0 / 1)); -$interactive-destructive-default: var( - --openui-interactive-destructive-default, - oklch(0.577 0.215 27.325 / 0.02) -); -$interactive-destructive-hover: var( - --openui-interactive-destructive-hover, - oklch(0.577 0.215 27.325 / 0.08) -); -$interactive-destructive-disabled: var( - --openui-interactive-destructive-disabled, - oklch(0.577 0.215 27.325 / 0.02) -); -$interactive-destructive-pressed: var( - --openui-interactive-destructive-pressed, - oklch(0.577 0.215 27.325 / 0.1) -); -$interactive-destructive-accent-default: var( - --openui-interactive-destructive-accent-default, - oklch(0.577 0.215 27.325 / 1) -); -$interactive-destructive-accent-hover: var( - --openui-interactive-destructive-accent-hover, - oklch(0.637 0.208 25.331 / 1) -); -$interactive-destructive-accent-pressed: var( - --openui-interactive-destructive-accent-pressed, - oklch(0.505 0.19 27.518 / 1) -); -$interactive-destructive-accent-disabled: var( - --openui-interactive-destructive-accent-disabled, - oklch(0.577 0.215 27.325 / 0.4) -); +$interactive-destructive-default: var(--openui-interactive-destructive-default, oklch(0.577 0.215 27.325 / 0.02)); +$interactive-destructive-hover: var(--openui-interactive-destructive-hover, oklch(0.577 0.215 27.325 / 0.08)); +$interactive-destructive-disabled: var(--openui-interactive-destructive-disabled, oklch(0.577 0.215 27.325 / 0.02)); +$interactive-destructive-pressed: var(--openui-interactive-destructive-pressed, oklch(0.577 0.215 27.325 / 0.1)); +$interactive-destructive-accent-default: var(--openui-interactive-destructive-accent-default, oklch(0.577 0.215 27.325 / 1)); +$interactive-destructive-accent-hover: var(--openui-interactive-destructive-accent-hover, oklch(0.637 0.208 25.331 / 1)); +$interactive-destructive-accent-pressed: var(--openui-interactive-destructive-accent-pressed, oklch(0.505 0.19 27.518 / 1)); +$interactive-destructive-accent-disabled: var(--openui-interactive-destructive-accent-disabled, oklch(0.577 0.215 27.325 / 0.4)); // Chat Colors @@ -225,11 +192,7 @@ $text-label-sm-heavy: var(--openui-text-label-sm-heavy, 500 14px/1.25 "Inter", s $text-label-sm-heavy-letter-spacing: var(--openui-text-label-sm-heavy-letter-spacing, 0); $text-label-default: var(--openui-text-label-default, 400 16px/1.25 "Inter", sans-serif); $text-label-default-letter-spacing: var(--openui-text-label-default-letter-spacing, 0); -$text-label-default-heavy: var( - --openui-text-label-default-heavy, - 500 16px/1.25 "Inter", - sans-serif -); +$text-label-default-heavy: var(--openui-text-label-default-heavy, 500 16px/1.25 "Inter", sans-serif); $text-label-default-heavy-letter-spacing: var(--openui-text-label-default-heavy-letter-spacing, 0); $text-label-lg: var(--openui-text-label-lg, 400 18px/1.25 "Inter", sans-serif); $text-label-lg-letter-spacing: var(--openui-text-label-lg-letter-spacing, 0); @@ -245,76 +208,30 @@ $text-numbers-sm-heavy: var(--openui-text-numbers-sm-heavy, 500 14px/1.5 "Inter" $text-numbers-sm-heavy-letter-spacing: var(--openui-text-numbers-sm-heavy-letter-spacing, 0); $text-numbers-default: var(--openui-text-numbers-default, 400 16px/1.5 "Inter", sans-serif); $text-numbers-default-letter-spacing: var(--openui-text-numbers-default-letter-spacing, 0); -$text-numbers-default-heavy: var( - --openui-text-numbers-default-heavy, - 500 16px/1.5 "Inter", - sans-serif -); -$text-numbers-default-heavy-letter-spacing: var( - --openui-text-numbers-default-heavy-letter-spacing, - 0 -); +$text-numbers-default-heavy: var(--openui-text-numbers-default-heavy, 500 16px/1.5 "Inter", sans-serif); +$text-numbers-default-heavy-letter-spacing: var(--openui-text-numbers-default-heavy-letter-spacing, 0); $text-numbers-lg: var(--openui-text-numbers-lg, 400 18px/1.5 "Inter", sans-serif); $text-numbers-lg-letter-spacing: var(--openui-text-numbers-lg-letter-spacing, 0); $text-numbers-lg-heavy: var(--openui-text-numbers-lg-heavy, 500 18px/1.5 "Inter", sans-serif); $text-numbers-lg-heavy-letter-spacing: var(--openui-text-numbers-lg-heavy-letter-spacing, 0); $text-code-sm: var(--openui-text-code-sm, 400 12px/1.5 "SFMono-Regular", Menlo, monospace); $text-code-sm-letter-spacing: var(--openui-text-code-sm-letter-spacing, 0); -$text-code-sm-heavy: var( - --openui-text-code-sm-heavy, - 700 12px/1.5 "SFMono-Regular", - Menlo, - monospace -); +$text-code-sm-heavy: var(--openui-text-code-sm-heavy, 700 12px/1.5 "SFMono-Regular", Menlo, monospace); $text-code-sm-heavy-letter-spacing: var(--openui-text-code-sm-heavy-letter-spacing, 0); -$text-code-default: var( - --openui-text-code-default, - 400 14px/1.5 "SFMono-Regular", - Menlo, - monospace -); +$text-code-default: var(--openui-text-code-default, 400 14px/1.5 "SFMono-Regular", Menlo, monospace); $text-code-default-letter-spacing: var(--openui-text-code-default-letter-spacing, 0); -$text-code-default-heavy: var( - --openui-text-code-default-heavy, - 700 14px/1.5 "SFMono-Regular", - Menlo, - monospace -); +$text-code-default-heavy: var(--openui-text-code-default-heavy, 700 14px/1.5 "SFMono-Regular", Menlo, monospace); $text-code-default-heavy-letter-spacing: var(--openui-text-code-default-heavy-letter-spacing, 0); // Shadows $shadow-0: var(--openui-shadow-0, none); -$shadow-s: var( - --openui-shadow-s, - 0 1px 3px -2px oklch(0 0 0 / 0.02), - 0 2px 5px -2px oklch(0 0 0 / 0.04) -); -$shadow-m: var( - --openui-shadow-m, - 0 4px 6px -2px oklch(0 0 0 / 0.025), - 0 2px 2px -2px oklch(0 0 0 / 0.05) -); -$shadow-l: var( - --openui-shadow-l, - 0 4px 4px -2px oklch(0 0 0 / 0.05), - 0 4px 8px -2px oklch(0 0 0 / 0.04) -); -$shadow-xl: var( - --openui-shadow-xl, - 0 8px 16px -4px oklch(0 0 0 / 0.08), - 0 16px 32px -6px oklch(0 0 0 / 0.12) -); -$shadow-2xl: var( - --openui-shadow-2xl, - 0 12px 24px -6px oklch(0 0 0 / 0.12), - 0 24px 48px -8px oklch(0 0 0 / 0.16) -); -$shadow-3xl: var( - --openui-shadow-3xl, - 0 16px 32px -8px oklch(0 0 0 / 0.16), - 0 32px 64px -12px oklch(0 0 0 / 0.22) -); +$shadow-s: var(--openui-shadow-s, 0 1px 3px -2px oklch(0 0 0 / 0.02), 0 2px 5px -2px oklch(0 0 0 / 0.04)); +$shadow-m: var(--openui-shadow-m, 0 4px 6px -2px oklch(0 0 0 / 0.025), 0 2px 2px -2px oklch(0 0 0 / 0.05)); +$shadow-l: var(--openui-shadow-l, 0 4px 4px -2px oklch(0 0 0 / 0.05), 0 4px 8px -2px oklch(0 0 0 / 0.04)); +$shadow-xl: var(--openui-shadow-xl, 0 8px 16px -4px oklch(0 0 0 / 0.08), 0 16px 32px -6px oklch(0 0 0 / 0.12)); +$shadow-2xl: var(--openui-shadow-2xl, 0 12px 24px -6px oklch(0 0 0 / 0.12), 0 24px 48px -8px oklch(0 0 0 / 0.16)); +$shadow-3xl: var(--openui-shadow-3xl, 0 16px 32px -8px oklch(0 0 0 / 0.16), 0 32px 64px -12px oklch(0 0 0 / 0.22)); $chat-container-bg: $background; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1243b795f..bd26f34f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,13 +58,13 @@ importers: version: 0.68.17 fumadocs-core: specifier: 16.6.5 - version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) fumadocs-mdx: specifier: 14.2.8 - version: 14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react@19.2.4)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.0)) + version: 14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react@19.2.4)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.0)) fumadocs-ui: specifier: 16.6.5 - version: 16.6.5(@takumi-rs/image-response@0.68.17)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) + version: 16.6.5(@takumi-rs/image-response@0.68.17)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) gpt-tokenizer: specifier: ^3.4.0 version: 3.4.0 @@ -76,7 +76,7 @@ importers: version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.1.6 - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) posthog-js: specifier: ^1.358.1 version: 1.358.1 @@ -113,7 +113,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 @@ -137,7 +137,7 @@ importers: version: 0.575.0(react@19.2.3) next: specifier: 16.1.6 - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.89.2) + version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.89.2) openai: specifier: ^6.22.0 version: 6.22.0(ws@8.18.2)(zod@4.3.6) @@ -292,6 +292,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 @@ -380,6 +392,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 @@ -1087,18 +1111,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} @@ -1107,10 +1123,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} @@ -1123,18 +1135,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} - '@floating-ui/core@1.7.1': resolution: {integrity: sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==} @@ -2939,23 +2943,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==} @@ -2963,6 +3024,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==} @@ -2978,9 +3048,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==} @@ -2990,6 +3057,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/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} @@ -3347,9 +3417,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==} @@ -3665,6 +3732,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -3705,18 +3776,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'} @@ -3725,10 +3845,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'} @@ -3745,6 +3885,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==} @@ -3817,6 +3971,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'} @@ -4091,10 +4248,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} @@ -4107,16 +4260,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} @@ -4131,10 +4274,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'} @@ -4144,10 +4283,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'} @@ -4603,6 +4738,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + 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'} @@ -5891,6 +6030,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + 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'} @@ -5899,6 +6041,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==} @@ -7121,11 +7266,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) @@ -7143,20 +7283,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 @@ -7165,10 +7293,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 @@ -7187,18 +7311,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 - '@floating-ui/core@1.7.1': dependencies: '@floating-ui/utils': 0.2.9 @@ -8991,28 +9108,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 @@ -9031,8 +9241,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 @@ -9041,6 +9249,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/hast@2.3.10': dependencies: '@types/unist': 2.0.11 @@ -9111,22 +9321,6 @@ snapshots: '@types/uuid@9.0.8': {} - '@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 @@ -9143,18 +9337,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 @@ -9185,18 +9367,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 @@ -9226,17 +9396,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)) @@ -9493,13 +9652,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 @@ -9803,6 +9955,8 @@ snapshots: commander@4.1.1: {} + commander@7.2.0: {} + commander@8.3.0: {} compute-scroll-into-view@3.1.1: {} @@ -9839,18 +9993,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 @@ -9859,6 +10075,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 @@ -9873,6 +10091,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-view-buffer@1.0.2: @@ -9933,6 +10201,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: {} dequal@2.0.3: {} @@ -10211,26 +10483,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 @@ -10263,21 +10515,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 @@ -10293,17 +10530,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 @@ -10315,35 +10541,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 @@ -10373,25 +10570,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 @@ -10421,17 +10599,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 @@ -10447,28 +10614,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 @@ -10516,56 +10661,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)) @@ -10614,22 +10715,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 @@ -10794,7 +10885,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): + fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@orama/orama': 3.1.18 @@ -10826,21 +10917,21 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.14 lucide-react: 0.570.0(react@19.2.4) - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + next: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) zod: 4.3.6 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react@19.2.4)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.0)): + fumadocs-mdx@14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react@19.2.4)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.27.3 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -10857,13 +10948,13 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.13 '@types/react': 19.2.14 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + next: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) react: 19.2.4 vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - supports-color - fumadocs-ui@16.6.5(@takumi-rs/image-response@0.68.17)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): + fumadocs-ui@16.6.5(@takumi-rs/image-response@0.68.17)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): dependencies: '@fumadocs/tailwind': 0.0.2(tailwindcss@4.2.1) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -10877,7 +10968,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 - fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) lucide-react: 0.570.0(react@19.2.4) motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -10892,7 +10983,7 @@ snapshots: optionalDependencies: '@takumi-rs/image-response': 0.68.17 '@types/react': 19.2.14 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + next: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -11174,6 +11265,10 @@ snapshots: html-void-elements@3.0.0: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -12100,7 +12195,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.89.2): + next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.89.2): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -12109,7 +12204,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -12127,7 +12222,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2): + next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -12136,7 +12231,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -12840,6 +12935,8 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.2: {} + rollup@4.43.0: dependencies: '@types/estree': 1.0.7 @@ -12870,6 +12967,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + rxjs@7.8.1: dependencies: tslib: 2.8.1 @@ -13172,19 +13271,15 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 - optionalDependencies: - '@babel/core': 7.29.0 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(react@19.2.4): dependencies: client-only: 0.0.1 react: 19.2.4 - optionalDependencies: - '@babel/core': 7.29.0 sucrase@3.35.0: dependencies: @@ -13365,17 +13460,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) @@ -13548,7 +13632,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 From 71094078f5a82be81f27eb655bf8b1ccb5664435 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Fri, 6 Mar 2026 22:18:57 +0530 Subject: [PATCH 02/23] refactor(ChartsV2): extract hooks, improve layout, and fix fixed-dimension rendering Refactor the D3AreaChart orchestrator by extracting inline logic into dedicated hooks (useXScale, useYScale, useStackedData, usePrintContext), improving type safety across all sub-components (AreaSeries, Crosshair, Grid, GradientDefs, XAxis), and cleaning up exports/barrel files. Fix chart overflow when fixed pixel dimensions (e.g. width="500px") are set by switching the main container from width:100% to flex:1 and making the outer container a flex column so the legend stays within bounds. Also adds onClick handler support, Storybook stories for new features, and simplifies XAxisTickVariant to singleLine/multiLine only. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 + .../ChartsV2/D3AreaChart/D3AreaChart.tsx | 209 +++++---- .../ChartsV2/D3AreaChart/d3AreaChart.scss | 9 +- .../components/ChartsV2/D3AreaChart/index.ts | 2 +- .../ChartsV2/D3AreaChart/parts/AreaSeries.tsx | 33 +- .../ChartsV2/D3AreaChart/parts/Crosshair.tsx | 34 +- .../D3AreaChart/parts/GradientDefs.tsx | 11 +- .../ChartsV2/D3AreaChart/parts/Grid.tsx | 8 +- .../ChartsV2/D3AreaChart/parts/XAxis.tsx | 30 +- .../stories/d3AreaChart.stories.tsx | 425 ++++++++++++++++-- .../components/ChartsV2/D3AreaChart/types.ts | 23 +- .../src/components/ChartsV2/hooks/index.ts | 10 +- .../ChartsV2/hooks/useContainerSize.ts | 2 +- .../ChartsV2/hooks/usePrintContext.ts | 27 ++ .../ChartsV2/hooks/useStackedData.ts | 23 + .../ChartsV2/hooks/useTransformedKeys.ts | 4 +- .../ChartsV2/hooks/useXAxisHeight.ts | 31 +- .../components/ChartsV2/hooks/useXScale.ts | 18 + .../ChartsV2/hooks/useYAxisWidth.ts | 5 +- .../components/ChartsV2/hooks/useYScale.ts | 33 ++ .../react-ui/src/components/ChartsV2/index.ts | 2 +- .../shared/DefaultLegend/DefaultLegend.tsx | 6 +- .../PortalTooltip/CustomTooltipContent.tsx | 4 +- .../src/components/ChartsV2/shared/index.ts | 4 +- .../src/components/ChartsV2/types/common.ts | 22 + .../components/ChartsV2/utils/dataUtils.ts | 4 +- .../src/components/ChartsV2/utils/index.ts | 4 +- .../components/ChartsV2/utils/paletteUtils.ts | 78 +++- .../components/ChartsV2/utils/scrollUtils.ts | 12 +- 29 files changed, 795 insertions(+), 282 deletions(-) create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/usePrintContext.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useStackedData.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useXScale.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useYScale.ts diff --git a/.gitignore b/.gitignore index 83468ded9..5fb8a8dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ .cursor/plans .cursor/agents +#Claude +.claude/ +Claude.md + # Dependencies node_modules .pnpm-store/ diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx index 9d5aab7ce..f7c042da8 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx @@ -1,13 +1,16 @@ import clsx from "clsx"; -import { scaleLinear, scalePoint } from "d3-scale"; +import type { ScalePoint } from "d3-scale"; import { pointer } from "d3-selection"; -import { stack, stackOffsetNone, stackOrderNone } from "d3-shape"; -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useContainerSize } from "../hooks/useContainerSize"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useStackedData } from "../hooks/useStackedData"; import { useTransformedKeys } from "../hooks/useTransformedKeys"; import { useXAxisHeight } from "../hooks/useXAxisHeight"; +import { useXScale } from "../hooks/useXScale"; import { useYAxisWidth } from "../hooks/useYAxisWidth"; +import { useYScale } from "../hooks/useYScale"; import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; import { CustomTooltipContent } from "../shared/PortalTooltip/CustomTooltipContent"; @@ -32,6 +35,37 @@ import type { D3AreaChartData, D3AreaChartProps } from "./types"; const MARGIN_TOP = 10; const DEFAULT_CHART_HEIGHT = 296; +const SINGLE_LINE_BREAKPOINT = 300; + +let nextChartId = 0; + +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; +} + +function getRelativePosition( + clientX: number, + clientY: number, + containerRef: React.RefObject, +): { x: number; y: number } | null { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return null; + return { + x: clientX - rect.left + (containerRef.current?.scrollLeft ?? 0), + y: clientY - rect.top, + }; +} export function D3AreaChart({ data, @@ -51,10 +85,30 @@ export function D3AreaChart({ className, height, width: fixedWidth, + onClick, }: D3AreaChartProps) { + const isPrinting = usePrintContext(); const containerRef = useRef(null); const mainContainerRef = useRef(null); - const chartId = useMemo(() => crypto.randomUUID(), []); + const legendRef = useRef(null); + const chartId = useMemo(() => `d3ac-${nextChartId++}`, []); + 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]); const { width: containerWidth, height: containerHeight } = useContainerSize( containerRef, @@ -79,14 +133,17 @@ export function D3AreaChart({ }); const transformedKeys = useTransformedKeys(allDataKeys); - const widthOfGroup = getWidthOfGroup(data); - const tickVariant = containerWidth < 300 ? ("singleLine" as const) : tickVariantProp; + const widthOfGroup = getWidthOfGroup(data.length); + + const tickVariant = + containerWidth < SINGLE_LINE_BREAKPOINT ? ("singleLine" as const) : tickVariantProp; + const xAxisHeight = useXAxisHeight(data, catKey, tickVariant, widthOfGroup); const { yAxisWidth } = useYAxisWidth(data, dataKeys); const effectiveYAxisWidth = showYAxis ? yAxisWidth : 0; const chartConfig = useMemo( - () => get2dChartConfig(allDataKeys, colors, transformedKeys, undefined, icons as any), + () => get2dChartConfig(allDataKeys, colors, transformedKeys, undefined, icons), [allDataKeys, colors, transformedKeys, icons], ); @@ -126,72 +183,23 @@ export function D3AreaChart({ : containerHeight > 0 ? containerHeight : DEFAULT_CHART_HEIGHT; - const chartInnerHeight = resolvedHeight - MARGIN_TOP - xAxisHeight; - const totalHeight = resolvedHeight; + const svgAvailableHeight = height ? resolvedHeight - legendHeight : resolvedHeight; + const chartInnerHeight = svgAvailableHeight - MARGIN_TOP - xAxisHeight; + const totalHeight = svgAvailableHeight; const svgWidth = needsScroll ? dataWidth : containerWidth - effectiveYAxisWidth; - const xScale = useMemo(() => { - return scalePoint() - .domain(data.map((d) => String(d[catKey]))) - .range([widthOfGroup / 2, svgWidth - widthOfGroup / 2]) - .padding(0); - }, [data, catKey, svgWidth, widthOfGroup]); - - const yScale = useMemo(() => { - let maxVal = 0; - - if (stacked && dataKeys.length > 0) { - const stackGenerator = stack>() - .keys(dataKeys) - .order(stackOrderNone) - .offset(stackOffsetNone); - const stackedData = stackGenerator(data as Iterable<{ [key: string]: number }>); - 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, stacked, chartInnerHeight]); + const xScale = useXScale(data, catKey, svgWidth, widthOfGroup); + const stackedData = useStackedData(data, dataKeys, stacked); + const yScale = useYScale(data, dataKeys, chartInnerHeight, stackedData); const [hoveredIndex, setHoveredIndex] = useState(null); const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); const handleMouseMove = useCallback( (event: React.MouseEvent) => { - const svgElement = event.currentTarget; - const [mouseX] = pointer(event.nativeEvent, svgElement); - - 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; - } - }); - - setHoveredIndex(nearestIdx); - - const containerRect = mainContainerRef.current?.getBoundingClientRect(); - if (containerRect) { - setMousePos({ - x: event.clientX - containerRect.left + (mainContainerRef.current?.scrollLeft ?? 0), - y: event.clientY - containerRect.top, - }); - } + const [mouseX] = pointer(event.nativeEvent, event.currentTarget); + setHoveredIndex(findNearestDataIndex(xScale, mouseX)); + setMousePos(getRelativePosition(event.clientX, event.clientY, mainContainerRef)); }, [xScale], ); @@ -205,31 +213,10 @@ export function D3AreaChart({ (event: React.TouchEvent) => { const touch = event.touches[0]; if (!touch) return; - const svgElement = event.currentTarget; - const svgRect = svgElement.getBoundingClientRect(); + const svgRect = event.currentTarget.getBoundingClientRect(); const mouseX = touch.clientX - svgRect.left; - - 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; - } - }); - - setHoveredIndex(nearestIdx); - - const containerRect = mainContainerRef.current?.getBoundingClientRect(); - if (containerRect) { - setMousePos({ - x: touch.clientX - containerRect.left + (mainContainerRef.current?.scrollLeft ?? 0), - y: touch.clientY - containerRect.top, - }); - } + setHoveredIndex(findNearestDataIndex(xScale, mouseX)); + setMousePos(getRelativePosition(touch.clientX, touch.clientY, mainContainerRef)); }, [xScale], ); @@ -239,9 +226,36 @@ export function D3AreaChart({ setMousePos(null); }, []); + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (!onClick) return; + const [mouseX] = pointer(event.nativeEvent, event.currentTarget); + const idx = findNearestDataIndex(xScale, mouseX); + if (idx >= 0 && idx < data.length) { + onClick(data[idx]!, idx); + } + }, + [onClick, xScale, data], + ); + 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]); + const handleScroll = useCallback(() => { const el = mainContainerRef.current; if (!el) return; @@ -262,7 +276,7 @@ export function D3AreaChart({ ); const legendItems = useMemo( - () => getLegendItems(allDataKeys, colors, icons as any), + () => getLegendItems(allDataKeys, colors, icons), [allDataKeys, colors, icons], ); const [isLegendExpanded, setIsLegendExpanded] = useState(false); @@ -301,7 +315,7 @@ export function D3AreaChart({ }, [hoveredIndex, data, dataKeys, catKey, chartConfig]); if (!data || data.length === 0) { - return null; + return
; } const containerStyle = useMemo(() => { @@ -317,6 +331,7 @@ export function D3AreaChart({ ref={containerRef} className={clsx("openui-d3-area-chart-container", className)} style={containerStyle as React.CSSProperties} + data-openui-chart="area" >
{showYAxis && ( @@ -345,6 +360,7 @@ export function D3AreaChart({ onMouseLeave={handleMouseLeave} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} + onClick={handleClick} > ({ chartHeight={chartInnerHeight} /> - {grid && } + {grid && ( + + )} ({ dataKeys={dataKeys} categoryKey={catKey} colors={colorMap} - stacked={stacked} + stackedData={stackedData} chartHeight={chartInnerHeight} /> @@ -406,6 +424,7 @@ export function D3AreaChart({ {showLegend && ( ; yScale: ScaleLinear; variant: D3AreaChartVariant; - stacked: boolean; + stackedData: StackedData | null; categoryKey: string; transformedKeys: Record; colors: Record; @@ -38,7 +36,7 @@ export const AreaSeries: React.FC = ({ xScale, yScale, variant, - stacked, + stackedData, categoryKey, transformedKeys, colors, @@ -48,14 +46,7 @@ export const AreaSeries: React.FC = ({ const curve = curveMap[variant]; const seriesPaths = useMemo(() => { - if (stacked) { - const stackGenerator = stack>() - .keys(dataKeys) - .order(stackOrderNone) - .offset(stackOffsetNone); - - const stackedData = stackGenerator(data as Iterable<{ [key: string]: number }>); - + if (stackedData) { return stackedData.map((series) => { const areaGenerator = d3Area<[number, number]>() .x((_, i) => xScale(String(data[i]![categoryKey])) ?? 0) @@ -94,7 +85,7 @@ export const AreaSeries: React.FC = ({ }; }); } - }, [data, dataKeys, xScale, yScale, variant, stacked, categoryKey, curve]); + }, [data, dataKeys, xScale, yScale, variant, stackedData, categoryKey, curve]); return ( @@ -108,11 +99,7 @@ export const AreaSeries: React.FC = ({ d={areaPath} fill={`url(#grad-${chartId}-${transformedKey})`} /> - + ); })} diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx index 2dca206fc..d6b3ae007 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx @@ -1,6 +1,6 @@ import type { ScaleLinear, ScalePoint } from "d3-scale"; -import { stack, stackOrderNone, stackOffsetNone } from "d3-shape"; -import React, { useMemo } from "react"; +import React from "react"; +import type { StackedData } from "../../hooks/useStackedData"; interface CrosshairProps { hoveredIndex: number | null; @@ -10,7 +10,7 @@ interface CrosshairProps { dataKeys: string[]; categoryKey: string; colors: Record; - stacked: boolean; + stackedData: StackedData | null; chartHeight: number; } @@ -22,18 +22,9 @@ export const Crosshair: React.FC = ({ dataKeys, categoryKey, colors, - stacked, + stackedData, chartHeight, }) => { - const stackedData = 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]); - if (hoveredIndex === null || hoveredIndex < 0 || hoveredIndex >= data.length) { return null; } @@ -44,16 +35,10 @@ export const Crosshair: React.FC = ({ return ( - + {dataKeys.map((key, seriesIndex) => { let yValue: number; - if (stacked && stackedData) { + if (stackedData) { const series = stackedData[seriesIndex]; const point = series?.[hoveredIndex]; yValue = point ? point[1] : 0; @@ -66,12 +51,7 @@ export const Crosshair: React.FC = ({ return ( - + ; @@ -20,7 +25,7 @@ export const GradientDefs: React.FC = ({ return ( - + {dataKeys.map((key) => { const transformedKey = transformedKeys[key]; @@ -34,8 +39,8 @@ export const GradientDefs: React.FC = ({ x2="0" y2="1" > - - + + ); })} diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx index fb9e77095..983567353 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx @@ -16,13 +16,7 @@ export const Grid: React.FC = ({ yScale, chartWidth, chartHeight }) = return ( {ticks.map((tick) => ( - + ))} ); diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx index d3c5d613d..319c4196e 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx @@ -3,6 +3,8 @@ import React, { useRef } from "react"; import { LabelTooltip } from "../../shared/LabelTooltip/LabelTooltip"; import { XAxisTickVariant } from "../../types"; +const X_AXIS_TOP_GAP = 4; + interface XAxisProps { scale: ScalePoint; data: Array>; @@ -32,15 +34,11 @@ export const XAxis: React.FC = ({ - + ); })} @@ -63,11 +61,7 @@ const XAxisLabel: React.FC<{ return ( -
+
{label}
@@ -80,8 +74,18 @@ function useIsTruncated(ref: React.RefObject): boolean { React.useEffect(() => { const el = ref.current; if (!el) return; - setTruncated(el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight); - }); + + 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/D3AreaChart/stories/d3AreaChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx index 0cd9af1d3..93ad5c507 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx @@ -1,18 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { - Calendar, - Globe, - Laptop, - Monitor, - Smartphone, - TabletSmartphone, - Tv, - Watch, -} from "lucide-react"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Card } from "../../../Card"; import { D3AreaChart } from "../D3AreaChart"; -import type { D3AreaChartProps, D3AreaChartData } from "../types"; +import type { D3AreaChartProps } from "../types"; const dataVariations = { default: [ @@ -30,14 +20,54 @@ const dataVariations = { { 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 }, + { + 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 }, @@ -59,13 +89,48 @@ const dataVariations = { ], 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 }, + { + 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 }, @@ -96,12 +161,114 @@ const dataVariations = { { 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 }, + { + 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, + }, ], }; @@ -161,16 +328,40 @@ export const DataExplorer: Story = { yAxisLabel: "Values", }, render: (args: any) => { - const [selectedDataType, setSelectedDataType] = useState("default"); + 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" }; + 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) => ( @@ -183,8 +374,11 @@ export const DataExplorer: Story = { ))}
-
- Dataset: {selectedDataType} | Items: {currentData.length} | Category: {currentCategoryKey} +
+ Dataset: {selectedDataType} | Items: {currentData.length} | Category:{" "} + {currentCategoryKey}
@@ -540,19 +734,40 @@ export const DynamicSizing: Story = { const [heightVal, setHeightVal] = useState(296); const widthProp = - widthMode === "number" ? widthVal : widthMode === "percent" ? `${widthVal}%` : `${widthVal}px`; + 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" }; + const controlRow: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "12px", + marginBottom: "8px", + }; return (
-
+
width: - setWidthMode(e.target.value as any)} + style={{ fontSize: "13px" }} + > @@ -571,7 +786,11 @@ export const DynamicSizing: Story = {
height: - setHeightMode(e.target.value as any)} + style={{ fontSize: "13px" }} + > @@ -607,3 +826,121 @@ export const DynamicSizing: Story = { ); }, }; + +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 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 index 116efa782..86945bc8f 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/types.ts +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/types.ts @@ -1,26 +1,11 @@ -import { PaletteName } from "../utils/paletteUtils"; -import { XAxisTickVariant } from "../types"; +import type { BaseChartProps, ChartData } from "../types"; -export type D3AreaChartData = Array>; +export type D3AreaChartData = ChartData; export type D3AreaChartVariant = "linear" | "natural" | "step"; -export interface D3AreaChartProps { - data: T; - categoryKey: keyof T[number]; - theme?: PaletteName; - customPalette?: string[]; +export interface D3AreaChartProps extends BaseChartProps { variant?: D3AreaChartVariant; - tickVariant?: XAxisTickVariant; stacked?: boolean; - grid?: boolean; - legend?: boolean; - icons?: Partial>; - isAnimationActive?: boolean; - showYAxis?: boolean; - xAxisLabel?: React.ReactNode; - yAxisLabel?: React.ReactNode; - className?: string; - height?: number | string; - width?: number | string; + onClick?: (row: T[number], index: number) => void; } diff --git a/packages/react-ui/src/components/ChartsV2/hooks/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/index.ts index 5376e4e3a..6a3f21ed8 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/index.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/index.ts @@ -1,5 +1,9 @@ +export * from "./useCanvasContextForLabelSize"; export * from "./useContainerSize"; -export * from "./useYAxisWidth"; -export * from "./useXAxisHeight"; +export * from "./usePrintContext"; +export * from "./useStackedData"; export * from "./useTransformedKeys"; -export * from "./useCanvasContextForLabelSize"; +export * from "./useXAxisHeight"; +export * from "./useXScale"; +export * from "./useYAxisWidth"; +export * from "./useYScale"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts b/packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts index 7027dc1b2..eebea2207 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts @@ -27,7 +27,7 @@ export function useContainerSize( setSize({ width: rect.width, height: rect.height }); return () => observer.disconnect(); - }, [numericWidth, numericHeight]); + }, [numericWidth, numericHeight, ref]); return { width: numericWidth ?? size.width, diff --git a/packages/react-ui/src/components/ChartsV2/hooks/usePrintContext.ts b/packages/react-ui/src/components/ChartsV2/hooks/usePrintContext.ts new file mode 100644 index 000000000..40915abad --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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/useStackedData.ts b/packages/react-ui/src/components/ChartsV2/hooks/useStackedData.ts new file mode 100644 index 000000000..b68b65e81 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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/useTransformedKeys.ts b/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts index 964b4d265..6d66fc5c7 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts @@ -1,5 +1,7 @@ import { useMemo, useRef } from "react"; +let nextKeyId = 0; + export const useTransformedKeys = (keys: string[]) => { const cacheRef = useRef>({}); @@ -7,7 +9,7 @@ export const useTransformedKeys = (keys: string[]) => { return keys.reduce( (acc, key) => { if (!cacheRef.current[key]) { - cacheRef.current[key] = crypto.randomUUID(); + cacheRef.current[key] = `tk-${nextKeyId++}`; } acc[key] = cacheRef.current[key]; return acc; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts b/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts index 06bf09479..1ef91c644 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts @@ -1,8 +1,9 @@ -import { useMemo } from "react"; +import { useEffect, useState } from "react"; import { useTheme } from "../../ThemeProvider"; import { XAxisTickVariant } from "../types"; const DEFAULT_HEIGHT = 30; +const X_AXIS_LABEL_PADDING = 13; export const useXAxisHeight = ( data: Record[], @@ -12,9 +13,12 @@ export const useXAxisHeight = ( ) => { const { theme: userTheme } = useTheme(); - const maxLabelHeight = useMemo(() => { + const [maxLabelHeight, setMaxLabelHeight] = useState(DEFAULT_HEIGHT); + + useEffect(() => { if (typeof window === "undefined" || !data || data.length === 0) { - return DEFAULT_HEIGHT; + setMaxLabelHeight(DEFAULT_HEIGHT); + return; } const largestLabel = data.reduce((max, item) => { @@ -52,14 +56,23 @@ export const useXAxisHeight = ( div2.getBoundingClientRect().height, div3.getBoundingClientRect().height * 3, ); - div1.remove(); - return largestLabelHeight; - }, [data, categoryKey, tickVariant, widthOfGroup]); + setMaxLabelHeight(largestLabelHeight); + + return () => { + div1.remove(); + }; + }, [ + data, + categoryKey, + tickVariant, + widthOfGroup, + userTheme.textLabelXs, + userTheme.textLabelXsLetterSpacing, + ]); if (tickVariant === "multiLine") { - return Math.max(maxLabelHeight + 13, DEFAULT_HEIGHT); - } else { - return DEFAULT_HEIGHT; + return Math.max(maxLabelHeight + X_AXIS_LABEL_PADDING, DEFAULT_HEIGHT); } + return DEFAULT_HEIGHT; }; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useXScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/useXScale.ts new file mode 100644 index 000000000..bd5507e60 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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/useYAxisWidth.ts b/packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts index 65052764e..f4c7df6ae 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts @@ -7,10 +7,7 @@ const MIN_Y_AXIS_WIDTH = 20; const MAX_Y_AXIS_WIDTH = 200; const LABEL_PADDING = 10; -export const useYAxisWidth = ( - data: Array>, - dataKeys: string[], -) => { +export const useYAxisWidth = (data: Array>, dataKeys: string[]) => { const context = useCanvasContextForLabelSize(); const [maxLabelWidthReceived, setMaxLabelWidthReceived] = useState(0); diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useYScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/useYScale.ts new file mode 100644 index 000000000..0b5bf7a1c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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/index.ts b/packages/react-ui/src/components/ChartsV2/index.ts index d544d4c02..eae74a6d7 100644 --- a/packages/react-ui/src/components/ChartsV2/index.ts +++ b/packages/react-ui/src/components/ChartsV2/index.ts @@ -1,2 +1,2 @@ export { D3AreaChart } from "./D3AreaChart"; -export type { D3AreaChartData, D3AreaChartVariant, D3AreaChartProps } from "./D3AreaChart/types"; +export type { D3AreaChartData, D3AreaChartProps, D3AreaChartVariant } from "./D3AreaChart/types"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx index 0f5c40ee5..8841bc4e0 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx @@ -70,14 +70,12 @@ const DefaultLegend = memo(
{xAxisLabel && ( - X-Axis:{" "} - {xAxisLabel} + X-Axis: {xAxisLabel} )} {yAxisLabel && ( - Y-Axis:{" "} - {yAxisLabel} + Y-Axis: {yAxisLabel} )}
diff --git a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx index 703d7028f..3d5bc6997 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import React, { memo, useEffect, useMemo, useState } from "react"; +import React, { memo, useEffect, useState } from "react"; import { FloatingUIPortal } from "./FloatingUIPortal"; import { tooltipNumberFormatter } from "./utils"; @@ -163,4 +163,4 @@ function CustomTooltipContentRender({ export const CustomTooltipContent = memo(CustomTooltipContentRender); CustomTooltipContent.displayName = "CustomTooltipContent"; -export type { TooltipItem, CustomTooltipContentProps }; +export type { CustomTooltipContentProps, TooltipItem }; diff --git a/packages/react-ui/src/components/ChartsV2/shared/index.ts b/packages/react-ui/src/components/ChartsV2/shared/index.ts index 2656a98ef..d98cb501d 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/index.ts +++ b/packages/react-ui/src/components/ChartsV2/shared/index.ts @@ -1,5 +1,5 @@ -export { LabelTooltip, LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; export { DefaultLegend } from "./DefaultLegend/DefaultLegend"; -export { ScrollButtonsHorizontal } from "./ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +export { LabelTooltip, LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; export { CustomTooltipContent } from "./PortalTooltip/CustomTooltipContent"; export { FloatingUIPortal } from "./PortalTooltip/FloatingUIPortal"; +export { ScrollButtonsHorizontal } from "./ScrollButtonsHorizontal/ScrollButtonsHorizontal"; diff --git a/packages/react-ui/src/components/ChartsV2/types/common.ts b/packages/react-ui/src/components/ChartsV2/types/common.ts index b630e1b22..4cf0defbd 100644 --- a/packages/react-ui/src/components/ChartsV2/types/common.ts +++ b/packages/react-ui/src/components/ChartsV2/types/common.ts @@ -1,3 +1,5 @@ +import type { PaletteName } from "../utils/paletteUtils"; + export interface LegendItem { key: string; label: string; @@ -7,3 +9,23 @@ export interface LegendItem { } 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; +} diff --git a/packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts index 22f05619d..96450f15d 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/dataUtils.ts @@ -22,7 +22,7 @@ export const get2dChartConfig = ( colors: string[], transformedKeys: Record, secondaryColors?: string[], - icons?: Partial>, + icons?: Partial>, ): ChartConfig => { return dataKeys.reduce( (config, key, index) => ({ @@ -42,7 +42,7 @@ export const get2dChartConfig = ( export const getLegendItems = ( dataKeys: string[], colors: string[], - icons?: Partial>, + icons?: Partial>, ): LegendItem[] => { return dataKeys.map((key, index) => ({ key, diff --git a/packages/react-ui/src/components/ChartsV2/utils/index.ts b/packages/react-ui/src/components/ChartsV2/utils/index.ts index b1f2a9b38..9f49594d2 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/index.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/index.ts @@ -1,4 +1,4 @@ -export * from "./paletteUtils"; export * from "./dataUtils"; -export * from "./styleUtils"; +export * from "./paletteUtils"; export * from "./scrollUtils"; +export * from "./styleUtils"; diff --git a/packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts index d65b82290..3b3faef8f 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/paletteUtils.ts @@ -15,43 +15,97 @@ const colorPalettes: PaletteMap = { ocean: { name: "Ocean", colors: [ - "#0D47A1", "#1565C0", "#1976D2", "#1E88E5", "#2196F3", - "#42A5F5", "#64B5F6", "#90CAF9", "#BBDEFB", "#E3F2FD", "#EFF8FF", + "#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", + "#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", + "#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", + "#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", + "#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", + "#FF595E", + "#FF924C", + "#FFCA3A", + "#C5CA30", + "#8AC926", + "#36949D", + "#1982C4", + "#4267AC", + "#565AA0", + "#6A4C93", + "#63438F", ], }, }; diff --git a/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts index adee0da4a..f0e36e306 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts @@ -1,20 +1,20 @@ type ChartData = Array>; const ELEMENT_SPACING = 72; +const MIN_SINGLE_POINT_WIDTH = 200; export const getWidthOfData = (data: ChartData, containerWidth: number) => { if (data.length === 0) { return containerWidth; } - const width = data.length * getWidthOfGroup(data); + const width = data.length * getWidthOfGroup(data.length); if (containerWidth >= width) { return containerWidth; } if (data.length === 1) { - const minSingleDataWidth = 200; - return Math.max(width, minSingleDataWidth); + return Math.max(width, MIN_SINGLE_POINT_WIDTH); } return width; @@ -42,8 +42,8 @@ export const findNearestSnapPosition = ( } }; -export const getWidthOfGroup = (data: ChartData) => { - if (data.length === 0) return 200; +export const getWidthOfGroup = (dataLength: number): number => { + if (dataLength === 0) return MIN_SINGLE_POINT_WIDTH; return ELEMENT_SPACING; }; @@ -51,7 +51,7 @@ export const getSnapPositions = (data: ChartData): number[] => { if (data.length === 0) return [0]; const positions = [0]; - const groupWidthValue = getWidthOfGroup(data); + const groupWidthValue = getWidthOfGroup(data.length); for (let i = 1; i < data.length; i++) { positions.push(i * groupWidthValue); From 4eb2c7db71e8a3102d784dbc2d482225b4b43bdd Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Fri, 6 Mar 2026 22:31:34 +0530 Subject: [PATCH 03/23] css fix --- .../src/components/ChartsV2/D3AreaChart/d3AreaChart.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss index b35871ff0..d552dd494 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss @@ -48,6 +48,7 @@ color: cssUtils.$text-neutral-secondary; display: -webkit-box; -webkit-line-clamp: 3; + line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-align: center; @@ -70,7 +71,9 @@ } .openui-d3-area-chart-area-path { - transition: d 0.3s ease-out, opacity 0.2s ease; + transition: + d 0.3s ease-out, + opacity 0.2s ease; } .openui-d3-area-chart-area-line { From 6d1c4f4e26b3db28e2710c9dfc99162c1c6e05cb Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 7 Mar 2026 07:08:34 +0530 Subject: [PATCH 04/23] chore: update eslint config and remove unused tooltip files Remove useId restriction from eslint (React 17 polyfill no longer needed). Delete CustomTooltipContent and FloatingUIPortal replaced by ChartTooltip. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + eslint.config.cjs | 13 +- .../PortalTooltip/CustomTooltipContent.tsx | 166 ------------------ .../shared/PortalTooltip/FloatingUIPortal.tsx | 64 ------- 4 files changed, 2 insertions(+), 242 deletions(-) delete mode 100644 packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx delete mode 100644 packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/FloatingUIPortal.tsx diff --git a/.gitignore b/.gitignore index 5fb8a8dd3..dfd00723b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ #Claude .claude/ Claude.md +Claude.local.md # Dependencies node_modules diff --git a/eslint.config.cjs b/eslint.config.cjs index 9056ae83c..d7be31e7b 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -68,18 +68,7 @@ module.exports = [ allow: ["error", "warn", "info"], }, ], - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "react", - importNames: ["useId"], - message: "import of useId is allowed only using polyfills", - }, - ], - }, - ], + "no-restricted-imports": "off", ...eslintPluginPrettier.configs.recommended.rules, "react-hooks/exhaustive-deps": "warn", }, diff --git a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx deleted file mode 100644 index 3d5bc6997..000000000 --- a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/CustomTooltipContent.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import clsx from "clsx"; -import React, { memo, useEffect, useState } from "react"; -import { FloatingUIPortal } from "./FloatingUIPortal"; -import { tooltipNumberFormatter } from "./utils"; - -interface TooltipItem { - name: string; - dataKey: string; - value: number; - color: string; - payload: Record; -} - -interface CustomTooltipContentProps { - active: boolean; - label: string; - items: TooltipItem[]; - position: { x: number; y: number }; - chartId: string; - portalContainer?: React.RefObject; - parentRef: React.RefObject; - className?: string; - chartStyle?: React.CSSProperties; -} - -function CustomTooltipContentRender({ - active, - label, - items, - position, - chartId, - portalContainer, - parentRef, - className, - chartStyle, -}: CustomTooltipContentProps) { - const [forcefullyHideTooltip, setForcefullyHideTooltip] = useState(false); - const [parentScrollPosition, setParentScrollPosition] = useState({ - x: 0, - y: 0, - width: 0, - height: 0, - }); - - const isGreaterThanTen = items.length > 10; - const remainingItems = isGreaterThanTen ? items.length - 5 : 0; - - useEffect(() => { - const parent = parentRef.current; - if (!parent) return; - - const touchHandler = (e: TouchEvent) => { - for (let i = 0; i < e.targetTouches.length; i++) { - const target = e.targetTouches[i]!.target as HTMLElement; - if (!parent.contains(target)) { - setForcefullyHideTooltip(true); - return; - } - } - setForcefullyHideTooltip(false); - }; - document.body.addEventListener("touchstart", touchHandler); - - const scrollHandler = () => { - setParentScrollPosition({ - x: parent.scrollLeft, - y: parent.scrollTop, - width: parent.clientWidth, - height: parent.clientHeight, - }); - }; - - parent.addEventListener("scroll", scrollHandler); - setParentScrollPosition({ - x: parent.scrollLeft, - y: parent.scrollTop, - width: parent.clientWidth, - height: parent.clientHeight, - }); - - return () => { - document.body.removeEventListener("touchstart", touchHandler); - parent.removeEventListener("scroll", scrollHandler); - }; - }, [parentRef]); - - if (!active || items.length === 0 || forcefullyHideTooltip) { - return null; - } - - if ( - parentScrollPosition.x > position.x || - parentScrollPosition.y > position.y || - parentScrollPosition.width + parentScrollPosition.x < position.x || - parentScrollPosition.height + parentScrollPosition.y < position.y - ) { - return null; - } - - const displayItems = isGreaterThanTen ? items.slice(0, 5) : items; - const isTwoItemsLayout = items.length <= 2; - - const tooltipContent = ( -
-
{label}
-
-
- {displayItems.map((item, index) => ( -
-
-
-
- {item.name} -
- - {tooltipNumberFormatter(item.value)} - -
-
-
- ))} -
- {isGreaterThanTen &&
} - {isGreaterThanTen && ( -
- Click to view all {remainingItems} -
- )} -
- ); - - return ( - - {tooltipContent} - - ); -} - -export const CustomTooltipContent = memo(CustomTooltipContentRender); -CustomTooltipContent.displayName = "CustomTooltipContent"; - -export type { CustomTooltipContentProps, TooltipItem }; diff --git a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/FloatingUIPortal.tsx b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/FloatingUIPortal.tsx deleted file mode 100644 index 500b24685..000000000 --- a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/FloatingUIPortal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { Placement } from "@floating-ui/react-dom"; -import { autoUpdate, flip, hide, offset, useFloating } from "@floating-ui/react-dom"; -import clsx from "clsx"; -import React, { useEffect } from "react"; -import { createPortal } from "react-dom"; -import { useTheme } from "../../../ThemeProvider"; - -interface FloatingUIPortalProps { - children: React.ReactNode; - className?: string; - chartId?: string; - portalContainer?: React.RefObject; - position?: Partial<{ x: number; y: number }>; - placement?: Placement; - offsetDistance?: number; -} - -export const FloatingUIPortal: React.FC = ({ - children, - className = "", - chartId, - portalContainer, - position, - placement = "right-start", - offsetDistance = 20, -}) => { - const { refs, floatingStyles, update } = useFloating({ - placement, - middleware: [offset(offsetDistance), flip(), hide()], - whileElementsMounted: autoUpdate, - }); - - const { portalThemeClassName } = useTheme(); - - useEffect(() => { - if (position) { - update(); - } - }, [position, update]); - - return ( - <> -
- {createPortal( -
- {children} -
, - portalContainer?.current || document.body, - )} - - ); -}; From 59da03676c4bd912abf96052f5f52f6ebd96fd31 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 7 Mar 2026 07:08:48 +0530 Subject: [PATCH 05/23] refactor(ChartsV2): extract shared chart infrastructure Add reusable shared components: ClipDefs, Grid, XAxis, XAxisLabel, YAxis, LineDotCrosshair, ChartTooltip, and chartBase SCSS mixin. Extract mouseUtils (findNearestDataIndex, findBandIndex) and fix getWidthOfGroup dead parameter. Unify XAxis to handle both ScalePoint and ScaleBand via type guard. Co-Authored-By: Claude Opus 4.6 --- .../components/ChartsV2/shared/ClipDefs.tsx | 17 +++ .../src/components/ChartsV2/shared/Grid.tsx | 24 ++++ .../ChartsV2/shared/LineDotCrosshair.tsx | 60 ++++++++++ .../shared/PortalTooltip/ChartTooltip.tsx | 113 ++++++++++++++++++ .../shared/PortalTooltip/portalTooltip.scss | 2 +- .../scrollButtonsHorizontal.scss | 2 +- .../src/components/ChartsV2/shared/XAxis.tsx | 69 +++++++++++ .../components/ChartsV2/shared/XAxisLabel.tsx | 33 +++++ .../src/components/ChartsV2/shared/YAxis.tsx | 41 +++++++ .../components/ChartsV2/shared/chartBase.scss | 85 +++++++++++++ .../src/components/ChartsV2/shared/index.ts | 11 +- .../ChartsV2/shared/useIsTruncated.ts | 23 ++++ .../src/components/ChartsV2/types/common.ts | 5 + .../src/components/ChartsV2/utils/index.ts | 1 + .../components/ChartsV2/utils/mouseUtils.ts | 25 ++++ .../components/ChartsV2/utils/scrollUtils.ts | 7 +- 16 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 packages/react-ui/src/components/ChartsV2/shared/ClipDefs.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/Grid.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/LineDotCrosshair.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/ChartTooltip.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/XAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/XAxisLabel.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/YAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/chartBase.scss create mode 100644 packages/react-ui/src/components/ChartsV2/shared/useIsTruncated.ts create mode 100644 packages/react-ui/src/components/ChartsV2/utils/mouseUtils.ts diff --git a/packages/react-ui/src/components/ChartsV2/shared/ClipDefs.tsx b/packages/react-ui/src/components/ChartsV2/shared/ClipDefs.tsx new file mode 100644 index 000000000..7bcd140cd --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/Grid.tsx b/packages/react-ui/src/components/ChartsV2/shared/Grid.tsx new file mode 100644 index 000000000..23ee2f85c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/LineDotCrosshair.tsx b/packages/react-ui/src/components/ChartsV2/shared/LineDotCrosshair.tsx new file mode 100644 index 000000000..b16e305be --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/PortalTooltip/ChartTooltip.tsx b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/ChartTooltip.tsx new file mode 100644 index 000000000..0a6680541 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/PortalTooltip/portalTooltip.scss b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss index 9baa64573..f956fce66 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss +++ b/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss @@ -1,4 +1,4 @@ -@use "../../../../cssUtils.scss" as cssUtils; +@use "../../../../cssUtils" as cssUtils; .openui-portal-tooltip { pointer-events: none; diff --git a/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss b/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss index cb6300473..b75b39b1d 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss +++ b/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss @@ -1,4 +1,4 @@ -@use "../../../../cssUtils.scss" as cssUtils; +@use "../../../../cssUtils" as cssUtils; .openui-chart-horizontal-scroll { &-buttons-container { diff --git a/packages/react-ui/src/components/ChartsV2/shared/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/XAxis.tsx new file mode 100644 index 000000000..70fdc7fad --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/XAxisLabel.tsx b/packages/react-ui/src/components/ChartsV2/shared/XAxisLabel.tsx new file mode 100644 index 000000000..02bdf77b2 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/XAxisLabel.tsx @@ -0,0 +1,33 @@ +import React, { useRef } from "react"; +import type { XAxisTickVariant } from "../types"; +import { LabelTooltip } from "./LabelTooltip/LabelTooltip"; +import { useIsTruncated } from "./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/YAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/YAxis.tsx new file mode 100644 index 000000000..6f5297213 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/chartBase.scss b/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss new file mode 100644 index 000000000..3ec16efce --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss @@ -0,0 +1,85 @@ +@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; + } +} + +@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/index.ts b/packages/react-ui/src/components/ChartsV2/shared/index.ts index d98cb501d..8a5d95efd 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/index.ts +++ b/packages/react-ui/src/components/ChartsV2/shared/index.ts @@ -1,5 +1,12 @@ +export { ClipDefs } from "./ClipDefs"; export { DefaultLegend } from "./DefaultLegend/DefaultLegend"; +export { Grid } from "./Grid"; export { LabelTooltip, LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; -export { CustomTooltipContent } from "./PortalTooltip/CustomTooltipContent"; -export { FloatingUIPortal } from "./PortalTooltip/FloatingUIPortal"; +export { LineDotCrosshair } from "./LineDotCrosshair"; +export { ChartTooltip } from "./PortalTooltip/ChartTooltip"; +export type { TooltipItem } from "./PortalTooltip/ChartTooltip"; export { ScrollButtonsHorizontal } from "./ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +export { useIsTruncated } from "./useIsTruncated"; +export { XAxis } from "./XAxis"; +export { XAxisLabel } from "./XAxisLabel"; +export { YAxis } from "./YAxis"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/useIsTruncated.ts b/packages/react-ui/src/components/ChartsV2/shared/useIsTruncated.ts new file mode 100644 index 000000000..8b4f8dd74 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/types/common.ts b/packages/react-ui/src/components/ChartsV2/types/common.ts index 4cf0defbd..d06ca46a5 100644 --- a/packages/react-ui/src/components/ChartsV2/types/common.ts +++ b/packages/react-ui/src/components/ChartsV2/types/common.ts @@ -28,4 +28,9 @@ export interface BaseChartProps { 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; } diff --git a/packages/react-ui/src/components/ChartsV2/utils/index.ts b/packages/react-ui/src/components/ChartsV2/utils/index.ts index 9f49594d2..18053a195 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/index.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/index.ts @@ -1,4 +1,5 @@ export * from "./dataUtils"; +export * from "./mouseUtils"; export * from "./paletteUtils"; 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/scrollUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts index f0e36e306..08f6fff16 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts @@ -7,7 +7,7 @@ export const getWidthOfData = (data: ChartData, containerWidth: number) => { if (data.length === 0) { return containerWidth; } - const width = data.length * getWidthOfGroup(data.length); + const width = data.length * getWidthOfGroup(); if (containerWidth >= width) { return containerWidth; @@ -42,8 +42,7 @@ export const findNearestSnapPosition = ( } }; -export const getWidthOfGroup = (dataLength: number): number => { - if (dataLength === 0) return MIN_SINGLE_POINT_WIDTH; +export const getWidthOfGroup = (): number => { return ELEMENT_SPACING; }; @@ -51,7 +50,7 @@ export const getSnapPositions = (data: ChartData): number[] => { if (data.length === 0) return [0]; const positions = [0]; - const groupWidthValue = getWidthOfGroup(data.length); + const groupWidthValue = getWidthOfGroup(); for (let i = 1; i < data.length; i++) { positions.push(i * groupWidthValue); From b845ce54b585fde7767852f8fb18dab6d33f659c Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 7 Mar 2026 07:08:59 +0530 Subject: [PATCH 06/23] refactor(ChartsV2): decompose useChartOrchestration into focused hooks Split monolithic orchestration hook into: useChartData (series/colors), useChartDimensions (sizing/layout), useChartHover (mouse interactions), useChartScroll (scroll state). Orchestration is now a thin facade. Add useXBandScale and useTooltipPayload hooks. Fix useTransformedKeys module-level counter memory leak (use useRef). Co-Authored-By: Claude Opus 4.6 --- .../src/components/ChartsV2/hooks/index.ts | 7 + .../components/ChartsV2/hooks/useChartData.ts | 107 ++++++++++++ .../ChartsV2/hooks/useChartDimensions.ts | 117 +++++++++++++ .../ChartsV2/hooks/useChartHover.ts | 62 +++++++ .../ChartsV2/hooks/useChartOrchestration.ts | 163 ++++++++++++++++++ .../ChartsV2/hooks/useChartScroll.ts | 61 +++++++ .../ChartsV2/hooks/useTooltipPayload.ts | 29 ++++ .../ChartsV2/hooks/useTransformedKeys.ts | 5 +- .../ChartsV2/hooks/useXBandScale.ts | 20 +++ 9 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useChartData.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useChartOrchestration.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useTooltipPayload.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useXBandScale.ts diff --git a/packages/react-ui/src/components/ChartsV2/hooks/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/index.ts index 6a3f21ed8..8ba44e659 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/index.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/index.ts @@ -1,9 +1,16 @@ export * from "./useCanvasContextForLabelSize"; +export * from "./useChartData"; +export * from "./useChartDimensions"; +export * from "./useChartHover"; +export * from "./useChartOrchestration"; +export * from "./useChartScroll"; export * from "./useContainerSize"; export * from "./usePrintContext"; export * from "./useStackedData"; +export * from "./useTooltipPayload"; export * from "./useTransformedKeys"; export * from "./useXAxisHeight"; +export * from "./useXBandScale"; export * from "./useXScale"; export * from "./useYAxisWidth"; export * from "./useYScale"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartData.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartData.ts new file mode 100644 index 000000000..4b09b5fd9 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartData.ts @@ -0,0 +1,107 @@ +import React, { useCallback, useMemo, useState } from "react"; + +import { get2dChartConfig, getDataKeys, getLegendItems } from "../utils/dataUtils"; +import { useChartPalette } from "../utils/paletteUtils"; +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, setHiddenSeries] = useState>(new Set()); + 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], + ); + + const toggleSeries = useCallback( + (key: string) => { + setHiddenSeries((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + if (next.size < allDataKeys.length - 1) { + next.add(key); + } + } + return next; + }); + }, + [allDataKeys.length], + ); + + return { + catKey, + allDataKeys, + dataKeys, + hiddenSeries, + toggleSeries, + colors, + transformedKeys, + chartConfig, + colorMap, + chartStyle, + legendItems, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts new file mode 100644 index 000000000..d4e027ca3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts @@ -0,0 +1,117 @@ +import React, { useEffect, useMemo, useState } from "react"; + +import { getWidthOfData, getWidthOfGroup } from "../utils/scrollUtils"; +import { useContainerSize } from "./useContainerSize"; +import { useXAxisHeight } from "./useXAxisHeight"; +import { useYAxisWidth } from "./useYAxisWidth"; + +import type { ChartData } from "../types"; + +const MARGIN_TOP = 10; +const DEFAULT_CHART_HEIGHT = 296; +const SINGLE_LINE_BREAKPOINT = 300; + +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; +} + +export function useChartDimensions({ + containerRef, + legendRef, + data, + catKey, + dataKeys, + showYAxis, + showLegend, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + condensed, +}: UseChartDimensionsParams) { + 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]); + + 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(); + + 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)), + [condensed, data, availableWidth], + ); + 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 = svgAvailableHeight - 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, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts new file mode 100644 index 000000000..a67b55c5b --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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/useChartOrchestration.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartOrchestration.ts new file mode 100644 index 000000000..54f690a17 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartOrchestration.ts @@ -0,0 +1,163 @@ +import React, { useId, useMemo, useRef, useState } from "react"; + +import { useTooltipPayload } from "./useTooltipPayload"; + +import { useChartData } from "./useChartData"; +import { useChartDimensions } from "./useChartDimensions"; +import { useChartHover } from "./useChartHover"; +import { useChartScroll } from "./useChartScroll"; + +import type { ChartData } from "../types"; +import type { PaletteName } from "../utils/paletteUtils"; + +export interface UseChartOrchestrationParams { + 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; +} + +export function useChartOrchestration({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix, + icons, + onClick, + condensed = false, +}: UseChartOrchestrationParams) { + 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, + }); + + // Hover: index, mouse position, handler factory + const hover = useChartHover({ data, onClick }); + + // Scroll: buttons, snap navigation + const scroll = useChartScroll({ + mainContainerRef, + data, + needsScroll: dimensions.needsScroll, + }); + + // 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(() => { + const s: Record = { ...chartData.chartStyle }; + if (typeof fixedWidth === "string") s["width"] = fixedWidth; + if (typeof height === "string") s["height"] = height; + return s; + }, [chartData.chartStyle, fixedWidth, height]); + + return { + // Refs + containerRef, + mainContainerRef, + legendRef, + chartId, + + // Data (spread from useChartData) + catKey: chartData.catKey, + allDataKeys: chartData.allDataKeys, + dataKeys: chartData.dataKeys, + colors: chartData.colors, + transformedKeys: chartData.transformedKeys, + widthOfGroup: dimensions.widthOfGroup, + chartConfig: chartData.chartConfig, + colorMap: chartData.colorMap, + chartStyle: chartData.chartStyle, + + // Dimensions (spread from useChartDimensions) + effectiveYAxisWidth: dimensions.effectiveYAxisWidth, + containerWidth: dimensions.containerWidth, + tickVariant: dimensions.tickVariant, + xAxisHeight: dimensions.xAxisHeight, + chartInnerHeight: dimensions.chartInnerHeight, + totalHeight: dimensions.totalHeight, + svgWidth: dimensions.svgWidth, + dataWidth: dimensions.dataWidth, + needsScroll: dimensions.needsScroll, + labelInterval: dimensions.labelInterval, + MARGIN_TOP: dimensions.MARGIN_TOP, + + // Hover (spread from useChartHover) + hoveredIndex: hover.hoveredIndex, + mousePos: hover.mousePos, + + // Scroll (spread from useChartScroll) + 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, + + // Mouse handler factory + createMouseHandlers: hover.createMouseHandlers, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts new file mode 100644 index 000000000..947f04ba7 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts @@ -0,0 +1,61 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { findNearestSnapPosition, getSnapPositions } from "../utils/scrollUtils"; + +import type { ChartData } from "../types"; + +export interface UseChartScrollParams { + mainContainerRef: React.RefObject; + data: T; + needsScroll: boolean; +} + +export function useChartScroll({ + mainContainerRef, + data, + needsScroll, +}: 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); + const idx = findNearestSnapPosition(snaps, el.scrollLeft, direction); + const target = snaps[idx] ?? 0; + el.scrollTo({ left: target, behavior: "smooth" }); + }, + [data, mainContainerRef], + ); + + return { + canScrollLeft, + canScrollRight, + handleScroll, + scrollTo, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useTooltipPayload.ts b/packages/react-ui/src/components/ChartsV2/hooks/useTooltipPayload.ts new file mode 100644 index 000000000..4fd4c4ce1 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useTooltipPayload.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react"; +import type { TooltipItem } from "../shared/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/useTransformedKeys.ts b/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts index 6d66fc5c7..b124a8cb4 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts @@ -1,15 +1,14 @@ import { useMemo, useRef } from "react"; -let nextKeyId = 0; - 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-${nextKeyId++}`; + cacheRef.current[key] = `tk-${nextIdRef.current++}`; } acc[key] = cacheRef.current[key]; return acc; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useXBandScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/useXBandScale.ts new file mode 100644 index 000000000..35ba619fd --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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]); +}; From c08e2fee59864215b7b7d84c45d682d25f10884b Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 7 Mar 2026 07:09:09 +0530 Subject: [PATCH 07/23] refactor(ChartsV2): update D3AreaChart to use shared infrastructure Migrate D3AreaChart to use shared components (Grid, XAxis, YAxis, ClipDefs) and useChartOrchestration facade. Remove per-chart Grid and YAxis duplicates. Add condensed toggle story. Simplify SCSS using chartBase mixin. Co-Authored-By: Claude Opus 4.6 --- .../ChartsV2/D3AreaChart/D3AreaChart.tsx | 431 ++++-------------- .../components/ChartsV2/D3AreaChart/DESIGN.md | 36 +- .../ChartsV2/D3AreaChart/d3AreaChart.scss | 83 +--- .../ChartsV2/D3AreaChart/parts/Crosshair.tsx | 62 +-- .../D3AreaChart/parts/GradientDefs.tsx | 7 +- .../ChartsV2/D3AreaChart/parts/Grid.tsx | 23 - .../ChartsV2/D3AreaChart/parts/XAxis.tsx | 67 +-- .../ChartsV2/D3AreaChart/parts/YAxis.tsx | 33 -- .../stories/d3AreaChart.stories.tsx | 46 ++ 9 files changed, 205 insertions(+), 583 deletions(-) delete mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx delete mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/YAxis.tsx diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx index f7c042da8..bf7246862 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx @@ -1,72 +1,26 @@ import clsx from "clsx"; -import type { ScalePoint } from "d3-scale"; -import { pointer } from "d3-selection"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback } from "react"; -import { useContainerSize } from "../hooks/useContainerSize"; +import { useChartOrchestration } from "../hooks/useChartOrchestration"; import { usePrintContext } from "../hooks/usePrintContext"; import { useStackedData } from "../hooks/useStackedData"; -import { useTransformedKeys } from "../hooks/useTransformedKeys"; -import { useXAxisHeight } from "../hooks/useXAxisHeight"; import { useXScale } from "../hooks/useXScale"; -import { useYAxisWidth } from "../hooks/useYAxisWidth"; import { useYScale } from "../hooks/useYScale"; import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { CustomTooltipContent } from "../shared/PortalTooltip/CustomTooltipContent"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -import { get2dChartConfig, getDataKeys, getLegendItems } from "../utils/dataUtils"; -import { useChartPalette } from "../utils/paletteUtils"; -import { - findNearestSnapPosition, - getSnapPositions, - getWidthOfData, - getWidthOfGroup, -} from "../utils/scrollUtils"; +import { findNearestDataIndex } from "../utils/mouseUtils"; +import { Grid } from "../shared/Grid"; +import { XAxis } from "../shared/XAxis"; +import { YAxis } from "../shared/YAxis"; import { AreaSeries } from "./parts/AreaSeries"; import { Crosshair } from "./parts/Crosshair"; import { GradientDefs } from "./parts/GradientDefs"; -import { Grid } from "./parts/Grid"; -import { XAxis } from "./parts/XAxis"; -import { YAxis } from "./parts/YAxis"; import type { D3AreaChartData, D3AreaChartProps } from "./types"; -const MARGIN_TOP = 10; -const DEFAULT_CHART_HEIGHT = 296; -const SINGLE_LINE_BREAKPOINT = 300; - -let nextChartId = 0; - -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; -} - -function getRelativePosition( - clientX: number, - clientY: number, - containerRef: React.RefObject, -): { x: number; y: number } | null { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return null; - return { - x: clientX - rect.left + (containerRef.current?.scrollLeft ?? 0), - y: clientY - rect.top, - }; -} - export function D3AreaChart({ data, categoryKey, @@ -85,263 +39,64 @@ export function D3AreaChart({ className, height, width: fixedWidth, + fitLegendInHeight, + condensed = false, onClick, }: D3AreaChartProps) { const isPrinting = usePrintContext(); - const containerRef = useRef(null); - const mainContainerRef = useRef(null); - const legendRef = useRef(null); - const chartId = useMemo(() => `d3ac-${nextChartId++}`, []); - 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]); - - const { width: containerWidth, height: containerHeight } = useContainerSize( - containerRef, - fixedWidth, - height, - ); - const catKey = String(categoryKey); - const allDataKeys = useMemo(() => getDataKeys(data, catKey), [data, catKey]); - - const [hiddenSeries, setHiddenSeries] = useState>(new Set()); - const dataKeys = useMemo( - () => allDataKeys.filter((k) => !hiddenSeries.has(k)), - [allDataKeys, hiddenSeries], - ); - - const colors = useChartPalette({ + const orch = useChartOrchestration({ + data, + categoryKey, chartThemeName, customPalette, - themePaletteName: "defaultChartPalette", - dataLength: allDataKeys.length, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3ac", + icons, + onClick, + condensed, }); - const transformedKeys = useTransformedKeys(allDataKeys); - const widthOfGroup = getWidthOfGroup(data.length); - - const tickVariant = - containerWidth < SINGLE_LINE_BREAKPOINT ? ("singleLine" as const) : tickVariantProp; + const xScale = useXScale(data, orch.catKey, orch.svgWidth, orch.widthOfGroup); + const stackedData = useStackedData(data, orch.dataKeys, stacked); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); - const xAxisHeight = useXAxisHeight(data, catKey, tickVariant, widthOfGroup); - const { yAxisWidth } = useYAxisWidth(data, dataKeys); - - const effectiveYAxisWidth = showYAxis ? yAxisWidth : 0; - 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 dataWidth = useMemo( - () => getWidthOfData(data, containerWidth - effectiveYAxisWidth), - [data, containerWidth, effectiveYAxisWidth], - ); - const needsScroll = dataWidth > containerWidth - effectiveYAxisWidth; - - const resolvedHeight = - typeof height === "number" - ? height - : containerHeight > 0 - ? containerHeight - : DEFAULT_CHART_HEIGHT; - const svgAvailableHeight = height ? resolvedHeight - legendHeight : resolvedHeight; - const chartInnerHeight = svgAvailableHeight - MARGIN_TOP - xAxisHeight; - const totalHeight = svgAvailableHeight; - const svgWidth = needsScroll ? dataWidth : containerWidth - effectiveYAxisWidth; - - const xScale = useXScale(data, catKey, svgWidth, widthOfGroup); - const stackedData = useStackedData(data, dataKeys, stacked); - const yScale = useYScale(data, dataKeys, chartInnerHeight, stackedData); - - const [hoveredIndex, setHoveredIndex] = useState(null); - const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); - - const handleMouseMove = useCallback( - (event: React.MouseEvent) => { - const [mouseX] = pointer(event.nativeEvent, event.currentTarget); - setHoveredIndex(findNearestDataIndex(xScale, mouseX)); - setMousePos(getRelativePosition(event.clientX, event.clientY, mainContainerRef)); - }, - [xScale], - ); - - const handleMouseLeave = useCallback(() => { - setHoveredIndex(null); - setMousePos(null); - }, []); - - const handleTouchMove = useCallback( - (event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const svgRect = event.currentTarget.getBoundingClientRect(); - const mouseX = touch.clientX - svgRect.left; - setHoveredIndex(findNearestDataIndex(xScale, mouseX)); - setMousePos(getRelativePosition(touch.clientX, touch.clientY, mainContainerRef)); - }, - [xScale], - ); - - const handleTouchEnd = useCallback(() => { - setHoveredIndex(null); - setMousePos(null); - }, []); - - const handleClick = useCallback( - (event: React.MouseEvent) => { - if (!onClick) return; - const [mouseX] = pointer(event.nativeEvent, event.currentTarget); - const idx = findNearestDataIndex(xScale, mouseX); - if (idx >= 0 && idx < data.length) { - onClick(data[idx]!, idx); - } - }, - [onClick, xScale, data], - ); - - 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]); - - const handleScroll = useCallback(() => { - const el = mainContainerRef.current; - if (!el) return; - setCanScrollLeft(el.scrollLeft > 1); - setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1); - }, []); - - const scrollTo = useCallback( - (direction: "left" | "right") => { - const el = mainContainerRef.current; - if (!el) return; - const snaps = getSnapPositions(data); - const idx = findNearestSnapPosition(snaps, el.scrollLeft, direction); - const target = snaps[idx] ?? 0; - el.scrollTo({ left: target, behavior: "smooth" }); - }, - [data], - ); - - const legendItems = useMemo( - () => getLegendItems(allDataKeys, colors, icons), - [allDataKeys, colors, icons], - ); - const [isLegendExpanded, setIsLegendExpanded] = useState(false); - - const handleLegendItemClick = useCallback( - (key: string) => { - setHiddenSeries((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - if (next.size < allDataKeys.length - 1) { - next.add(key); - } - } - return next; - }); - }, - [allDataKeys.length], - ); - - const tooltipPayload = useMemo(() => { - if (hoveredIndex === null || hoveredIndex >= data.length) return null; - const row = data[hoveredIndex]!; - return { - active: true, - label: String(row[catKey]), - payload: dataKeys.map((key) => ({ - name: key, - dataKey: key, - value: Number(row[key]) || 0, - color: chartConfig[key]?.color ?? "#000", - payload: row, - })), - }; - }, [hoveredIndex, data, dataKeys, catKey, chartConfig]); + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); if (!data || data.length === 0) { return
; } - const containerStyle = useMemo(() => { - const s: Record = { ...chartStyle }; - if (typeof fixedWidth === "string") s["width"] = fixedWidth; - if (typeof height === "string") s["height"] = height; - return s; - }, [chartStyle, fixedWidth, height]); - return (
{showYAxis && (
- - + + @@ -349,13 +104,15 @@ export function D3AreaChart({ )}
({ onClick={handleClick} > - + {grid && ( - + )} - + @@ -414,37 +181,33 @@ export function D3AreaChart({
scrollTo("left")} - onScrollRight={() => scrollTo("right")} + dataWidth={orch.dataWidth} + effectiveWidth={orch.containerWidth - orch.effectiveYAxisWidth} + canScrollLeft={orch.canScrollLeft} + canScrollRight={orch.canScrollRight} + onScrollLeft={() => orch.scrollTo("left")} + onScrollRight={() => orch.scrollTo("right")} /> {showLegend && ( )} - {tooltipPayload && mousePos && ( - )}
diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md b/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md index 7259d7d01..ae01f085f 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md @@ -45,8 +45,7 @@ ChartsV2/ │ │ └── hooks/ │ │ └── useDefaultLegend.ts │ ├── PortalTooltip/ ← @floating-ui tooltip -│ │ ├── CustomTooltipContent.tsx ← adapted for D3 payload shape -│ │ ├── FloatingUIPortal.tsx +│ │ ├── ChartTooltip.tsx ← virtual-element tooltip (replaces CustomTooltipContent + FloatingUIPortal) │ │ ├── portalTooltip.scss │ │ └── utils/index.ts │ ├── ScrollButtonsHorizontal/ @@ -334,25 +333,22 @@ User mouses over chart body → Tooltip renders via FloatingUIPortal at mouse position ``` -The tooltip payload is constructed to match the existing `CustomTooltipContent` contract: +The tooltip payload is constructed by the `useTooltipPayload` hook: ```typescript -// Payload shape for tooltip +interface TooltipItem { + name: string; // series key + value: number; // series value at hovered index + color: string; // series color +} + interface TooltipPayload { - active: boolean; - label: string; // category value at hovered index - coordinate: { x: number; y: number }; - payload: Array<{ - name: string; // series key - dataKey: string; // series key - value: number; // series value at hovered index - color: string; // series color - payload: DataRow; // full data row - }>; + label: string; // category value at hovered index + items: TooltipItem[]; } ``` -The tooltip is positioned using the mouse event coordinates relative to the chart container, then `FloatingUIPortal` handles placement and flipping. +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. @@ -608,8 +604,8 @@ Each of these is copied from `Charts/shared/` into `ChartsV2/shared/` with modif ### `PortalTooltip` - **Source**: `Charts/shared/PortalTooltip/` -- **Changes**: Decouple `CustomTooltipContent` from `useChart()` recharts context. Instead, accept tooltip data via props. Keep `FloatingUIPortal` as-is. -- **Files**: `CustomTooltipContent.tsx`, `FloatingUIPortal.tsx`, `portalTooltip.scss`, `utils/index.ts` +- **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` @@ -751,9 +747,7 @@ Props (data, categoryKey, theme, stacked, variant, ...) │
│ │ │ │ │ -│ │ ← portal tooltip -│ │ -│ │ +│ │ ← portal tooltip (virtual element) │
│ └─────────────────────────────────────────────┘ ``` @@ -769,7 +763,7 @@ Props (data, categoryKey, theme, stacked, variant, ...) | `` | `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` → `FloatingUIPortal` | Decoupled from recharts context | +| `` | `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 | diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss index d552dd494..2704aefe4 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss @@ -1,74 +1,7 @@ -@use "../../../cssUtils" as cssUtils; +@use "../shared/chartBase" as base; -.openui-d3-area-chart { - &-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-area-chart-grid line { - stroke: cssUtils.$border-default; - stroke-dasharray: 3 3; -} - -.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; - 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; -} - -.openui-d3-area-chart-crosshair-line { - stroke: cssUtils.$border-default; - stroke-width: 1; - stroke-dasharray: 4 4; -} +@include base.chart-base("area-chart"); +@include base.crosshair-styles("area-chart"); .openui-d3-area-chart-area-path { transition: @@ -82,16 +15,6 @@ transition: d 0.3s ease-out; } -.openui-d3-area-chart-active-dot-outer { - fill: cssUtils.$foreground; - stroke: cssUtils.$border-default; - stroke-width: 1; -} - -.openui-d3-area-chart-active-dot-inner { - stroke: none; -} - .openui-d3-area-chart-area-line--animated { stroke-dasharray: var(--path-length); stroke-dashoffset: var(--path-length); diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx index d6b3ae007..04c5fae56 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx @@ -1,6 +1,7 @@ import type { ScaleLinear, ScalePoint } from "d3-scale"; -import React from "react"; +import React, { useCallback } from "react"; import type { StackedData } from "../../hooks/useStackedData"; +import { LineDotCrosshair } from "../../shared/LineDotCrosshair"; interface CrosshairProps { hoveredIndex: number | null; @@ -25,43 +26,30 @@ export const Crosshair: React.FC = ({ stackedData, 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 getYValue = useCallback( + (_row: Record, key: string, seriesIndex: number) => { + if (stackedData && hoveredIndex !== null) { + const series = stackedData[seriesIndex]; + const point = series?.[hoveredIndex]; + return point ? point[1] : 0; + } + return Number(_row[key]) || 0; + }, + [stackedData, hoveredIndex], + ); return ( - - - {dataKeys.map((key, seriesIndex) => { - let yValue: number; - if (stackedData) { - const series = stackedData[seriesIndex]; - const point = series?.[hoveredIndex]; - yValue = point ? point[1] : 0; - } else { - yValue = Number(row[key]) || 0; - } - - const y = yScale(yValue); - const color = colors[key] ?? "#000"; - - 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 index a2396d04d..e4a75dae0 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx @@ -1,9 +1,8 @@ import React from "react"; +import { ClipDefs } from "../../shared/ClipDefs"; const GRADIENT_TOP_OPACITY = 0.6; const GRADIENT_BOTTOM_OPACITY = 0; -/** Extra space above/below the clip rect so strokes and dots at the chart edges aren't clipped. */ -const CLIP_OVERFLOW = 6; interface GradientDefsProps { dataKeys: string[]; @@ -24,9 +23,7 @@ export const GradientDefs: React.FC = ({ }) => { return ( - - - + {dataKeys.map((key) => { const transformedKey = transformedKeys[key]; const color = colors[key] ?? "#000"; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx deleted file mode 100644 index 983567353..000000000 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Grid.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { ScaleLinear } from "d3-scale"; -import React from "react"; - -interface GridProps { - yScale: ScaleLinear; - chartWidth: number; - chartHeight: number; -} - -const MIN_TICK_SPACING = 40; - -export const Grid: React.FC = ({ yScale, chartWidth, chartHeight }) => { - 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/D3AreaChart/parts/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx index 319c4196e..764da0096 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx @@ -1,6 +1,6 @@ import type { ScalePoint } from "d3-scale"; -import React, { useRef } from "react"; -import { LabelTooltip } from "../../shared/LabelTooltip/LabelTooltip"; +import React from "react"; +import { XAxisLabel } from "../../shared/XAxisLabel"; import { XAxisTickVariant } from "../../types"; const X_AXIS_TOP_GAP = 4; @@ -12,23 +12,26 @@ interface XAxisProps { tickVariant: XAxisTickVariant; widthOfGroup: number; labelHeight: number; + labelInterval?: number; } export const XAxis: React.FC = ({ scale, - data, - categoryKey, + data: _data, + categoryKey: _categoryKey, tickVariant, widthOfGroup, labelHeight, + labelInterval = 1, }) => { const domain = scale.domain(); return ( - {domain.map((category) => { + {domain.map((category, i) => { const x = scale(category) ?? 0; const label = String(category); + const showLabel = labelInterval <= 1 || i % labelInterval === 0 || i === domain.length - 1; return ( = ({ width={widthOfGroup} height={labelHeight} > - + {showLabel && ( + + )} ); })} ); }; - -const XAxisLabel: React.FC<{ - label: string; - tickVariant: XAxisTickVariant; - width: number; -}> = ({ label, tickVariant, width }) => { - const labelRef = useRef(null); - const isTruncated = useIsTruncated(labelRef); - - const className = - tickVariant === "multiLine" - ? "openui-d3-area-chart-x-tick-multi-line" - : "openui-d3-area-chart-x-tick-single-line"; - - return ( - -
- {label} -
-
- ); -}; - -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/D3AreaChart/parts/YAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/YAxis.tsx deleted file mode 100644 index dbee90de1..000000000 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/YAxis.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { ScaleLinear } from "d3-scale"; -import React from "react"; -import { numberTickFormatter } from "../../utils/styleUtils"; - -interface YAxisProps { - scale: ScaleLinear; - width: number; - chartHeight: number; -} - -const MIN_TICK_SPACING = 40; - -export const YAxis: React.FC = ({ scale, width, chartHeight }) => { - 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/D3AreaChart/stories/d3AreaChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx index 93ad5c507..5eb550651 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx @@ -892,6 +892,52 @@ export const OnClickHandler: Story = { }, }; +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: () => { From 98981efba0f5708347cc39600bcc91fac9356616 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 7 Mar 2026 07:09:16 +0530 Subject: [PATCH 08/23] feat(ChartsV2): add D3BarChart component New D3-based bar chart supporting grouped and stacked variants. Uses shared infrastructure (XAxis, YAxis, Grid, ChartTooltip) and useChartOrchestration. Includes BarSeries with configurable radius, max width, internal lines, and entry animations. Co-Authored-By: Claude Opus 4.6 --- .../ChartsV2/D3BarChart/D3BarChart.tsx | 219 ++++++++ .../ChartsV2/D3BarChart/d3BarChart.scss | 35 ++ .../components/ChartsV2/D3BarChart/index.ts | 2 + .../ChartsV2/D3BarChart/parts/BarSeries.tsx | 185 ++++++ .../ChartsV2/D3BarChart/parts/Crosshair.tsx | 38 ++ .../ChartsV2/D3BarChart/parts/XAxis.tsx | 57 ++ .../D3BarChart/stories/d3BarChart.stories.tsx | 528 ++++++++++++++++++ .../components/ChartsV2/D3BarChart/types.ts | 19 + 8 files changed, 1083 insertions(+) create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/parts/BarSeries.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/parts/Crosshair.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/parts/XAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/stories/d3BarChart.stories.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/types.ts 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..dc4882dfa --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx @@ -0,0 +1,219 @@ +import clsx from "clsx"; +import React, { useCallback } from "react"; + +import { useChartOrchestration } from "../hooks/useChartOrchestration"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useStackedData } from "../hooks/useStackedData"; +import { useXBandScale } from "../hooks/useXBandScale"; +import { useYScale } from "../hooks/useYScale"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +import { findBandIndex } from "../utils/mouseUtils"; + +import { ClipDefs } from "../shared/ClipDefs"; +import { Grid } from "../shared/Grid"; +import { XAxis } from "../shared/XAxis"; +import { YAxis } from "../shared/YAxis"; +import { BarSeries } from "./parts/BarSeries"; +import { Crosshair } from "./parts/Crosshair"; + +import type { D3BarChartData, D3BarChartProps } from "./types"; + +export function D3BarChart({ + 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, + onClick, +}: D3BarChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartOrchestration({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3bc", + icons, + onClick, + condensed, + }); + + const isStacked = variant === "stacked"; + const xScale = useXBandScale(data, orch.catKey, orch.svgWidth); + const stackedData = useStackedData(data, orch.dataKeys, isStacked); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + + const findIndex = useCallback((mouseX: number) => findBandIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); + + if (!data || data.length === 0) { + return
; + } + + return ( + +
+
+ {showYAxis && ( +
+ + + + + +
+ )} + +
+ + + + + + {grid && ( + + )} + + + + + + + +
+
+ + orch.scrollTo("left")} + onScrollRight={() => orch.scrollTo("right")} + /> + + {showLegend && ( + + )} + + {orch.tooltipPayload && orch.mousePos && ( + + )} +
+
+ ); +} 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..ad3b79462 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss @@ -0,0 +1,35 @@ +@use "../../../cssUtils" as cssUtils; +@use "../shared/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..866e792e7 --- /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/useStackedData"; +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/parts/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/XAxis.tsx new file mode 100644 index 000000000..3a8a3bc23 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/XAxis.tsx @@ -0,0 +1,57 @@ +import type { ScaleBand } from "d3-scale"; +import React from "react"; +import { XAxisLabel } from "../../shared/XAxisLabel"; +import { XAxisTickVariant } from "../../types"; + +const X_AXIS_TOP_GAP = 4; + +interface XAxisProps { + scale: ScaleBand; + data: Array>; + categoryKey: string; + tickVariant: XAxisTickVariant; + labelHeight: number; + labelInterval?: number; +} + +export const XAxis: React.FC = ({ + scale, + data: _data, + categoryKey: _categoryKey, + tickVariant, + labelHeight, + labelInterval = 1, +}) => { + const domain = scale.domain(); + const bandWidth = scale.bandwidth(); + + return ( + + {domain.map((category, i) => { + const x = scale(category) ?? 0; + 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/D3BarChart/stories/d3BarChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/stories/d3BarChart.stories.tsx new file mode 100644 index 000000000..b7991ac0f --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/stories/d3BarChart.stories.tsx @@ -0,0 +1,528 @@ +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", + render: () => ( +
+
+

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

+ + + +
+
+

+ condensed stacked — 12 months in 400px card +

+ + + +
+
+

+ normal (default) — same data scrolls +

+ + + +
+
+ ), +}; 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; +} From 9aca02262f18ef97e8d5d105c56cfa90554a7786 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 7 Mar 2026 07:09:23 +0530 Subject: [PATCH 09/23] feat(ChartsV2): add D3LineChart component New D3-based line chart supporting linear, natural (monotoneX), and step curve variants. Uses shared infrastructure and orchestration. Includes LineSeries with optional dots and Crosshair with vertical line + dot indicators. Co-Authored-By: Claude Opus 4.6 --- .../ChartsV2/D3LineChart/D3LineChart.tsx | 212 ++++++++ .../ChartsV2/D3LineChart/d3LineChart.scss | 31 ++ .../components/ChartsV2/D3LineChart/index.ts | 2 + .../ChartsV2/D3LineChart/parts/Crosshair.tsx | 23 + .../ChartsV2/D3LineChart/parts/LineSeries.tsx | 106 ++++ .../ChartsV2/D3LineChart/parts/XAxis.tsx | 58 +++ .../stories/d3LineChart.stories.tsx | 457 ++++++++++++++++++ .../components/ChartsV2/D3LineChart/types.ts | 12 + 8 files changed, 901 insertions(+) create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/parts/Crosshair.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/parts/LineSeries.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/parts/XAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/stories/d3LineChart.stories.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/types.ts 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..dd4f8a80f --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx @@ -0,0 +1,212 @@ +import clsx from "clsx"; +import React, { useCallback } from "react"; + +import { useChartOrchestration } from "../hooks/useChartOrchestration"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useXScale } from "../hooks/useXScale"; +import { useYScale } from "../hooks/useYScale"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +import { findNearestDataIndex } from "../utils/mouseUtils"; + +import { ClipDefs } from "../shared/ClipDefs"; +import { Grid } from "../shared/Grid"; +import { XAxis } from "../shared/XAxis"; +import { YAxis } from "../shared/YAxis"; +import { Crosshair } from "./parts/Crosshair"; +import { LineSeries } from "./parts/LineSeries"; + +import type { D3LineChartData, D3LineChartProps } from "./types"; + +export function D3LineChart({ + 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, + onClick, +}: D3LineChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartOrchestration({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3lc", + icons, + onClick, + condensed, + }); + + const xScale = useXScale(data, orch.catKey, orch.svgWidth, orch.widthOfGroup); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, null); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); + + if (!data || data.length === 0) { + return
; + } + + return ( + +
+
+ {showYAxis && ( +
+ + + + + +
+ )} + +
+ + + + + + {grid && ( + + )} + + + + + + + +
+
+ + orch.scrollTo("left")} + onScrollRight={() => orch.scrollTo("right")} + /> + + {showLegend && ( + + )} + + {orch.tooltipPayload && orch.mousePos && ( + + )} +
+
+ ); +} 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..f2f2a5afa --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss @@ -0,0 +1,31 @@ +@use "../../../cssUtils" as cssUtils; +@use "../shared/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/Crosshair.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/Crosshair.tsx new file mode 100644 index 000000000..73c0016c3 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/Crosshair.tsx @@ -0,0 +1,23 @@ +import type { ScaleLinear, ScalePoint } from "d3-scale"; +import React, { useCallback } from "react"; +import { LineDotCrosshair } from "../../shared/LineDotCrosshair"; + +interface CrosshairProps { + hoveredIndex: number | null; + xScale: ScalePoint; + yScale: ScaleLinear; + data: Array>; + dataKeys: string[]; + categoryKey: string; + colors: Record; + chartHeight: number; +} + +export const Crosshair: React.FC = (props) => { + const getYValue = useCallback( + (row: Record, key: string) => Number(row[key]) || 0, + [], + ); + + return ; +}; 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/parts/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/XAxis.tsx new file mode 100644 index 000000000..675d21689 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/XAxis.tsx @@ -0,0 +1,58 @@ +import type { ScalePoint } from "d3-scale"; +import React from "react"; +import { XAxisLabel } from "../../shared/XAxisLabel"; +import { XAxisTickVariant } from "../../types"; + +const X_AXIS_TOP_GAP = 4; + +interface XAxisProps { + scale: ScalePoint; + data: Array>; + categoryKey: string; + tickVariant: XAxisTickVariant; + widthOfGroup: number; + labelHeight: number; + labelInterval?: number; +} + +export const XAxis: React.FC = ({ + scale, + data: _data, + categoryKey: _categoryKey, + tickVariant, + widthOfGroup, + labelHeight, + labelInterval = 1, +}) => { + const domain = scale.domain(); + + return ( + + {domain.map((category, i) => { + const x = scale(category) ?? 0; + 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/D3LineChart/stories/d3LineChart.stories.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/stories/d3LineChart.stories.tsx new file mode 100644 index 000000000..e4c1cb159 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/stories/d3LineChart.stories.tsx @@ -0,0 +1,457 @@ +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; +} From f8c88248215cd825683810881d8a3c7e7dc393dc Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 7 Mar 2026 07:09:30 +0530 Subject: [PATCH 10/23] feat(ChartsV2): export D3BarChart and D3LineChart from package Wire up ChartsV2 barrel exports and SCSS imports for new chart components. Export from react-ui package entry point. Co-Authored-By: Claude Opus 4.6 --- packages/react-ui/src/components/ChartsV2/chartsV2.scss | 2 ++ packages/react-ui/src/components/ChartsV2/index.ts | 8 ++++++++ packages/react-ui/src/index.ts | 1 + 3 files changed, 11 insertions(+) diff --git a/packages/react-ui/src/components/ChartsV2/chartsV2.scss b/packages/react-ui/src/components/ChartsV2/chartsV2.scss index fb0843e94..63b8d1d15 100644 --- a/packages/react-ui/src/components/ChartsV2/chartsV2.scss +++ b/packages/react-ui/src/components/ChartsV2/chartsV2.scss @@ -1,4 +1,6 @@ @forward "./D3AreaChart/d3AreaChart"; +@forward "./D3LineChart/d3LineChart"; +@forward "./D3BarChart/d3BarChart"; @forward "./shared/PortalTooltip/portalTooltip"; @forward "./shared/DefaultLegend/defaultLegend"; @forward "./shared/ScrollButtonsHorizontal/scrollButtonsHorizontal"; diff --git a/packages/react-ui/src/components/ChartsV2/index.ts b/packages/react-ui/src/components/ChartsV2/index.ts index eae74a6d7..108dc50a3 100644 --- a/packages/react-ui/src/components/ChartsV2/index.ts +++ b/packages/react-ui/src/components/ChartsV2/index.ts @@ -1,2 +1,10 @@ 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 type { BaseChartProps, ChartData, LegendItem, XAxisTickVariant } from "./types"; diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index eea2745ae..575f4473e 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"; From 3f5897bb4bb1af21927d18b18675b2fd881caf6a Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 7 Mar 2026 19:47:39 +0530 Subject: [PATCH 11/23] fix(ChartsV2): resolve rendering issues in D3AreaChart Addressed chart overflow when fixed pixel dimensions are set by adjusting the main container's layout. Improved type safety across sub-components and added support for onClick handlers. Updated Storybook stories to reflect new features and layout changes. Co-Authored-By: Claude Opus 4.6 --- .../src/components/ChartsV2/README.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 packages/react-ui/src/components/ChartsV2/README.md 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..6fa77df90 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/README.md @@ -0,0 +1,168 @@ +# ChartsV2 — Internal Developer Guide + +D3-based chart components for OpenUI. Three chart types share a common infrastructure of hooks, components, and utilities. + +| 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 | + +## Architecture + +``` text +┌────────────────────────────────────────────────-─┐ +│ D3[Type]Chart Component │ +│ │ +│ useChartOrchestration ◄── master hook │ +│ ├── useChartData (keys, colors, legend) │ +│ ├── useChartDimensions (sizing, layout) │ +│ ├── useChartHover (hover state, handlers)│ +│ └── useChartScroll (scroll state) │ +│ │ +│ + type-specific scale hook (useXScale or │ +│ useXBandScale) + useYScale + useStackedData │ +│ │ +│ Renders: │ +│ ├── YAxis (separate SVG) │ +│ ├── Main SVG (scrollable container) │ +│ │ ├── Grid, Series, Crosshair │ +│ │ └── XAxis │ +│ ├── ScrollButtonsHorizontal │ +│ ├── DefaultLegend │ +│ └── ChartTooltip (portal) │ +└───────────────────────────────────────────────-──┘ +``` + +## Hooks Reference + +All hooks are in `hooks/` and re-exported from `hooks/index.ts`. + +### Orchestration + +| Hook | Signature | Description | +| ----------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useChartOrchestration` | `(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 charts. | + +### Data + +| 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. | +| `useStackedData` | `(data, dataKeys, stacked) => StackedData \| null` | Runs D3 `stack()` generator. Returns null when stacking is disabled. Used by AreaChart (stacked) and BarChart (stacked variant). | +| `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. | + +### Scales + +| 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 | Signature | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useChartDimensions` | `(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` | `(ref, fixedWidth?, fixedHeight?) => { width, height }` | Measures container via ResizeObserver. Supports fixed overrides that bypass measurement. | +| `useXAxisHeight` | `(data, categoryKey, tickVariant, widthOfGroup?) => number` | Measures required X-axis label height by rendering hidden DOM elements. Returns 30px for `singleLine` variant. | +| `useYAxisWidth` | `(data, dataKeys) => { yAxisWidth, setLabelWidth }` | Measures Y-axis width from formatted numbers. Clamped between 20–200px. `setLabelWidth` allows runtime refinement. | +| `useCanvasContextForLabelSize` | `() => CanvasRenderingContext2D` | Returns a memoized canvas 2D context configured with theme font, used for text measurement. | + +### Interaction + +| Hook | Signature | Description | +| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `useChartHover` | `(params) => { hoveredIndex, mousePos, createMouseHandlers }` | Manages hover state. `createMouseHandlers(findIndex)` is a factory that returns `handleMouseMove`, `handleMouseLeave`, `handleTouchMove`, `handleTouchEnd`, and `handleClick`. | +| `useChartScroll` | `(params) => { canScrollLeft, canScrollRight, handleScroll, scrollTo }` | Manages horizontal scroll state with snap-to-data-point behavior. `scrollTo('left' \| 'right')` uses smooth scrolling. | + +### Context + +| Hook | Signature | Description | +| ----------------- | --------------- | ------------------------------------------------------------------------------------------- | +| `usePrintContext` | `() => 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. | +| `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. | + +### 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. | + +## Adding a New Chart + +To add a new chart type (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 `useChartOrchestration`** as the primary hook — it provides data, dimensions, hover, scroll, legend, and tooltip state out of the box. + +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. **Follow the render pattern** — LabelTooltipProvider > container > Y-axis SVG + scrollable main SVG + ScrollButtons + Legend + ChartTooltip. + +7. **Export** from `D3ScatterChart/index.ts` and add to `ChartsV2/index.ts`. From 2fbfbe1bd62a108baabe71aa9db5189e30dacd53 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 00:21:29 +0530 Subject: [PATCH 12/23] refactor(ChartsV2): add condensed orchestrator and supporting hooks Rename useChartOrchestration to useChartScrollableOrchestrator and add new hooks for the condensed chart variant: useChartCondensedOrchestrator, useAutoAngleCalculation, useLegendHeight, and useMaxLabelWidth. Co-Authored-By: Claude Opus 4.6 --- .../src/components/ChartsV2/hooks/index.ts | 6 +- .../ChartsV2/hooks/useAutoAngleCalculation.ts | 67 +++++++++ .../hooks/useChartCondensedOrchestrator.ts | 136 ++++++++++++++++++ ...n.ts => useChartScrollableOrchestrator.ts} | 17 ++- .../ChartsV2/hooks/useLegendHeight.ts | 26 ++++ .../ChartsV2/hooks/useMaxLabelWidth.ts | 34 +++++ 6 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useAutoAngleCalculation.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts rename packages/react-ui/src/components/ChartsV2/hooks/{useChartOrchestration.ts => useChartScrollableOrchestrator.ts} (89%) create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useLegendHeight.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/useMaxLabelWidth.ts diff --git a/packages/react-ui/src/components/ChartsV2/hooks/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/index.ts index 8ba44e659..8efc27c7a 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/index.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/index.ts @@ -1,10 +1,14 @@ +export * from "./useAutoAngleCalculation"; export * from "./useCanvasContextForLabelSize"; +export * from "./useChartCondensedOrchestrator"; export * from "./useChartData"; export * from "./useChartDimensions"; export * from "./useChartHover"; -export * from "./useChartOrchestration"; export * from "./useChartScroll"; +export * from "./useChartScrollableOrchestrator"; export * from "./useContainerSize"; +export * from "./useLegendHeight"; +export * from "./useMaxLabelWidth"; export * from "./usePrintContext"; export * from "./useStackedData"; export * from "./useTooltipPayload"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useAutoAngleCalculation.ts b/packages/react-ui/src/components/ChartsV2/hooks/useAutoAngleCalculation.ts new file mode 100644 index 000000000..7812a0d2c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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/useChartCondensedOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts new file mode 100644 index 000000000..6677905e1 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts @@ -0,0 +1,136 @@ +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 { useAutoAngleCalculation } from "./useAutoAngleCalculation"; +import { useChartData } from "./useChartData"; +import { useChartHover } from "./useChartHover"; +import { useContainerSize } from "./useContainerSize"; +import { useLegendHeight } from "./useLegendHeight"; +import { useMaxLabelWidth } from "./useMaxLabelWidth"; +import { useTooltipPayload } from "./useTooltipPayload"; +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 { + containerRef, + legendRef, + chartId, + + catKey: chartData.catKey, + dataKeys: chartData.dataKeys, + colorMap: chartData.colorMap, + chartConfig: chartData.chartConfig, + chartStyle: chartData.chartStyle, + transformedKeys: chartData.transformedKeys, + legendItems: chartData.legendItems, + hiddenSeries: chartData.hiddenSeries, + toggleSeries: chartData.toggleSeries, + + effectiveYAxisWidth, + chartAreaWidth, + widthPerDataPoint, + containerWidth, + angle, + xAxisHeight, + chartInnerHeight, + totalSvgHeight, + totalSvgWidth, + CHART_MARGIN_TOP, + + hoveredIndex: hover.hoveredIndex, + mousePos: hover.mousePos, + createMouseHandlers: hover.createMouseHandlers, + + tooltipPayload, + containerStyle, + + isLegendExpanded, + setIsLegendExpanded, + }; +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartOrchestration.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts similarity index 89% rename from packages/react-ui/src/components/ChartsV2/hooks/useChartOrchestration.ts rename to packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts index 54f690a17..95390f69c 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartOrchestration.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts @@ -1,5 +1,6 @@ import React, { useId, useMemo, useRef, useState } from "react"; +import { buildContainerStyle } from "../utils/buildContainerStyle"; import { useTooltipPayload } from "./useTooltipPayload"; import { useChartData } from "./useChartData"; @@ -10,7 +11,7 @@ import { useChartScroll } from "./useChartScroll"; import type { ChartData } from "../types"; import type { PaletteName } from "../utils/paletteUtils"; -export interface UseChartOrchestrationParams { +export interface UseChartScrollableOrchestratorParams { data: T; categoryKey: keyof T[number]; chartThemeName: PaletteName; @@ -27,7 +28,7 @@ export interface UseChartOrchestrationParams { condensed?: boolean; } -export function useChartOrchestration({ +export function useChartScrollableOrchestrator({ data, categoryKey, chartThemeName, @@ -42,7 +43,7 @@ export function useChartOrchestration({ icons, onClick, condensed = false, -}: UseChartOrchestrationParams) { +}: UseChartScrollableOrchestratorParams) { const containerRef = useRef(null); const mainContainerRef = useRef(null); const legendRef = useRef(null); @@ -96,12 +97,10 @@ export function useChartOrchestration({ ); // Container style - const containerStyle = useMemo(() => { - const s: Record = { ...chartData.chartStyle }; - if (typeof fixedWidth === "string") s["width"] = fixedWidth; - if (typeof height === "string") s["height"] = height; - return s; - }, [chartData.chartStyle, fixedWidth, height]); + const containerStyle = useMemo( + () => buildContainerStyle(chartData.chartStyle, fixedWidth, height), + [chartData.chartStyle, fixedWidth, height], + ); return { // Refs diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useLegendHeight.ts b/packages/react-ui/src/components/ChartsV2/hooks/useLegendHeight.ts new file mode 100644 index 000000000..1011b599a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/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/useMaxLabelWidth.ts b/packages/react-ui/src/components/ChartsV2/hooks/useMaxLabelWidth.ts new file mode 100644 index 000000000..d7e018481 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/useMaxLabelWidth.ts @@ -0,0 +1,34 @@ +import { useMemo } from "react"; + +import { useCanvasContextForLabelSize } from "./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]); +}; From 008f7e9c4bf7842b2563c595dbdef854742a49d8 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 00:21:40 +0530 Subject: [PATCH 13/23] feat(ChartsV2): add shared utils and AngledXAxis for condensed mode Add AngledXAxis component for rotated SVG text labels, layout constants (CHART_MARGIN_TOP, DEFAULT_CHART_HEIGHT, etc.), and buildContainerStyle utility for merging CSS variables with dimension overrides. Co-Authored-By: Claude Opus 4.6 --- .../ChartsV2/shared/AngledXAxis.tsx | 52 +++++++++++++++++++ .../components/ChartsV2/shared/chartBase.scss | 5 ++ .../src/components/ChartsV2/shared/index.ts | 1 + .../ChartsV2/utils/buildContainerStyle.ts | 10 ++++ .../components/ChartsV2/utils/constants.ts | 4 ++ .../src/components/ChartsV2/utils/index.ts | 2 + 6 files changed, 74 insertions(+) create mode 100644 packages/react-ui/src/components/ChartsV2/shared/AngledXAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/utils/buildContainerStyle.ts create mode 100644 packages/react-ui/src/components/ChartsV2/utils/constants.ts diff --git a/packages/react-ui/src/components/ChartsV2/shared/AngledXAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/AngledXAxis.tsx new file mode 100644 index 000000000..1d8db06e0 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/chartBase.scss b/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss index 3ec16efce..eb0c29fc7 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss +++ b/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss @@ -64,6 +64,11 @@ text-overflow: ellipsis; text-align: center; } + + .openui-d3-#{$prefix}-x-tick-angled { + @include cssUtils.typography(label, extra-small); + fill: cssUtils.$text-neutral-secondary; + } } @mixin crosshair-styles($prefix) { diff --git a/packages/react-ui/src/components/ChartsV2/shared/index.ts b/packages/react-ui/src/components/ChartsV2/shared/index.ts index 8a5d95efd..b9cefca9f 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/index.ts +++ b/packages/react-ui/src/components/ChartsV2/shared/index.ts @@ -1,3 +1,4 @@ +export { AngledXAxis } from "./AngledXAxis"; export { ClipDefs } from "./ClipDefs"; export { DefaultLegend } from "./DefaultLegend/DefaultLegend"; export { Grid } from "./Grid"; 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/index.ts b/packages/react-ui/src/components/ChartsV2/utils/index.ts index 18053a195..352642b36 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/index.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/index.ts @@ -1,3 +1,5 @@ +export * from "./buildContainerStyle"; +export * from "./constants"; export * from "./dataUtils"; export * from "./mouseUtils"; export * from "./paletteUtils"; From e288bd088a075e831bd2f938d1d8c14cf893ea43 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 00:21:51 +0530 Subject: [PATCH 14/23] refactor(ChartsV2): split charts into Scrollable and Condensed variants Each chart type (Area, Bar, Line) now has separate Scrollable and Condensed implementations. Router components delegate based on the condensed prop. Condensed variant fits all data in container width with angled X-axis labels; Scrollable variant uses horizontal snap scrolling with split SVG layout. Co-Authored-By: Claude Opus 4.6 --- .../ChartsV2/D3AreaChart/D3AreaChart.tsx | 217 +---------------- .../D3AreaChart/D3AreaChartCondensed.tsx | 188 +++++++++++++++ .../D3AreaChart/D3AreaChartScrollable.tsx | 216 +++++++++++++++++ .../ChartsV2/D3BarChart/D3BarChart.tsx | 220 +----------------- .../D3BarChart/D3BarChartCondensed.tsx | 192 +++++++++++++++ .../D3BarChart/D3BarChartScrollable.tsx | 219 +++++++++++++++++ .../ChartsV2/D3LineChart/D3LineChart.tsx | 213 +---------------- .../D3LineChart/D3LineChartCondensed.tsx | 184 +++++++++++++++ .../D3LineChart/D3LineChartScrollable.tsx | 212 +++++++++++++++++ .../ChartsV2/hooks/useChartDimensions.ts | 30 +-- .../ChartsV2/hooks/useChartHover.ts | 6 +- 11 files changed, 1238 insertions(+), 659 deletions(-) create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx index bf7246862..5b878b6cb 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx @@ -1,216 +1,11 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; - -import { useChartOrchestration } from "../hooks/useChartOrchestration"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useStackedData } from "../hooks/useStackedData"; -import { useXScale } from "../hooks/useXScale"; -import { useYScale } from "../hooks/useYScale"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -import { findNearestDataIndex } from "../utils/mouseUtils"; - -import { Grid } from "../shared/Grid"; -import { XAxis } from "../shared/XAxis"; -import { YAxis } from "../shared/YAxis"; -import { AreaSeries } from "./parts/AreaSeries"; -import { Crosshair } from "./parts/Crosshair"; -import { GradientDefs } from "./parts/GradientDefs"; +import { D3AreaChartCondensed } from "./D3AreaChartCondensed"; +import { D3AreaChartScrollable } from "./D3AreaChartScrollable"; import type { D3AreaChartData, D3AreaChartProps } from "./types"; -export function D3AreaChart({ - 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, - onClick, -}: D3AreaChartProps) { - const isPrinting = usePrintContext(); - - const orch = useChartOrchestration({ - data, - categoryKey, - chartThemeName, - customPalette, - showLegend, - showYAxis, - height, - fixedWidth, - fitLegendInHeight, - tickVariantProp, - chartIdPrefix: "d3ac", - icons, - onClick, - condensed, - }); - - const xScale = useXScale(data, orch.catKey, orch.svgWidth, orch.widthOfGroup); - const stackedData = useStackedData(data, orch.dataKeys, stacked); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); - - const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); - const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); - - if (!data || data.length === 0) { - return
; +export function D3AreaChart(props: D3AreaChartProps) { + if (props.condensed) { + return ; } - - return ( - -
-
- {showYAxis && ( -
- - - - - -
- )} - -
- - - - {grid && ( - - )} - - - - - - - -
-
- - orch.scrollTo("left")} - onScrollRight={() => orch.scrollTo("right")} - /> - - {showLegend && ( - - )} - - {orch.tooltipPayload && orch.mousePos && ( - - )} -
-
- ); + 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..76658d90a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx @@ -0,0 +1,188 @@ +import clsx from "clsx"; +import React, { useCallback } from "react"; + +import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useStackedData } from "../hooks/useStackedData"; +import { useXScale } from "../hooks/useXScale"; +import { useYScale } from "../hooks/useYScale"; +import { AngledXAxis } from "../shared/AngledXAxis"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { Grid } from "../shared/Grid"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { YAxis } from "../shared/YAxis"; +import { findNearestDataIndex } from "../utils/mouseUtils"; +import { AreaSeries } from "./parts/AreaSeries"; +import { Crosshair } from "./parts/Crosshair"; +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.catKey, orch.chartAreaWidth, orch.widthPerDataPoint); + const stackedData = useStackedData(data, orch.dataKeys, stacked); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); + + if (!data || data.length === 0) { + return
; + } + + return ( + +
+ + + + {showYAxis && ( + + + + )} + + + + + {grid && ( + + )} + + + + + + + + + + + {showLegend && ( + + )} + + {orch.tooltipPayload && orch.mousePos && ( + + )} +
+
+ ); +} 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..0a36e6a25 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx @@ -0,0 +1,216 @@ +import clsx from "clsx"; +import React, { useCallback } from "react"; + +import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useStackedData } from "../hooks/useStackedData"; +import { useXScale } from "../hooks/useXScale"; +import { useYScale } from "../hooks/useYScale"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +import { findNearestDataIndex } from "../utils/mouseUtils"; + +import { Grid } from "../shared/Grid"; +import { XAxis } from "../shared/XAxis"; +import { YAxis } from "../shared/YAxis"; +import { AreaSeries } from "./parts/AreaSeries"; +import { Crosshair } from "./parts/Crosshair"; +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, + onClick, +}: D3AreaChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartScrollableOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3ac", + icons, + onClick, + condensed, + }); + + const xScale = useXScale(data, orch.catKey, orch.svgWidth, orch.widthOfGroup); + const stackedData = useStackedData(data, orch.dataKeys, stacked); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); + + if (!data || data.length === 0) { + return
; + } + + return ( + +
+
+ {showYAxis && ( +
+ + + + + +
+ )} + +
+ + + + {grid && ( + + )} + + + + + + + +
+
+ + orch.scrollTo("left")} + onScrollRight={() => orch.scrollTo("right")} + /> + + {showLegend && ( + + )} + + {orch.tooltipPayload && orch.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx index dc4882dfa..9a752c525 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx @@ -1,219 +1,11 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; - -import { useChartOrchestration } from "../hooks/useChartOrchestration"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useStackedData } from "../hooks/useStackedData"; -import { useXBandScale } from "../hooks/useXBandScale"; -import { useYScale } from "../hooks/useYScale"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -import { findBandIndex } from "../utils/mouseUtils"; - -import { ClipDefs } from "../shared/ClipDefs"; -import { Grid } from "../shared/Grid"; -import { XAxis } from "../shared/XAxis"; -import { YAxis } from "../shared/YAxis"; -import { BarSeries } from "./parts/BarSeries"; -import { Crosshair } from "./parts/Crosshair"; +import { D3BarChartCondensed } from "./D3BarChartCondensed"; +import { D3BarChartScrollable } from "./D3BarChartScrollable"; import type { D3BarChartData, D3BarChartProps } from "./types"; -export function D3BarChart({ - 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, - onClick, -}: D3BarChartProps) { - const isPrinting = usePrintContext(); - - const orch = useChartOrchestration({ - data, - categoryKey, - chartThemeName, - customPalette, - showLegend, - showYAxis, - height, - fixedWidth, - fitLegendInHeight, - tickVariantProp, - chartIdPrefix: "d3bc", - icons, - onClick, - condensed, - }); - - const isStacked = variant === "stacked"; - const xScale = useXBandScale(data, orch.catKey, orch.svgWidth); - const stackedData = useStackedData(data, orch.dataKeys, isStacked); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); - - const findIndex = useCallback((mouseX: number) => findBandIndex(xScale, mouseX), [xScale]); - const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); - - if (!data || data.length === 0) { - return
; +export function D3BarChart(props: D3BarChartProps) { + if (props.condensed) { + return ; } - - return ( - -
-
- {showYAxis && ( -
- - - - - -
- )} - -
- - - - - - {grid && ( - - )} - - - - - - - -
-
- - orch.scrollTo("left")} - onScrollRight={() => orch.scrollTo("right")} - /> - - {showLegend && ( - - )} - - {orch.tooltipPayload && orch.mousePos && ( - - )} -
-
- ); + 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..4b5577013 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx @@ -0,0 +1,192 @@ +import clsx from "clsx"; +import React, { useCallback } from "react"; + +import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useStackedData } from "../hooks/useStackedData"; +import { useXBandScale } from "../hooks/useXBandScale"; +import { useYScale } from "../hooks/useYScale"; +import { AngledXAxis } from "../shared/AngledXAxis"; +import { ClipDefs } from "../shared/ClipDefs"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { Grid } from "../shared/Grid"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { YAxis } from "../shared/YAxis"; +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.catKey, orch.chartAreaWidth); + const stackedData = useStackedData(data, orch.dataKeys, isStacked); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + + const findIndex = useCallback((mouseX: number) => findBandIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); + + if (!data || data.length === 0) { + return
; + } + + return ( + +
+ + + + + + {showYAxis && ( + + + + )} + + + + + {grid && ( + + )} + + + + + + + + + + + {showLegend && ( + + )} + + {orch.tooltipPayload && orch.mousePos && ( + + )} +
+
+ ); +} 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..7315f3701 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx @@ -0,0 +1,219 @@ +import clsx from "clsx"; +import React, { useCallback } from "react"; + +import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useStackedData } from "../hooks/useStackedData"; +import { useXBandScale } from "../hooks/useXBandScale"; +import { useYScale } from "../hooks/useYScale"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +import { findBandIndex } from "../utils/mouseUtils"; + +import { ClipDefs } from "../shared/ClipDefs"; +import { Grid } from "../shared/Grid"; +import { XAxis } from "../shared/XAxis"; +import { YAxis } from "../shared/YAxis"; +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, + onClick, +}: D3BarChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartScrollableOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3bc", + icons, + onClick, + condensed, + }); + + const isStacked = variant === "stacked"; + const xScale = useXBandScale(data, orch.catKey, orch.svgWidth); + const stackedData = useStackedData(data, orch.dataKeys, isStacked); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + + const findIndex = useCallback((mouseX: number) => findBandIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); + + if (!data || data.length === 0) { + return
; + } + + return ( + +
+
+ {showYAxis && ( +
+ + + + + +
+ )} + +
+ + + + + + {grid && ( + + )} + + + + + + + +
+
+ + orch.scrollTo("left")} + onScrollRight={() => orch.scrollTo("right")} + /> + + {showLegend && ( + + )} + + {orch.tooltipPayload && orch.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx index dd4f8a80f..acf8eb490 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx @@ -1,212 +1,11 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; - -import { useChartOrchestration } from "../hooks/useChartOrchestration"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useXScale } from "../hooks/useXScale"; -import { useYScale } from "../hooks/useYScale"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -import { findNearestDataIndex } from "../utils/mouseUtils"; - -import { ClipDefs } from "../shared/ClipDefs"; -import { Grid } from "../shared/Grid"; -import { XAxis } from "../shared/XAxis"; -import { YAxis } from "../shared/YAxis"; -import { Crosshair } from "./parts/Crosshair"; -import { LineSeries } from "./parts/LineSeries"; +import { D3LineChartCondensed } from "./D3LineChartCondensed"; +import { D3LineChartScrollable } from "./D3LineChartScrollable"; import type { D3LineChartData, D3LineChartProps } from "./types"; -export function D3LineChart({ - 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, - onClick, -}: D3LineChartProps) { - const isPrinting = usePrintContext(); - - const orch = useChartOrchestration({ - data, - categoryKey, - chartThemeName, - customPalette, - showLegend, - showYAxis, - height, - fixedWidth, - fitLegendInHeight, - tickVariantProp, - chartIdPrefix: "d3lc", - icons, - onClick, - condensed, - }); - - const xScale = useXScale(data, orch.catKey, orch.svgWidth, orch.widthOfGroup); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, null); - - const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); - const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); - - if (!data || data.length === 0) { - return
; +export function D3LineChart(props: D3LineChartProps) { + if (props.condensed) { + return ; } - - return ( - -
-
- {showYAxis && ( -
- - - - - -
- )} - -
- - - - - - {grid && ( - - )} - - - - - - - -
-
- - orch.scrollTo("left")} - onScrollRight={() => orch.scrollTo("right")} - /> - - {showLegend && ( - - )} - - {orch.tooltipPayload && orch.mousePos && ( - - )} -
-
- ); + 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..999594329 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx @@ -0,0 +1,184 @@ +import clsx from "clsx"; +import React, { useCallback } from "react"; + +import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useXScale } from "../hooks/useXScale"; +import { useYScale } from "../hooks/useYScale"; +import { AngledXAxis } from "../shared/AngledXAxis"; +import { ClipDefs } from "../shared/ClipDefs"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { Grid } from "../shared/Grid"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { YAxis } from "../shared/YAxis"; +import { findNearestDataIndex } from "../utils/mouseUtils"; +import { Crosshair } from "./parts/Crosshair"; +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.catKey, orch.chartAreaWidth, orch.widthPerDataPoint); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, null); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); + + if (!data || data.length === 0) { + return
; + } + + return ( + +
+ + + + + + {showYAxis && ( + + + + )} + + + + + {grid && ( + + )} + + + + + + + + + + + {showLegend && ( + + )} + + {orch.tooltipPayload && orch.mousePos && ( + + )} +
+
+ ); +} 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..f28b89b12 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx @@ -0,0 +1,212 @@ +import clsx from "clsx"; +import React, { useCallback } from "react"; + +import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; +import { usePrintContext } from "../hooks/usePrintContext"; +import { useXScale } from "../hooks/useXScale"; +import { useYScale } from "../hooks/useYScale"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +import { findNearestDataIndex } from "../utils/mouseUtils"; + +import { ClipDefs } from "../shared/ClipDefs"; +import { Grid } from "../shared/Grid"; +import { XAxis } from "../shared/XAxis"; +import { YAxis } from "../shared/YAxis"; +import { Crosshair } from "./parts/Crosshair"; +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, + onClick, +}: D3LineChartProps) { + const isPrinting = usePrintContext(); + + const orch = useChartScrollableOrchestrator({ + data, + categoryKey, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + fixedWidth, + fitLegendInHeight, + tickVariantProp, + chartIdPrefix: "d3lc", + icons, + onClick, + condensed, + }); + + const xScale = useXScale(data, orch.catKey, orch.svgWidth, orch.widthOfGroup); + const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, null); + + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = + orch.createMouseHandlers(findIndex); + + if (!data || data.length === 0) { + return
; + } + + return ( + +
+
+ {showYAxis && ( +
+ + + + + +
+ )} + +
+ + + + + + {grid && ( + + )} + + + + + + + +
+
+ + orch.scrollTo("left")} + onScrollRight={() => orch.scrollTo("right")} + /> + + {showLegend && ( + + )} + + {orch.tooltipPayload && orch.mousePos && ( + + )} +
+
+ ); +} diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts index d4e027ca3..6db69a559 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts @@ -1,16 +1,14 @@ -import React, { useEffect, useMemo, useState } from "react"; +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 "./useContainerSize"; +import { useLegendHeight } from "./useLegendHeight"; import { useXAxisHeight } from "./useXAxisHeight"; import { useYAxisWidth } from "./useYAxisWidth"; import type { ChartData } from "../types"; -const MARGIN_TOP = 10; -const DEFAULT_CHART_HEIGHT = 296; -const SINGLE_LINE_BREAKPOINT = 300; - export interface UseChartDimensionsParams { containerRef: React.RefObject; legendRef: React.RefObject; @@ -40,23 +38,7 @@ export function useChartDimensions({ tickVariantProp, condensed, }: UseChartDimensionsParams) { - 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]); + const legendHeight = useLegendHeight(legendRef, showLegend); const { width: containerWidth, height: containerHeight } = useContainerSize( containerRef, @@ -96,7 +78,7 @@ export function useChartDimensions({ : DEFAULT_CHART_HEIGHT; const shouldFitLegend = fitLegendInHeight ?? !!height; const svgAvailableHeight = shouldFitLegend ? resolvedHeight - legendHeight : resolvedHeight; - const chartInnerHeight = svgAvailableHeight - MARGIN_TOP - xAxisHeight; + const chartInnerHeight = Math.max(0, svgAvailableHeight - CHART_MARGIN_TOP - xAxisHeight); const totalHeight = svgAvailableHeight; const svgWidth = needsScroll ? dataWidth : containerWidth - effectiveYAxisWidth; @@ -112,6 +94,6 @@ export function useChartDimensions({ widthOfGroup, needsScroll, labelInterval, - MARGIN_TOP, + MARGIN_TOP: CHART_MARGIN_TOP, }; } diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts index a67b55c5b..d66daa2b6 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts @@ -14,7 +14,7 @@ export function useChartHover({ data, onClick }: UseChartHo const createMouseHandlers = useCallback( (findIndex: (mouseX: number) => number) => { - const handleMouseMove = (event: React.MouseEvent) => { + const handleMouseMove = (event: React.MouseEvent) => { const [mouseX] = pointer(event.nativeEvent, event.currentTarget); setHoveredIndex(findIndex(mouseX)); setMousePos({ x: event.clientX, y: event.clientY }); @@ -25,7 +25,7 @@ export function useChartHover({ data, onClick }: UseChartHo setMousePos(null); }; - const handleTouchMove = (event: React.TouchEvent) => { + const handleTouchMove = (event: React.TouchEvent) => { const touch = event.touches[0]; if (!touch) return; const svgRect = event.currentTarget.getBoundingClientRect(); @@ -40,7 +40,7 @@ export function useChartHover({ data, onClick }: UseChartHo }; const handleClick = onClick - ? (event: React.MouseEvent) => { + ? (event: React.MouseEvent) => { const [mouseX] = pointer(event.nativeEvent, event.currentTarget); const idx = findIndex(mouseX); if (idx >= 0 && idx < data.length) { From 82681398402514104b766287184e4fb4e8a9d04f Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 00:21:59 +0530 Subject: [PATCH 15/23] docs(ChartsV2): update stories and docs for condensed variant support Add condensed variant stories for all chart types and update README and DESIGN.md to reflect the Scrollable/Condensed architecture. Co-Authored-By: Claude Opus 4.6 --- .../components/ChartsV2/D3AreaChart/DESIGN.md | 8 ++-- .../stories/d3AreaChart.stories.tsx | 13 +----- .../D3BarChart/stories/d3BarChart.stories.tsx | 46 ++++++++++++++----- .../stories/d3LineChart.stories.tsx | 13 +----- .../src/components/ChartsV2/README.md | 14 +++--- 5 files changed, 49 insertions(+), 45 deletions(-) diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md b/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md index ae01f085f..6e4fc382f 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/DESIGN.md @@ -337,13 +337,13 @@ 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 + name: string; // series key + value: number; // series value at hovered index + color: string; // series color } interface TooltipPayload { - label: string; // category value at hovered index + label: string; // category value at hovered index items: TooltipItem[]; } ``` 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 index 5eb550651..41407b023 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/stories/d3AreaChart.stories.tsx @@ -901,12 +901,7 @@ export const Condensed: Story = { condensed — 12 months in 400px card (no scroll)

- +
@@ -927,11 +922,7 @@ export const Condensed: Story = { normal (default) — same data scrolls

- +
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 index b7991ac0f..16d6ac377 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/stories/d3BarChart.stories.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/stories/d3BarChart.stories.tsx @@ -481,45 +481,67 @@ export const FixedPixelDimensions: Story = { }; export const Condensed: Story = { - name: "Condensed", + name: "Condensed (Angled Labels)", render: () => (

- condensed — 12 months in 400px card (no scroll) + condensed — 12 months with angled labels

- + + + +
+
+

+ condensed stacked — angled labels +

+

- condensed stacked — 12 months in 400px card + condensed — long labels (big angle)

- + + +
+
+

+ condensed — short labels (minimal angle) +

+ +

- normal (default) — same data scrolls + condensed — narrow container (300px)

- +
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 index e4c1cb159..c3cc8e469 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/stories/d3LineChart.stories.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/stories/d3LineChart.stories.tsx @@ -418,12 +418,7 @@ export const Condensed: Story = { condensed — 12 months in 400px card (no scroll)

- +
@@ -445,11 +440,7 @@ export const Condensed: Story = { normal (default) — same data scrolls

- +
diff --git a/packages/react-ui/src/components/ChartsV2/README.md b/packages/react-ui/src/components/ChartsV2/README.md index 6fa77df90..f6bc0069f 100644 --- a/packages/react-ui/src/components/ChartsV2/README.md +++ b/packages/react-ui/src/components/ChartsV2/README.md @@ -10,11 +10,11 @@ D3-based chart components for OpenUI. Three chart types share a common infrastru ## Architecture -``` text +```text ┌────────────────────────────────────────────────-─┐ │ D3[Type]Chart Component │ │ │ -│ useChartOrchestration ◄── master hook │ +│ useChartScrollableOrchestrator ◄── master hook │ │ ├── useChartData (keys, colors, legend) │ │ ├── useChartDimensions (sizing, layout) │ │ ├── useChartHover (hover state, handlers)│ @@ -40,9 +40,9 @@ All hooks are in `hooks/` and re-exported from `hooks/index.ts`. ### Orchestration -| Hook | Signature | Description | -| ----------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `useChartOrchestration` | `(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 charts. | +| Hook | Signature | Description | +| -------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useChartScrollableOrchestrator` | `(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 charts. | ### Data @@ -143,7 +143,7 @@ To add a new chart type (e.g., `D3ScatterChart`): 1. **Create the directory** following the existing pattern: - ``` text + ```text D3ScatterChart/ ├── D3ScatterChart.tsx ├── types.ts @@ -157,7 +157,7 @@ To add a new chart type (e.g., `D3ScatterChart`): 2. **Define types** in `types.ts` — extend `BaseChartProps` from `types/common.ts` and add chart-specific props (e.g., `dotRadius`, `variant`). -3. **Use `useChartOrchestration`** as the primary hook — it provides data, dimensions, hover, scroll, legend, and tooltip state out of the box. +3. **Use `useChartScrollableOrchestrator`** as the primary hook — it provides data, dimensions, hover, scroll, legend, and tooltip state out of the box. 4. **Pick the right scale hook** — `useXScale` (point) for continuous positioning, `useXBandScale` (band) for discrete categories. From b097a2031a06805fb4aa4fd5144c195451983e03 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 01:02:39 +0530 Subject: [PATCH 16/23] refactor(ChartsV2): group orchestrator returns and add density prop Restructure both orchestrator return values into semantic groups (refs, identity, data, dimensions, hover, scroll, legend, tooltip, style) for improved readability. Add density prop ("compact" | "default" | "spacious") to control data point spacing in scrollable mode. Propagate through scrollUtils, useChartDimensions, and useChartScroll. Fix missing density dependency in useChartScroll's scrollTo callback. Co-Authored-By: Claude Opus 4.6 --- .../hooks/useChartCondensedOrchestrator.ts | 69 ++++++------ .../ChartsV2/hooks/useChartDimensions.ts | 11 +- .../ChartsV2/hooks/useChartScroll.ts | 7 +- .../hooks/useChartScrollableOrchestrator.ts | 105 +++++++++--------- .../src/components/ChartsV2/types/common.ts | 2 + .../components/ChartsV2/utils/scrollUtils.ts | 24 ++-- 6 files changed, 117 insertions(+), 101 deletions(-) diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts index 6677905e1..1d9fed95e 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts @@ -98,39 +98,40 @@ export function useChartCondensedOrchestrator({ ); return { - containerRef, - legendRef, - chartId, - - catKey: chartData.catKey, - dataKeys: chartData.dataKeys, - colorMap: chartData.colorMap, - chartConfig: chartData.chartConfig, - chartStyle: chartData.chartStyle, - transformedKeys: chartData.transformedKeys, - legendItems: chartData.legendItems, - hiddenSeries: chartData.hiddenSeries, - toggleSeries: chartData.toggleSeries, - - effectiveYAxisWidth, - chartAreaWidth, - widthPerDataPoint, - containerWidth, - angle, - xAxisHeight, - chartInnerHeight, - totalSvgHeight, - totalSvgWidth, - CHART_MARGIN_TOP, - - hoveredIndex: hover.hoveredIndex, - mousePos: hover.mousePos, - createMouseHandlers: hover.createMouseHandlers, - - tooltipPayload, - containerStyle, - - isLegendExpanded, - setIsLegendExpanded, + 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/useChartDimensions.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts index 6db69a559..2c6d851f2 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts @@ -8,6 +8,7 @@ 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; @@ -22,6 +23,7 @@ export interface UseChartDimensionsParams { fitLegendInHeight?: boolean; tickVariantProp: "singleLine" | "multiLine"; condensed: boolean; + density?: ChartDensity; } export function useChartDimensions({ @@ -37,6 +39,7 @@ export function useChartDimensions({ fitLegendInHeight, tickVariantProp, condensed, + density, }: UseChartDimensionsParams) { const legendHeight = useLegendHeight(legendRef, showLegend); @@ -50,7 +53,9 @@ export function useChartDimensions({ const effectiveYAxisWidth = showYAxis ? yAxisWidth : 0; const availableWidth = containerWidth - effectiveYAxisWidth; - const widthOfGroup = condensed ? availableWidth / Math.max(data.length, 1) : getWidthOfGroup(); + const widthOfGroup = condensed + ? availableWidth / Math.max(data.length, 1) + : getWidthOfGroup(density); const tickVariant = condensed ? ("singleLine" as const) @@ -61,8 +66,8 @@ export function useChartDimensions({ const xAxisHeight = useXAxisHeight(data, catKey, tickVariant, widthOfGroup); const dataWidth = useMemo( - () => (condensed ? availableWidth : getWidthOfData(data, availableWidth)), - [condensed, data, availableWidth], + () => (condensed ? availableWidth : getWidthOfData(data, availableWidth, density)), + [condensed, data, availableWidth, density], ); const needsScroll = condensed ? false : dataWidth > availableWidth; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts index 947f04ba7..b40f04a1b 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts @@ -3,17 +3,20 @@ 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); @@ -44,12 +47,12 @@ export function useChartScroll({ (direction: "left" | "right") => { const el = mainContainerRef.current; if (!el) return; - const snaps = getSnapPositions(data); + 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], + [data, mainContainerRef, density], ); return { diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts index 95390f69c..dfcd21339 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts @@ -10,6 +10,7 @@ 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; @@ -26,6 +27,7 @@ export interface UseChartScrollableOrchestratorParams { icons?: Partial>; onClick?: (row: T[number], index: number) => void; condensed?: boolean; + density?: ChartDensity; } export function useChartScrollableOrchestrator({ @@ -43,6 +45,7 @@ export function useChartScrollableOrchestrator({ icons, onClick, condensed = false, + density, }: UseChartScrollableOrchestratorParams) { const containerRef = useRef(null); const mainContainerRef = useRef(null); @@ -72,6 +75,7 @@ export function useChartScrollableOrchestrator({ fitLegendInHeight, tickVariantProp, condensed, + density, }); // Hover: index, mouse position, handler factory @@ -82,6 +86,7 @@ export function useChartScrollableOrchestrator({ mainContainerRef, data, needsScroll: dimensions.needsScroll, + density, }); // Legend expand/collapse @@ -103,60 +108,50 @@ export function useChartScrollableOrchestrator({ ); return { - // Refs - containerRef, - mainContainerRef, - legendRef, - chartId, - - // Data (spread from useChartData) - catKey: chartData.catKey, - allDataKeys: chartData.allDataKeys, - dataKeys: chartData.dataKeys, - colors: chartData.colors, - transformedKeys: chartData.transformedKeys, - widthOfGroup: dimensions.widthOfGroup, - chartConfig: chartData.chartConfig, - colorMap: chartData.colorMap, - chartStyle: chartData.chartStyle, - - // Dimensions (spread from useChartDimensions) - effectiveYAxisWidth: dimensions.effectiveYAxisWidth, - containerWidth: dimensions.containerWidth, - tickVariant: dimensions.tickVariant, - xAxisHeight: dimensions.xAxisHeight, - chartInnerHeight: dimensions.chartInnerHeight, - totalHeight: dimensions.totalHeight, - svgWidth: dimensions.svgWidth, - dataWidth: dimensions.dataWidth, - needsScroll: dimensions.needsScroll, - labelInterval: dimensions.labelInterval, - MARGIN_TOP: dimensions.MARGIN_TOP, - - // Hover (spread from useChartHover) - hoveredIndex: hover.hoveredIndex, - mousePos: hover.mousePos, - - // Scroll (spread from useChartScroll) - 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, - - // Mouse handler factory - createMouseHandlers: hover.createMouseHandlers, + 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/types/common.ts b/packages/react-ui/src/components/ChartsV2/types/common.ts index d06ca46a5..b3816e69e 100644 --- a/packages/react-ui/src/components/ChartsV2/types/common.ts +++ b/packages/react-ui/src/components/ChartsV2/types/common.ts @@ -33,4 +33,6 @@ export interface BaseChartProps { 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/utils/scrollUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts index 08f6fff16..d5dc5cb46 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/scrollUtils.ts @@ -1,13 +1,23 @@ type ChartData = Array>; -const ELEMENT_SPACING = 72; +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) => { +export const getWidthOfData = ( + data: ChartData, + containerWidth: number, + density: ChartDensity = "default", +) => { if (data.length === 0) { return containerWidth; } - const width = data.length * getWidthOfGroup(); + const width = data.length * getWidthOfGroup(density); if (containerWidth >= width) { return containerWidth; @@ -42,15 +52,15 @@ export const findNearestSnapPosition = ( } }; -export const getWidthOfGroup = (): number => { - return ELEMENT_SPACING; +export const getWidthOfGroup = (density: ChartDensity = "default"): number => { + return DENSITY_SPACING[density]; }; -export const getSnapPositions = (data: ChartData): number[] => { +export const getSnapPositions = (data: ChartData, density: ChartDensity = "default"): number[] => { if (data.length === 0) return [0]; const positions = [0]; - const groupWidthValue = getWidthOfGroup(); + const groupWidthValue = getWidthOfGroup(density); for (let i = 1; i < data.length; i++) { positions.push(i * groupWidthValue); From 9ae2e742075862b5d3ce2239201aa0c5f1a7e8d2 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 01:02:56 +0530 Subject: [PATCH 17/23] refactor(ChartsV2): extract shared layout components and add empty states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create ScrollableChartLayout and CondensedChartLayout that own the full container structure (Y-axis, grid, scroll buttons, legend, tooltip). Chart components now only provide scale hooks and series/ crosshair/xAxis via JSX slots, reducing each from ~200 to ~130 lines. Add empty state handling in router components (D3AreaChart, D3BarChart, D3LineChart) with consistent "No data available" UI. Remove Area and Line Crosshair wrappers — use LineDotCrosshair directly in the series slot. Bar Crosshair retained (different UX). Co-Authored-By: Claude Opus 4.6 --- .../ChartsV2/D3AreaChart/D3AreaChart.tsx | 11 + .../D3AreaChart/D3AreaChartCondensed.tsx | 213 ++++++--------- .../D3AreaChart/D3AreaChartScrollable.tsx | 243 +++++++----------- .../ChartsV2/D3AreaChart/parts/Crosshair.tsx | 55 ---- .../ChartsV2/D3BarChart/D3BarChart.tsx | 9 + .../D3BarChart/D3BarChartCondensed.tsx | 191 +++++--------- .../D3BarChart/D3BarChartScrollable.tsx | 221 +++++----------- .../ChartsV2/D3LineChart/D3LineChart.tsx | 11 + .../D3LineChart/D3LineChartCondensed.tsx | 199 ++++++-------- .../D3LineChart/D3LineChartScrollable.tsx | 231 ++++++----------- .../ChartsV2/D3LineChart/parts/Crosshair.tsx | 23 -- .../ChartsV2/shared/CondensedChartLayout.tsx | 143 +++++++++++ .../ChartsV2/shared/ScrollableChartLayout.tsx | 160 ++++++++++++ .../components/ChartsV2/shared/chartBase.scss | 12 + 14 files changed, 803 insertions(+), 919 deletions(-) delete mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx delete mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/parts/Crosshair.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx index 5b878b6cb..a21de94b1 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChart.tsx @@ -1,9 +1,20 @@ +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 ; } diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx index 76658d90a..141c3f774 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx @@ -1,5 +1,4 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; import { usePrintContext } from "../hooks/usePrintContext"; @@ -7,14 +6,10 @@ import { useStackedData } from "../hooks/useStackedData"; import { useXScale } from "../hooks/useXScale"; import { useYScale } from "../hooks/useYScale"; import { AngledXAxis } from "../shared/AngledXAxis"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { Grid } from "../shared/Grid"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { YAxis } from "../shared/YAxis"; +import { CondensedChartLayout } from "../shared/CondensedChartLayout"; +import { LineDotCrosshair } from "../shared/LineDotCrosshair"; import { findNearestDataIndex } from "../utils/mouseUtils"; import { AreaSeries } from "./parts/AreaSeries"; -import { Crosshair } from "./parts/Crosshair"; import { GradientDefs } from "./parts/GradientDefs"; import type { D3AreaChartData, D3AreaChartProps } from "./types"; @@ -56,133 +51,91 @@ export function D3AreaChartCondensed({ onClick, }); - const xScale = useXScale(data, orch.catKey, orch.chartAreaWidth, orch.widthPerDataPoint); - const stackedData = useStackedData(data, orch.dataKeys, stacked); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + 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 findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); - const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); + 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], + ); - if (!data || data.length === 0) { - return
; - } + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); return ( - -
- - + } + series={ + <> + - - {showYAxis && ( - - - - )} - - - - - {grid && ( - - )} - - - - - - - - - - - {showLegend && ( - - )} - - {orch.tooltipPayload && orch.mousePos && ( - - )} -
-
+ + } + xAxis={ + + } + /> ); } diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx index 0a36e6a25..a442af553 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx @@ -1,22 +1,15 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; import { usePrintContext } from "../hooks/usePrintContext"; import { useStackedData } from "../hooks/useStackedData"; import { useXScale } from "../hooks/useXScale"; import { useYScale } from "../hooks/useYScale"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -import { findNearestDataIndex } from "../utils/mouseUtils"; - -import { Grid } from "../shared/Grid"; +import { LineDotCrosshair } from "../shared/LineDotCrosshair"; +import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; import { XAxis } from "../shared/XAxis"; -import { YAxis } from "../shared/YAxis"; +import { findNearestDataIndex } from "../utils/mouseUtils"; import { AreaSeries } from "./parts/AreaSeries"; -import { Crosshair } from "./parts/Crosshair"; import { GradientDefs } from "./parts/GradientDefs"; import type { D3AreaChartData, D3AreaChartProps } from "./types"; @@ -41,6 +34,7 @@ export function D3AreaChartScrollable({ width: fixedWidth, fitLegendInHeight, condensed = false, + density, onClick, }: D3AreaChartProps) { const isPrinting = usePrintContext(); @@ -60,157 +54,98 @@ export function D3AreaChartScrollable({ icons, onClick, condensed, + density, }); - const xScale = useXScale(data, orch.catKey, orch.svgWidth, orch.widthOfGroup); - const stackedData = useStackedData(data, orch.dataKeys, stacked); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + 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 findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); - const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); + 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], + ); - if (!data || data.length === 0) { - return
; - } + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); return ( - -
-
- {showYAxis && ( -
- - - - - -
- )} - -
- - - - {grid && ( - - )} - - - - - - - -
-
- - orch.scrollTo("left")} - onScrollRight={() => orch.scrollTo("right")} + - - {showLegend && ( - + - )} - - {orch.tooltipPayload && orch.mousePos && ( - - )} -
-
+ + } + xAxis={ + + } + /> ); } diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx deleted file mode 100644 index 04c5fae56..000000000 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/Crosshair.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { ScaleLinear, ScalePoint } from "d3-scale"; -import React, { useCallback } from "react"; -import type { StackedData } from "../../hooks/useStackedData"; -import { LineDotCrosshair } from "../../shared/LineDotCrosshair"; - -interface CrosshairProps { - hoveredIndex: number | null; - xScale: ScalePoint; - yScale: ScaleLinear; - data: Array>; - dataKeys: string[]; - categoryKey: string; - colors: Record; - stackedData: StackedData | null; - chartHeight: number; -} - -export const Crosshair: React.FC = ({ - hoveredIndex, - xScale, - yScale, - data, - dataKeys, - categoryKey, - colors, - stackedData, - chartHeight, -}) => { - const getYValue = useCallback( - (_row: Record, key: string, seriesIndex: number) => { - if (stackedData && hoveredIndex !== null) { - const series = stackedData[seriesIndex]; - const point = series?.[hoveredIndex]; - return point ? point[1] : 0; - } - return Number(_row[key]) || 0; - }, - [stackedData, hoveredIndex], - ); - - return ( - - ); -}; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx index 9a752c525..461126ab9 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChart.tsx @@ -1,9 +1,18 @@ +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 ; } diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx index 4b5577013..8d26cc84c 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx @@ -1,5 +1,4 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; import { usePrintContext } from "../hooks/usePrintContext"; @@ -8,11 +7,7 @@ import { useXBandScale } from "../hooks/useXBandScale"; import { useYScale } from "../hooks/useYScale"; import { AngledXAxis } from "../shared/AngledXAxis"; import { ClipDefs } from "../shared/ClipDefs"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { Grid } from "../shared/Grid"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { YAxis } from "../shared/YAxis"; +import { CondensedChartLayout } from "../shared/CondensedChartLayout"; import { findBandIndex } from "../utils/mouseUtils"; import { BarSeries } from "./parts/BarSeries"; import { Crosshair } from "./parts/Crosshair"; @@ -61,132 +56,72 @@ export function D3BarChartCondensed({ }); const isStacked = variant === "stacked"; - const xScale = useXBandScale(data, orch.catKey, orch.chartAreaWidth); - const stackedData = useStackedData(data, orch.dataKeys, isStacked); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + 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 { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); - - if (!data || data.length === 0) { - return
; - } + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); return ( - -
- - - - - - {showYAxis && ( - - - - )} - - - - - {grid && ( - - )} - - - - - - - - - - - {showLegend && ( - + - )} - - {orch.tooltipPayload && orch.mousePos && ( - + } + series={ + <> + + - )} -
-
+ + } + xAxis={ + + } + /> ); } diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx index 7315f3701..18ccae62a 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx @@ -1,21 +1,14 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; import { usePrintContext } from "../hooks/usePrintContext"; import { useStackedData } from "../hooks/useStackedData"; import { useXBandScale } from "../hooks/useXBandScale"; import { useYScale } from "../hooks/useYScale"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -import { findBandIndex } from "../utils/mouseUtils"; - import { ClipDefs } from "../shared/ClipDefs"; -import { Grid } from "../shared/Grid"; +import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; import { XAxis } from "../shared/XAxis"; -import { YAxis } from "../shared/YAxis"; +import { findBandIndex } from "../utils/mouseUtils"; import { BarSeries } from "./parts/BarSeries"; import { Crosshair } from "./parts/Crosshair"; @@ -45,6 +38,7 @@ export function D3BarChartScrollable({ width: fixedWidth, fitLegendInHeight, condensed = false, + density, onClick, }: D3BarChartProps) { const isPrinting = usePrintContext(); @@ -64,156 +58,79 @@ export function D3BarChartScrollable({ icons, onClick, condensed, + density, }); const isStacked = variant === "stacked"; - const xScale = useXBandScale(data, orch.catKey, orch.svgWidth); - const stackedData = useStackedData(data, orch.dataKeys, isStacked); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, stackedData); + 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 { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); - - if (!data || data.length === 0) { - return
; - } + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); return ( - -
-
- {showYAxis && ( -
- - - - - -
- )} - -
- - - - - - {grid && ( - - )} - - - - - - - -
-
- - orch.scrollTo("left")} - onScrollRight={() => orch.scrollTo("right")} - /> - - {showLegend && ( - + - )} - - {orch.tooltipPayload && orch.mousePos && ( - + } + series={ + <> + - )} -
-
+ + + } + xAxis={ + + } + /> ); } diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx index acf8eb490..b3d157bd3 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChart.tsx @@ -1,9 +1,20 @@ +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 ; } diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx index 999594329..b67c938cd 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx @@ -1,5 +1,4 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; import { usePrintContext } from "../hooks/usePrintContext"; @@ -7,13 +6,9 @@ import { useXScale } from "../hooks/useXScale"; import { useYScale } from "../hooks/useYScale"; import { AngledXAxis } from "../shared/AngledXAxis"; import { ClipDefs } from "../shared/ClipDefs"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { Grid } from "../shared/Grid"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { YAxis } from "../shared/YAxis"; +import { CondensedChartLayout } from "../shared/CondensedChartLayout"; +import { LineDotCrosshair } from "../shared/LineDotCrosshair"; import { findNearestDataIndex } from "../utils/mouseUtils"; -import { Crosshair } from "./parts/Crosshair"; import { LineSeries } from "./parts/LineSeries"; import type { D3LineChartData, D3LineChartProps } from "./types"; @@ -56,129 +51,81 @@ export function D3LineChartCondensed({ onClick, }); - const xScale = useXScale(data, orch.catKey, orch.chartAreaWidth, orch.widthPerDataPoint); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, null); + 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 findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); - const { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); + const getYValue = useCallback( + (row: Record, key: string) => Number(row[key]) || 0, + [], + ); - if (!data || data.length === 0) { - return
; - } + const findIndex = useCallback((mouseX: number) => findNearestDataIndex(xScale, mouseX), [xScale]); + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); return ( - -
- - - - - - {showYAxis && ( - - - - )} - - - - - {grid && ( - - )} - - - - - - - - - - - {showLegend && ( - + - )} - - {orch.tooltipPayload && orch.mousePos && ( - + } + series={ + <> + + - )} -
-
+ + } + xAxis={ + + } + /> ); } diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx index f28b89b12..9c5947186 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx @@ -1,21 +1,14 @@ -import clsx from "clsx"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; import { usePrintContext } from "../hooks/usePrintContext"; import { useXScale } from "../hooks/useXScale"; import { useYScale } from "../hooks/useYScale"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { ScrollButtonsHorizontal } from "../shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -import { findNearestDataIndex } from "../utils/mouseUtils"; - import { ClipDefs } from "../shared/ClipDefs"; -import { Grid } from "../shared/Grid"; +import { LineDotCrosshair } from "../shared/LineDotCrosshair"; +import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; import { XAxis } from "../shared/XAxis"; -import { YAxis } from "../shared/YAxis"; -import { Crosshair } from "./parts/Crosshair"; +import { findNearestDataIndex } from "../utils/mouseUtils"; import { LineSeries } from "./parts/LineSeries"; import type { D3LineChartData, D3LineChartProps } from "./types"; @@ -41,6 +34,7 @@ export function D3LineChartScrollable({ width: fixedWidth, fitLegendInHeight, condensed = false, + density, onClick, }: D3LineChartProps) { const isPrinting = usePrintContext(); @@ -60,153 +54,88 @@ export function D3LineChartScrollable({ icons, onClick, condensed, + density, }); - const xScale = useXScale(data, orch.catKey, orch.svgWidth, orch.widthOfGroup); - const yScale = useYScale(data, orch.dataKeys, orch.chartInnerHeight, null); + 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 { handleMouseMove, handleMouseLeave, handleTouchMove, handleTouchEnd, handleClick } = - orch.createMouseHandlers(findIndex); - - if (!data || data.length === 0) { - return
; - } + const mouseHandlers = orch.hover.createMouseHandlers(findIndex); return ( - -
-
- {showYAxis && ( -
- - - - - -
- )} - -
- - - - - - {grid && ( - - )} - - - - - - - -
-
- - orch.scrollTo("left")} - onScrollRight={() => orch.scrollTo("right")} - /> - - {showLegend && ( - + - )} - - {orch.tooltipPayload && orch.mousePos && ( - + } + series={ + <> + + - )} -
-
+ + } + xAxis={ + + } + /> ); } diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/Crosshair.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/Crosshair.tsx deleted file mode 100644 index 73c0016c3..000000000 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/Crosshair.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { ScaleLinear, ScalePoint } from "d3-scale"; -import React, { useCallback } from "react"; -import { LineDotCrosshair } from "../../shared/LineDotCrosshair"; - -interface CrosshairProps { - hoveredIndex: number | null; - xScale: ScalePoint; - yScale: ScaleLinear; - data: Array>; - dataKeys: string[]; - categoryKey: string; - colors: Record; - chartHeight: number; -} - -export const Crosshair: React.FC = (props) => { - const getYValue = useCallback( - (row: Record, key: string) => Number(row[key]) || 0, - [], - ); - - return ; -}; diff --git a/packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx b/packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx new file mode 100644 index 000000000..5d087181a --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/useChartCondensedOrchestrator"; +import { DefaultLegend } from "./DefaultLegend/DefaultLegend"; +import { Grid } from "./Grid"; +import { LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "./PortalTooltip/ChartTooltip"; +import { YAxis } from "./YAxis"; + +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/ScrollableChartLayout.tsx b/packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx new file mode 100644 index 000000000..271d2a921 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/shared/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/useChartScrollableOrchestrator"; +import { DefaultLegend } from "./DefaultLegend/DefaultLegend"; +import { Grid } from "./Grid"; +import { LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "./PortalTooltip/ChartTooltip"; +import { ScrollButtonsHorizontal } from "./ScrollButtonsHorizontal/ScrollButtonsHorizontal"; +import { YAxis } from "./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/chartBase.scss b/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss index eb0c29fc7..11e35edff 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss +++ b/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss @@ -71,6 +71,18 @@ } } +.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; From 01b3a8e3f605aacaac44acf6b6d5d0e701426534 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 05:33:11 +0530 Subject: [PATCH 18/23] refactor(ChartsV2): reorganize hooks into core/cartesian/polar layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the flat hooks/ directory into three topology-based layers to clarify which hooks serve which chart type and prevent polar development from accidentally coupling with cartesian code. - core/ (9 hooks): coordinate-agnostic (useChartData, useContainerSize, etc.) - cartesian/ (12 hooks): cartesian-only (orchestrators, scales, axes, scroll) - polar/ (1 hook): new useCategoricalChartOrchestrator for pie/radial charts - Rename pieUtils.ts → polarUtils.ts for clarity - Simplify D3PieChart and D3RadialChart using shared polar orchestrator Co-Authored-By: Claude Opus 4.6 --- .../D3AreaChart/D3AreaChartCondensed.tsx | 12 +- .../D3AreaChart/D3AreaChartScrollable.tsx | 12 +- .../ChartsV2/D3AreaChart/parts/AreaSeries.tsx | 2 +- .../D3BarChart/D3BarChartCondensed.tsx | 12 +- .../D3BarChart/D3BarChartScrollable.tsx | 12 +- .../ChartsV2/D3BarChart/parts/BarSeries.tsx | 2 +- .../D3LineChart/D3LineChartCondensed.tsx | 5 +- .../D3LineChart/D3LineChartScrollable.tsx | 5 +- .../ChartsV2/D3PieChart/D3PieChart.tsx | 150 +++++++ .../ChartsV2/D3PieChart/d3PieChart.scss | 37 ++ .../components/ChartsV2/D3PieChart/index.ts | 2 + .../ChartsV2/D3PieChart/parts/PieSlices.tsx | 70 +++ .../D3PieChart/stories/d3PieChart.stories.tsx | 422 ++++++++++++++++++ .../components/ChartsV2/D3PieChart/types.ts | 26 ++ .../ChartsV2/D3RadialChart/D3RadialChart.tsx | 143 ++++++ .../ChartsV2/D3RadialChart/d3RadialChart.scss | 41 ++ .../ChartsV2/D3RadialChart/index.ts | 2 + .../D3RadialChart/parts/RadialBars.tsx | 98 ++++ .../D3RadialChart/parts/RadialGrid.tsx | 55 +++ .../stories/d3RadialChart.stories.tsx | 367 +++++++++++++++ .../ChartsV2/D3RadialChart/types.ts | 25 ++ .../src/components/ChartsV2/chartsV2.scss | 2 + .../ChartsV2/hooks/cartesian/index.ts | 12 + .../useAutoAngleCalculation.ts | 0 .../useChartCondensedOrchestrator.ts | 22 +- .../{ => cartesian}/useChartDimensions.ts | 16 +- .../hooks/{ => cartesian}/useChartScroll.ts | 6 +- .../useChartScrollableOrchestrator.ts | 15 +- .../hooks/{ => cartesian}/useMaxLabelWidth.ts | 2 +- .../hooks/{ => cartesian}/useStackedData.ts | 2 +- .../hooks/{ => cartesian}/useXAxisHeight.ts | 4 +- .../hooks/{ => cartesian}/useXBandScale.ts | 2 +- .../hooks/{ => cartesian}/useXScale.ts | 2 +- .../hooks/{ => cartesian}/useYAxisWidth.ts | 4 +- .../hooks/{ => cartesian}/useYScale.ts | 2 +- .../components/ChartsV2/hooks/core/index.ts | 9 + .../useCanvasContextForLabelSize.ts | 2 +- .../hooks/core/useCategoricalChartData.ts | 118 +++++ .../ChartsV2/hooks/{ => core}/useChartData.ts | 8 +- .../hooks/{ => core}/useChartHover.ts | 2 +- .../hooks/{ => core}/useContainerSize.ts | 0 .../hooks/{ => core}/useLegendHeight.ts | 0 .../hooks/{ => core}/usePrintContext.ts | 0 .../hooks/{ => core}/useTooltipPayload.ts | 4 +- .../hooks/{ => core}/useTransformedKeys.ts | 0 .../src/components/ChartsV2/hooks/index.ts | 23 +- .../components/ChartsV2/hooks/polar/index.ts | 1 + .../polar/useCategoricalChartOrchestrator.ts | 156 +++++++ .../react-ui/src/components/ChartsV2/index.ts | 6 + .../ChartsV2/shared/CondensedChartLayout.tsx | 2 +- .../DefaultLegend/hooks/useDefaultLegend.ts | 2 +- .../ChartsV2/shared/ScrollableChartLayout.tsx | 2 +- .../src/components/ChartsV2/utils/index.ts | 1 + .../components/ChartsV2/utils/polarUtils.ts | 22 + 54 files changed, 1853 insertions(+), 96 deletions(-) create mode 100644 packages/react-ui/src/components/ChartsV2/D3PieChart/D3PieChart.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3PieChart/d3PieChart.scss create mode 100644 packages/react-ui/src/components/ChartsV2/D3PieChart/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/D3PieChart/parts/PieSlices.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3PieChart/stories/d3PieChart.stories.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3PieChart/types.ts create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadialChart/d3RadialChart.scss create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadialChart/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadialChart/parts/RadialBars.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadialChart/parts/RadialGrid.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadialChart/stories/d3RadialChart.stories.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadialChart/types.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/cartesian/index.ts rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useAutoAngleCalculation.ts (100%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useChartCondensedOrchestrator.ts (87%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useChartDimensions.ts (87%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useChartScroll.ts (89%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useChartScrollableOrchestrator.ts (90%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useMaxLabelWidth.ts (90%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useStackedData.ts (93%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useXAxisHeight.ts (95%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useXBandScale.ts (92%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useXScale.ts (91%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useYAxisWidth.ts (92%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => cartesian}/useYScale.ts (95%) create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/core/index.ts rename packages/react-ui/src/components/ChartsV2/hooks/{ => core}/useCanvasContextForLabelSize.ts (88%) create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/core/useCategoricalChartData.ts rename packages/react-ui/src/components/ChartsV2/hooks/{ => core}/useChartData.ts (93%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => core}/useChartHover.ts (97%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => core}/useContainerSize.ts (100%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => core}/useLegendHeight.ts (100%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => core}/usePrintContext.ts (100%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => core}/useTooltipPayload.ts (85%) rename packages/react-ui/src/components/ChartsV2/hooks/{ => core}/useTransformedKeys.ts (100%) create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/polar/useCategoricalChartOrchestrator.ts create mode 100644 packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx index 141c3f774..2c8dc52b0 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx @@ -1,10 +1,12 @@ import { useCallback } from "react"; -import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useStackedData } from "../hooks/useStackedData"; -import { useXScale } from "../hooks/useXScale"; -import { useYScale } from "../hooks/useYScale"; +import { + useChartCondensedOrchestrator, + usePrintContext, + useStackedData, + useXScale, + useYScale, +} from "../hooks"; import { AngledXAxis } from "../shared/AngledXAxis"; import { CondensedChartLayout } from "../shared/CondensedChartLayout"; import { LineDotCrosshair } from "../shared/LineDotCrosshair"; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx index a442af553..b567f87a4 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx @@ -1,10 +1,12 @@ import { useCallback } from "react"; -import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useStackedData } from "../hooks/useStackedData"; -import { useXScale } from "../hooks/useXScale"; -import { useYScale } from "../hooks/useYScale"; +import { + useChartScrollableOrchestrator, + usePrintContext, + useStackedData, + useXScale, + useYScale, +} from "../hooks"; import { LineDotCrosshair } from "../shared/LineDotCrosshair"; import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; import { XAxis } from "../shared/XAxis"; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx index 63a7b810f..7e307cc6d 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/AreaSeries.tsx @@ -7,7 +7,7 @@ import { line as d3Line, } from "d3-shape"; import React, { useEffect, useMemo, useRef } from "react"; -import type { StackedData } from "../../hooks/useStackedData"; +import type { StackedData } from "../../hooks"; import { D3AreaChartVariant } from "../types"; const curveMap = { diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx index 8d26cc84c..607dfa034 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx @@ -1,10 +1,12 @@ import { useCallback } from "react"; -import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useStackedData } from "../hooks/useStackedData"; -import { useXBandScale } from "../hooks/useXBandScale"; -import { useYScale } from "../hooks/useYScale"; +import { + useChartCondensedOrchestrator, + usePrintContext, + useStackedData, + useXBandScale, + useYScale, +} from "../hooks"; import { AngledXAxis } from "../shared/AngledXAxis"; import { ClipDefs } from "../shared/ClipDefs"; import { CondensedChartLayout } from "../shared/CondensedChartLayout"; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx index 18ccae62a..fea97778a 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx @@ -1,10 +1,12 @@ import { useCallback } from "react"; -import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useStackedData } from "../hooks/useStackedData"; -import { useXBandScale } from "../hooks/useXBandScale"; -import { useYScale } from "../hooks/useYScale"; +import { + useChartScrollableOrchestrator, + usePrintContext, + useStackedData, + useXBandScale, + useYScale, +} from "../hooks"; import { ClipDefs } from "../shared/ClipDefs"; import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; import { XAxis } from "../shared/XAxis"; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/BarSeries.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/BarSeries.tsx index 866e792e7..35c3ae771 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/BarSeries.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/BarSeries.tsx @@ -1,7 +1,7 @@ import type { ScaleBand, ScaleLinear } from "d3-scale"; import { scaleBand } from "d3-scale"; import React, { useMemo } from "react"; -import type { StackedData } from "../../hooks/useStackedData"; +import type { StackedData } from "../../hooks"; import type { D3BarChartVariant } from "../types"; const DEFAULT_MAX_BAR_WIDTH = 16; diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx index b67c938cd..25d90d0af 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx @@ -1,9 +1,6 @@ import { useCallback } from "react"; -import { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useXScale } from "../hooks/useXScale"; -import { useYScale } from "../hooks/useYScale"; +import { useChartCondensedOrchestrator, usePrintContext, useXScale, useYScale } from "../hooks"; import { AngledXAxis } from "../shared/AngledXAxis"; import { ClipDefs } from "../shared/ClipDefs"; import { CondensedChartLayout } from "../shared/CondensedChartLayout"; diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx index 9c5947186..479553fb9 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx @@ -1,9 +1,6 @@ import { useCallback } from "react"; -import { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; -import { usePrintContext } from "../hooks/usePrintContext"; -import { useXScale } from "../hooks/useXScale"; -import { useYScale } from "../hooks/useYScale"; +import { useChartScrollableOrchestrator, usePrintContext, useXScale, useYScale } from "../hooks"; import { ClipDefs } from "../shared/ClipDefs"; import { LineDotCrosshair } from "../shared/LineDotCrosshair"; import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; 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..75b54fd08 --- /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/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/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/D3RadialChart/D3RadialChart.tsx b/packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx new file mode 100644 index 000000000..67d8e781f --- /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/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/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/chartsV2.scss b/packages/react-ui/src/components/ChartsV2/chartsV2.scss index 63b8d1d15..ec3401e13 100644 --- a/packages/react-ui/src/components/ChartsV2/chartsV2.scss +++ b/packages/react-ui/src/components/ChartsV2/chartsV2.scss @@ -1,6 +1,8 @@ @forward "./D3AreaChart/d3AreaChart"; @forward "./D3LineChart/d3LineChart"; @forward "./D3BarChart/d3BarChart"; +@forward "./D3PieChart/d3PieChart"; +@forward "./D3RadialChart/d3RadialChart"; @forward "./shared/PortalTooltip/portalTooltip"; @forward "./shared/DefaultLegend/defaultLegend"; @forward "./shared/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..50b6de0df --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/index.ts @@ -0,0 +1,12 @@ +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 "./useYScale"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useAutoAngleCalculation.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useAutoAngleCalculation.ts similarity index 100% rename from packages/react-ui/src/components/ChartsV2/hooks/useAutoAngleCalculation.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useAutoAngleCalculation.ts diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartCondensedOrchestrator.ts similarity index 87% rename from packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartCondensedOrchestrator.ts index 1d9fed95e..1deff1545 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartCondensedOrchestrator.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartCondensedOrchestrator.ts @@ -1,18 +1,22 @@ 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 { 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 { useChartData } from "./useChartData"; -import { useChartHover } from "./useChartHover"; -import { useContainerSize } from "./useContainerSize"; -import { useLegendHeight } from "./useLegendHeight"; import { useMaxLabelWidth } from "./useMaxLabelWidth"; -import { useTooltipPayload } from "./useTooltipPayload"; import { useYAxisWidth } from "./useYAxisWidth"; -import type { ChartData } from "../types"; -import type { PaletteName } from "../utils/paletteUtils"; +import type { ChartData } from "../../types"; +import type { PaletteName } from "../../utils/paletteUtils"; export interface UseChartCondensedOrchestratorParams { data: T; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartDimensions.ts similarity index 87% rename from packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartDimensions.ts index 2c6d851f2..898ed560e 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartDimensions.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartDimensions.ts @@ -1,14 +1,18 @@ 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 "./useContainerSize"; -import { useLegendHeight } from "./useLegendHeight"; +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"; +import type { ChartData } from "../../types"; +import type { ChartDensity } from "../../utils/scrollUtils"; export interface UseChartDimensionsParams { containerRef: React.RefObject; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScroll.ts similarity index 89% rename from packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScroll.ts index b40f04a1b..abe430952 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartScroll.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScroll.ts @@ -1,9 +1,9 @@ import React, { useCallback, useEffect, useState } from "react"; -import { findNearestSnapPosition, getSnapPositions } from "../utils/scrollUtils"; +import { findNearestSnapPosition, getSnapPositions } from "../../utils/scrollUtils"; -import type { ChartData } from "../types"; -import type { ChartDensity } from "../utils/scrollUtils"; +import type { ChartData } from "../../types"; +import type { ChartDensity } from "../../utils/scrollUtils"; export interface UseChartScrollParams { mainContainerRef: React.RefObject; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScrollableOrchestrator.ts similarity index 90% rename from packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScrollableOrchestrator.ts index dfcd21339..3524433aa 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartScrollableOrchestrator.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useChartScrollableOrchestrator.ts @@ -1,16 +1,15 @@ import React, { useId, useMemo, useRef, useState } from "react"; -import { buildContainerStyle } from "../utils/buildContainerStyle"; -import { useTooltipPayload } from "./useTooltipPayload"; - -import { useChartData } from "./useChartData"; +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 { useChartHover } from "./useChartHover"; import { useChartScroll } from "./useChartScroll"; -import type { ChartData } from "../types"; -import type { PaletteName } from "../utils/paletteUtils"; -import type { ChartDensity } from "../utils/scrollUtils"; +import type { ChartData } from "../../types"; +import type { PaletteName } from "../../utils/paletteUtils"; +import type { ChartDensity } from "../../utils/scrollUtils"; export interface UseChartScrollableOrchestratorParams { data: T; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useMaxLabelWidth.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useMaxLabelWidth.ts similarity index 90% rename from packages/react-ui/src/components/ChartsV2/hooks/useMaxLabelWidth.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useMaxLabelWidth.ts index d7e018481..580d6564d 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useMaxLabelWidth.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useMaxLabelWidth.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { useCanvasContextForLabelSize } from "./useCanvasContextForLabelSize"; +import { useCanvasContextForLabelSize } from "../core/useCanvasContextForLabelSize"; /** * Measures the pixel width of each category label and returns the maximum width found. diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useStackedData.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useStackedData.ts similarity index 93% rename from packages/react-ui/src/components/ChartsV2/hooks/useStackedData.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useStackedData.ts index b68b65e81..273093ab4 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useStackedData.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useStackedData.ts @@ -1,7 +1,7 @@ import type { Series } from "d3-shape"; import { stack, stackOffsetNone, stackOrderNone } from "d3-shape"; import { useMemo } from "react"; -import type { ChartData } from "../types"; +import type { ChartData } from "../../types"; export type StackedData = Series, string>[]; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts similarity index 95% rename from packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts index 1ef91c644..d94f94fb8 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useXAxisHeight.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { useTheme } from "../../ThemeProvider"; -import { XAxisTickVariant } from "../types"; +import { useTheme } from "../../../ThemeProvider"; +import { XAxisTickVariant } from "../../types"; const DEFAULT_HEIGHT = 30; const X_AXIS_LABEL_PADDING = 13; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useXBandScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXBandScale.ts similarity index 92% rename from packages/react-ui/src/components/ChartsV2/hooks/useXBandScale.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXBandScale.ts index 35ba619fd..0bef4f4b5 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useXBandScale.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXBandScale.ts @@ -1,7 +1,7 @@ import type { ScaleBand } from "d3-scale"; import { scaleBand } from "d3-scale"; import { useMemo } from "react"; -import type { ChartData } from "../types"; +import type { ChartData } from "../../types"; export const useXBandScale = ( data: ChartData, diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useXScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXScale.ts similarity index 91% rename from packages/react-ui/src/components/ChartsV2/hooks/useXScale.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXScale.ts index bd5507e60..2dbc47f8a 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useXScale.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXScale.ts @@ -1,7 +1,7 @@ import type { ScalePoint } from "d3-scale"; import { scalePoint } from "d3-scale"; import { useMemo } from "react"; -import type { ChartData } from "../types"; +import type { ChartData } from "../../types"; export const useXScale = ( data: ChartData, diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYAxisWidth.ts similarity index 92% rename from packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYAxisWidth.ts index f4c7df6ae..7f4e54227 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useYAxisWidth.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYAxisWidth.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { numberTickFormatter } from "../utils/styleUtils"; -import { useCanvasContextForLabelSize } from "./useCanvasContextForLabelSize"; +import { numberTickFormatter } from "../../utils/styleUtils"; +import { useCanvasContextForLabelSize } from "../core/useCanvasContextForLabelSize"; const DEFAULT_Y_AXIS_WIDTH = 40; const MIN_Y_AXIS_WIDTH = 20; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useYScale.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYScale.ts similarity index 95% rename from packages/react-ui/src/components/ChartsV2/hooks/useYScale.ts rename to packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYScale.ts index 0b5bf7a1c..6b87342da 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useYScale.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useYScale.ts @@ -1,7 +1,7 @@ import type { ScaleLinear } from "d3-scale"; import { scaleLinear } from "d3-scale"; import { useMemo } from "react"; -import type { ChartData } from "../types"; +import type { ChartData } from "../../types"; import type { StackedData } from "./useStackedData"; export const useYScale = ( 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..27efad2d7 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/index.ts @@ -0,0 +1,9 @@ +export * from "./useCanvasContextForLabelSize"; +export * from "./useCategoricalChartData"; +export * from "./useChartData"; +export * from "./useChartHover"; +export * from "./useContainerSize"; +export * from "./useLegendHeight"; +export * from "./usePrintContext"; +export * from "./useTooltipPayload"; +export * from "./useTransformedKeys"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useCanvasContextForLabelSize.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useCanvasContextForLabelSize.ts similarity index 88% rename from packages/react-ui/src/components/ChartsV2/hooks/useCanvasContextForLabelSize.ts rename to packages/react-ui/src/components/ChartsV2/hooks/core/useCanvasContextForLabelSize.ts index ed5811e21..b1523a001 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useCanvasContextForLabelSize.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useCanvasContextForLabelSize.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useTheme } from "../../ThemeProvider"; +import { useTheme } from "../../../ThemeProvider"; export const useCanvasContextForLabelSize = () => { const { theme: userTheme } = useTheme(); 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/useChartData.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts similarity index 93% rename from packages/react-ui/src/components/ChartsV2/hooks/useChartData.ts rename to packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts index 4b09b5fd9..98d44037a 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartData.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts @@ -1,11 +1,11 @@ import React, { useCallback, useMemo, useState } from "react"; -import { get2dChartConfig, getDataKeys, getLegendItems } from "../utils/dataUtils"; -import { useChartPalette } from "../utils/paletteUtils"; +import { get2dChartConfig, getDataKeys, getLegendItems } from "../../utils/dataUtils"; +import { useChartPalette } from "../../utils/paletteUtils"; import { useTransformedKeys } from "./useTransformedKeys"; -import type { ChartData } from "../types"; -import type { PaletteName } from "../utils/paletteUtils"; +import type { ChartData } from "../../types"; +import type { PaletteName } from "../../utils/paletteUtils"; export interface UseChartDataParams { data: T; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartHover.ts similarity index 97% rename from packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts rename to packages/react-ui/src/components/ChartsV2/hooks/core/useChartHover.ts index d66daa2b6..091c1c02e 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useChartHover.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartHover.ts @@ -1,7 +1,7 @@ import { pointer } from "d3-selection"; import React, { useCallback, useState } from "react"; -import type { ChartData } from "../types"; +import type { ChartData } from "../../types"; export interface UseChartHoverParams { data: T; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useContainerSize.ts similarity index 100% rename from packages/react-ui/src/components/ChartsV2/hooks/useContainerSize.ts rename to packages/react-ui/src/components/ChartsV2/hooks/core/useContainerSize.ts diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useLegendHeight.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useLegendHeight.ts similarity index 100% rename from packages/react-ui/src/components/ChartsV2/hooks/useLegendHeight.ts rename to packages/react-ui/src/components/ChartsV2/hooks/core/useLegendHeight.ts diff --git a/packages/react-ui/src/components/ChartsV2/hooks/usePrintContext.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/usePrintContext.ts similarity index 100% rename from packages/react-ui/src/components/ChartsV2/hooks/usePrintContext.ts rename to packages/react-ui/src/components/ChartsV2/hooks/core/usePrintContext.ts diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useTooltipPayload.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useTooltipPayload.ts similarity index 85% rename from packages/react-ui/src/components/ChartsV2/hooks/useTooltipPayload.ts rename to packages/react-ui/src/components/ChartsV2/hooks/core/useTooltipPayload.ts index 4fd4c4ce1..3b37837f0 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/useTooltipPayload.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useTooltipPayload.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import type { TooltipItem } from "../shared/PortalTooltip/ChartTooltip"; -import type { ChartData } from "../types"; +import type { TooltipItem } from "../../shared/PortalTooltip/ChartTooltip"; +import type { ChartData } from "../../types"; export interface TooltipPayload { label: string; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useTransformedKeys.ts similarity index 100% rename from packages/react-ui/src/components/ChartsV2/hooks/useTransformedKeys.ts rename to packages/react-ui/src/components/ChartsV2/hooks/core/useTransformedKeys.ts diff --git a/packages/react-ui/src/components/ChartsV2/hooks/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/index.ts index 8efc27c7a..bd686f96d 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/index.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/index.ts @@ -1,20 +1,3 @@ -export * from "./useAutoAngleCalculation"; -export * from "./useCanvasContextForLabelSize"; -export * from "./useChartCondensedOrchestrator"; -export * from "./useChartData"; -export * from "./useChartDimensions"; -export * from "./useChartHover"; -export * from "./useChartScroll"; -export * from "./useChartScrollableOrchestrator"; -export * from "./useContainerSize"; -export * from "./useLegendHeight"; -export * from "./useMaxLabelWidth"; -export * from "./usePrintContext"; -export * from "./useStackedData"; -export * from "./useTooltipPayload"; -export * from "./useTransformedKeys"; -export * from "./useXAxisHeight"; -export * from "./useXBandScale"; -export * from "./useXScale"; -export * from "./useYAxisWidth"; -export * from "./useYScale"; +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..6f55aa7f8 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts @@ -0,0 +1 @@ +export * from "./useCategoricalChartOrchestrator"; 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..ec0621187 --- /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/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/index.ts b/packages/react-ui/src/components/ChartsV2/index.ts index 108dc50a3..b341ccfd3 100644 --- a/packages/react-ui/src/components/ChartsV2/index.ts +++ b/packages/react-ui/src/components/ChartsV2/index.ts @@ -7,4 +7,10 @@ export type { D3LineChartData, D3LineChartProps, D3LineChartVariant } from "./D3 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 type { BaseChartProps, ChartData, LegendItem, XAxisTickVariant } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx b/packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx index 5d087181a..be22eda9c 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import type { ScaleLinear } from "d3-scale"; import React from "react"; -import type { useChartCondensedOrchestrator } from "../hooks/useChartCondensedOrchestrator"; +import type { useChartCondensedOrchestrator } from "../hooks"; import { DefaultLegend } from "./DefaultLegend/DefaultLegend"; import { Grid } from "./Grid"; import { LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts index b6a882805..d50166496 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts +++ b/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useCanvasContextForLabelSize } from "../../../hooks/useCanvasContextForLabelSize"; +import { useCanvasContextForLabelSize } from "../../../hooks"; import { LegendItem } from "../../../types"; const CHARACTER_WIDTH = 7; diff --git a/packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx b/packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx index 271d2a921..bdbc902df 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import type { ScaleLinear } from "d3-scale"; import React from "react"; -import type { useChartScrollableOrchestrator } from "../hooks/useChartScrollableOrchestrator"; +import type { useChartScrollableOrchestrator } from "../hooks"; import { DefaultLegend } from "./DefaultLegend/DefaultLegend"; import { Grid } from "./Grid"; import { LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; diff --git a/packages/react-ui/src/components/ChartsV2/utils/index.ts b/packages/react-ui/src/components/ChartsV2/utils/index.ts index 352642b36..ff1bcdbc3 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/index.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/index.ts @@ -3,5 +3,6 @@ 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/polarUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts new file mode 100644 index 000000000..a6cf46948 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts @@ -0,0 +1,22 @@ +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)}%`; +} From 32f7c9775d860d2008ddd24d24b4200b6b202a7a Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 05:39:32 +0530 Subject: [PATCH 19/23] docs(ChartsV2): update README for hooks reorganization and polar charts - Document the core/cartesian/polar hooks layer structure - Add polar chart architecture diagram and hook references - Add useCategoricalChartOrchestrator, useCategoricalChartData docs - Add polarUtils, layout components, condensed hooks to reference tables - Update "Adding a New Chart" with separate cartesian/polar guides Co-Authored-By: Claude Opus 4.6 --- .../src/components/ChartsV2/README.md | 141 +++++++++++++----- 1 file changed, 106 insertions(+), 35 deletions(-) diff --git a/packages/react-ui/src/components/ChartsV2/README.md b/packages/react-ui/src/components/ChartsV2/README.md index f6bc0069f..32232f047 100644 --- a/packages/react-ui/src/components/ChartsV2/README.md +++ b/packages/react-ui/src/components/ChartsV2/README.md @@ -1,6 +1,8 @@ # ChartsV2 — Internal Developer Guide -D3-based chart components for OpenUI. Three chart types share a common infrastructure of hooks, components, and utilities. +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 | | ------------- | ----------------------------- | ------------ | --------------------------------- | @@ -8,13 +10,22 @@ D3-based chart components for OpenUI. Three chart types share a common infrastru | `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 │ +┌──────────────────────────────────────────────────┐ +│ D3[Type]Chart Component │ │ │ -│ useChartScrollableOrchestrator ◄── master hook │ +│ useChartScrollableOrchestrator (hooks/cartesian)│ │ ├── useChartData (keys, colors, legend) │ │ ├── useChartDimensions (sizing, layout) │ │ ├── useChartHover (hover state, handlers)│ @@ -23,7 +34,7 @@ D3-based chart components for OpenUI. Three chart types share a common infrastru │ + type-specific scale hook (useXScale or │ │ useXBandScale) + useYScale + useStackedData │ │ │ -│ Renders: │ +│ Renders via ScrollableChartLayout: │ │ ├── YAxis (separate SVG) │ │ ├── Main SVG (scrollable container) │ │ │ ├── Grid, Series, Crosshair │ @@ -31,29 +42,67 @@ D3-based chart components for OpenUI. Three chart types share a common infrastru │ ├── 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 -All hooks are in `hooks/` and re-exported from `hooks/index.ts`. +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 | Signature | Description | -| -------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `useChartScrollableOrchestrator` | `(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 charts. | +| 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 +### 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. | -| `useStackedData` | `(data, dataKeys, stacked) => StackedData \| null` | Runs D3 `stack()` generator. Returns null when stacking is disabled. Used by AreaChart (stacked) and BarChart (stacked variant). | -| `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. | +| 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. | -### Scales +### 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 | | --------------- | ---------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------- | @@ -63,26 +112,29 @@ All hooks are in `hooks/` and re-exported from `hooks/index.ts`. ### Layout & Dimensions -| Hook | Signature | Description | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `useChartDimensions` | `(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` | `(ref, fixedWidth?, fixedHeight?) => { width, height }` | Measures container via ResizeObserver. Supports fixed overrides that bypass measurement. | -| `useXAxisHeight` | `(data, categoryKey, tickVariant, widthOfGroup?) => number` | Measures required X-axis label height by rendering hidden DOM elements. Returns 30px for `singleLine` variant. | -| `useYAxisWidth` | `(data, dataKeys) => { yAxisWidth, setLabelWidth }` | Measures Y-axis width from formatted numbers. Clamped between 20–200px. `setLabelWidth` allows runtime refinement. | -| `useCanvasContextForLabelSize` | `() => CanvasRenderingContext2D` | Returns a memoized canvas 2D context configured with theme font, used for text measurement. | +| 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 | Signature | Description | -| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `useChartHover` | `(params) => { hoveredIndex, mousePos, createMouseHandlers }` | Manages hover state. `createMouseHandlers(findIndex)` is a factory that returns `handleMouseMove`, `handleMouseLeave`, `handleTouchMove`, `handleTouchEnd`, and `handleClick`. | -| `useChartScroll` | `(params) => { canScrollLeft, canScrollRight, handleScroll, scrollTo }` | Manages horizontal scroll state with snap-to-data-point behavior. `scrollTo('left' \| 'right')` uses smooth scrolling. | +| 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 | Signature | Description | -| ----------------- | --------------- | ------------------------------------------------------------------------------------------- | -| `usePrintContext` | `() => boolean` | Detects print mode via `matchMedia("print")`. Useful for disabling animations during print. | +| Hook | Layer | Signature | Description | +| ----------------- | ----- | --------------- | ------------------------------------------------------------------------------------------- | +| `usePrintContext` | core | `() => boolean` | Detects print mode via `matchMedia("print")`. Useful for disabling animations during print. | ## Shared Components Reference @@ -93,6 +145,7 @@ All components are in `shared/` and re-exported from `shared/index.ts`. | 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. | @@ -104,6 +157,13 @@ All components are in `shared/` and re-exported from `shared/index.ts`. | `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 | @@ -136,10 +196,11 @@ All utilities are in `utils/` and re-exported from `utils/index.ts`. | `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 -To add a new chart type (e.g., `D3ScatterChart`): +### Cartesian Chart (e.g., `D3ScatterChart`) 1. **Create the directory** following the existing pattern: @@ -157,12 +218,22 @@ To add a new chart type (e.g., `D3ScatterChart`): 2. **Define types** in `types.ts` — extend `BaseChartProps` from `types/common.ts` and add chart-specific props (e.g., `dotRadius`, `variant`). -3. **Use `useChartScrollableOrchestrator`** as the primary hook — it provides data, dimensions, hover, scroll, legend, and tooltip state out of the box. +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. **Follow the render pattern** — LabelTooltipProvider > container > Y-axis SVG + scrollable main SVG + ScrollButtons + Legend + ChartTooltip. +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`. From 3c15e9e4c3dbe3fd7ea58d9a814968e3e342fcd9 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 17:00:32 +0530 Subject: [PATCH 20/23] feat(ChartsV2): add D3RadarChart component Add radar chart with polygon/circle grid shapes, multi-series support, radial scale, 2D hover detection, and legend toggle. Includes dedicated orchestrator hook, hover hook, and polar utility functions. Co-Authored-By: Claude Opus 4.6 --- .../ChartsV2/D3RadarChart/D3RadarChart.tsx | 155 +++++++++++ .../ChartsV2/D3RadarChart/d3RadarChart.scss | 66 +++++ .../components/ChartsV2/D3RadarChart/index.ts | 2 + .../D3RadarChart/parts/RadarAxisLabels.tsx | 49 ++++ .../ChartsV2/D3RadarChart/parts/RadarGrid.tsx | 56 ++++ .../D3RadarChart/parts/RadarSeries.tsx | 98 +++++++ .../stories/d3RadarChart.stories.tsx | 243 ++++++++++++++++++ .../components/ChartsV2/D3RadarChart/types.ts | 27 ++ .../src/components/ChartsV2/chartsV2.scss | 1 + .../components/ChartsV2/hooks/polar/index.ts | 2 + .../hooks/polar/useRadarChartOrchestrator.ts | 143 +++++++++++ .../ChartsV2/hooks/polar/useRadarHover.ts | 63 +++++ .../react-ui/src/components/ChartsV2/index.ts | 3 + .../components/ChartsV2/utils/polarUtils.ts | 11 + 14 files changed, 919 insertions(+) create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadarChart/D3RadarChart.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadarChart/d3RadarChart.scss create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadarChart/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarAxisLabels.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarGrid.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadarChart/parts/RadarSeries.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadarChart/stories/d3RadarChart.stories.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3RadarChart/types.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/polar/useRadarChartOrchestrator.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/polar/useRadarHover.ts 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..0a5844ae5 --- /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/DefaultLegend/DefaultLegend"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/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/chartsV2.scss b/packages/react-ui/src/components/ChartsV2/chartsV2.scss index ec3401e13..d514a3cd7 100644 --- a/packages/react-ui/src/components/ChartsV2/chartsV2.scss +++ b/packages/react-ui/src/components/ChartsV2/chartsV2.scss @@ -3,6 +3,7 @@ @forward "./D3BarChart/d3BarChart"; @forward "./D3PieChart/d3PieChart"; @forward "./D3RadialChart/d3RadialChart"; +@forward "./D3RadarChart/d3RadarChart"; @forward "./shared/PortalTooltip/portalTooltip"; @forward "./shared/DefaultLegend/defaultLegend"; @forward "./shared/ScrollButtonsHorizontal/scrollButtonsHorizontal"; diff --git a/packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts index 6f55aa7f8..d3ffe0671 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/polar/index.ts @@ -1 +1,3 @@ export * from "./useCategoricalChartOrchestrator"; +export * from "./useRadarChartOrchestrator"; +export * from "./useRadarHover"; 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 index b341ccfd3..6790272d9 100644 --- a/packages/react-ui/src/components/ChartsV2/index.ts +++ b/packages/react-ui/src/components/ChartsV2/index.ts @@ -13,4 +13,7 @@ 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 type { BaseChartProps, ChartData, LegendItem, XAxisTickVariant } from "./types"; diff --git a/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts index a6cf46948..5cf2e0368 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/polarUtils.ts @@ -20,3 +20,14 @@ 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"; +} From 5954240771a0069cff33e658beedd74e7ca39d39 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sun, 8 Mar 2026 17:00:51 +0530 Subject: [PATCH 21/23] feat(ChartsV2): add D3ScatterChart component Add scatter chart with continuous numeric X/Y axes, grouped datasets data model, 2D nearest-point hover with snap radius, dataset-level highlighting, and vertical grid support. Includes dedicated orchestrator hook, NumericXAxis part, and ScatterDots part. Co-Authored-By: Claude Opus 4.6 --- .../D3ScatterChart/D3ScatterChart.tsx | 197 +++++++++++ .../D3ScatterChart/d3ScatterChart.scss | 53 +++ .../ChartsV2/D3ScatterChart/index.ts | 2 + .../D3ScatterChart/parts/NumericXAxis.tsx | 41 +++ .../D3ScatterChart/parts/ScatterDots.tsx | 67 ++++ .../stories/d3ScatterChart.stories.tsx | 260 +++++++++++++++ .../ChartsV2/D3ScatterChart/types.ts | 45 +++ .../src/components/ChartsV2/chartsV2.scss | 1 + .../ChartsV2/hooks/cartesian/index.ts | 1 + .../cartesian/useScatterChartOrchestrator.ts | 312 ++++++++++++++++++ .../react-ui/src/components/ChartsV2/index.ts | 3 + 11 files changed, 982 insertions(+) create mode 100644 packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3ScatterChart/d3ScatterChart.scss create mode 100644 packages/react-ui/src/components/ChartsV2/D3ScatterChart/index.ts create mode 100644 packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/NumericXAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/ScatterDots.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3ScatterChart/stories/d3ScatterChart.stories.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/D3ScatterChart/types.ts create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/cartesian/useScatterChartOrchestrator.ts 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..6dd918893 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx @@ -0,0 +1,197 @@ +import clsx from "clsx"; +import type { ScaleLinear } from "d3-scale"; +import React from "react"; + +import { useScatterChartOrchestrator } from "../hooks"; +import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; +import { Grid } from "../shared/Grid"; +import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; +import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +import { YAxis } from "../shared/YAxis"; +import { NumericXAxis } from "./parts/NumericXAxis"; +import { ScatterDots } from "./parts/ScatterDots"; +import type { D3ScatterChartProps } from "./types"; + +const MIN_TICK_SPACING = 60; + +interface VerticalGridProps { + xScale: ScaleLinear; + chartWidth: number; + chartHeight: number; + className?: string; +} + +const VerticalGrid: React.FC = ({ + xScale, + chartWidth, + chartHeight, + className, +}) => { + const tickCount = Math.max(2, Math.floor(chartWidth / MIN_TICK_SPACING)); + const ticks = xScale.ticks(tickCount); + + return ( + + {ticks.map((t) => ( + + ))} + + ); +}; + +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, + 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..47d702aac --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/parts/ScatterDots.tsx @@ -0,0 +1,67 @@ +import type { ScaleLinear } from "d3-scale"; +import React from "react"; + +import type { D3ScatterChartData, HoveredScatterPoint, ScatterDataset } from "../types"; + +interface ScatterDotsProps { + datasets: ScatterDataset[]; + allDatasets: D3ScatterChartData; + xScale: ScaleLinear; + yScale: ScaleLinear; + colorMap: Record; + dotRadius: number; + hoveredPoint: HoveredScatterPoint | null; + isAnimationActive: boolean; + isPrinting: boolean; +} + +export const ScatterDots: React.FC = ({ + datasets, + allDatasets, + xScale, + yScale, + colorMap, + dotRadius, + hoveredPoint, + isAnimationActive, + isPrinting, +}) => { + const shouldAnimate = isAnimationActive && !isPrinting; + + return ( + + {datasets.map((ds) => { + const color = colorMap[ds.name] ?? "#000"; + const datasetIdx = allDatasets.indexOf(ds); + 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 ? `${datasetIdx * 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/chartsV2.scss b/packages/react-ui/src/components/ChartsV2/chartsV2.scss index d514a3cd7..17446acff 100644 --- a/packages/react-ui/src/components/ChartsV2/chartsV2.scss +++ b/packages/react-ui/src/components/ChartsV2/chartsV2.scss @@ -4,6 +4,7 @@ @forward "./D3PieChart/d3PieChart"; @forward "./D3RadialChart/d3RadialChart"; @forward "./D3RadarChart/d3RadarChart"; +@forward "./D3ScatterChart/d3ScatterChart"; @forward "./shared/PortalTooltip/portalTooltip"; @forward "./shared/DefaultLegend/defaultLegend"; @forward "./shared/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 index 50b6de0df..4c4bbeeca 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/index.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/index.ts @@ -9,4 +9,5 @@ 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/useScatterChartOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useScatterChartOrchestrator.ts new file mode 100644 index 000000000..3afe3bf9c --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useScatterChartOrchestrator.ts @@ -0,0 +1,312 @@ +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 { getDistributedColors, getPalette, type PaletteName } from "../../utils/paletteUtils"; +import { numberTickFormatter } from "../../utils/styleUtils"; +import { useCanvasContextForLabelSize } from "../core/useCanvasContextForLabelSize"; +import { useContainerSize } from "../core/useContainerSize"; +import { useLegendHeight } from "../core/useLegendHeight"; +import { usePrintContext } from "../core/usePrintContext"; + +const SNAP_RADIUS = 30; +const X_AXIS_HEIGHT = 28; + +export interface UseScatterChartOrchestratorParams { + data: D3ScatterChartData; + chartThemeName: PaletteName; + customPalette?: string[]; + showLegend: boolean; + showYAxis: boolean; + height?: number | string; + width?: number | string; + fitLegendInHeight?: boolean; + onClick?: ( + point: ScatterPoint, + datasetName: string, + datasetIndex: number, + pointIndex: number, + ) => void; +} + +export function useScatterChartOrchestrator({ + data, + chartThemeName, + customPalette, + showLegend, + showYAxis, + height, + width, + fitLegendInHeight, + onClick, +}: UseScatterChartOrchestratorParams) { + const containerRef = useRef(null); + const legendRef = useRef(null); + const [isLegendExpanded, setIsLegendExpanded] = useState(false); + const [hiddenSeries, setHiddenSeries] = useState>(new Set()); + 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 --- + const datasetNames = useMemo(() => data.map((ds) => ds.name), [data]); + const palette = useMemo(() => { + if (customPalette) return customPalette; + return getPalette(chartThemeName).colors; + }, [chartThemeName, customPalette]); + + const distributedColors = useMemo( + () => getDistributedColors(palette, datasetNames.length), + [palette, datasetNames.length], + ); + + const colorMap = useMemo(() => { + const map: Record = {}; + datasetNames.forEach((name, i) => { + map[name] = distributedColors[i] ?? "#000"; + }); + return map; + }, [datasetNames, distributedColors]); + + // --- Hidden series --- + const toggleSeries = useCallback( + (name: string) => { + setHiddenSeries((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + if (next.size >= datasetNames.length - 1) return prev; + next.add(name); + } + return next; + }); + }, + [datasetNames.length], + ); + + const visibleDatasets = useMemo( + () => data.filter((ds) => !hiddenSeries.has(ds.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 --- + const yAxisWidth = useMemo(() => { + if (!showYAxis) return 0; + const tempScale = scaleLinear().domain([yMin, yMax]).nice(); + const ticks = tempScale.ticks(); + let maxWidth = 0; + for (const tick of ticks) { + const w = context.measureText(numberTickFormatter(tick)).width; + if (w > maxWidth) maxWidth = w; + } + return Math.max(20, Math.min(200, Math.ceil(maxWidth) + 10)); + }, [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 handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const [mx, my] = pointer(event.nativeEvent, event.currentTarget); + let minDist = Infinity; + let closest: HoveredScatterPoint | null = null; + + for (let dsIdx = 0; dsIdx < visibleDatasets.length; dsIdx++) { + const ds = visibleDatasets[dsIdx]!; + const originalIdx = data.indexOf(ds); + 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: originalIdx, + pointIndex: ptIdx, + point: pt, + datasetName: ds.name, + }; + } + } + } + + if (closest && minDist <= SNAP_RADIUS) { + setHoveredPoint(closest); + } else { + setHoveredPoint(null); + } + + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + setMousePos({ + x: event.clientX, + y: event.clientY, + }); + } + }, + [visibleDatasets, data, xScale, yScale], + ); + + const handleMouseLeave = useCallback(() => { + setHoveredPoint(null); + setMousePos(null); + }, []); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const syntheticEvent = { + nativeEvent: touch, + currentTarget: event.currentTarget, + clientX: touch.clientX, + clientY: touch.clientY, + } as unknown as React.MouseEvent; + handleMouseMove(syntheticEvent); + }, + [handleMouseMove], + ); + + 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 --- + const tooltipPayload = useMemo(() => { + if (!hoveredPoint) return null; + const color = colorMap[hoveredPoint.datasetName] ?? "#000"; + return { + label: hoveredPoint.datasetName, + items: [ + { name: "X", value: hoveredPoint.point.x, color }, + { name: "Y", value: hoveredPoint.point.y, color }, + ], + }; + }, [hoveredPoint, colorMap]); + + // --- 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/index.ts b/packages/react-ui/src/components/ChartsV2/index.ts index 6790272d9..d6d968536 100644 --- a/packages/react-ui/src/components/ChartsV2/index.ts +++ b/packages/react-ui/src/components/ChartsV2/index.ts @@ -16,4 +16,7 @@ export type { D3RadialChartData, D3RadialChartProps } from "./D3RadialChart/type 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"; From 45fdd65db6c890bcecb58efc6eac3d973b323faa Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Thu, 19 Mar 2026 16:48:50 +0530 Subject: [PATCH 22/23] refactor(ChartsV2): fix SSR, align scatter chart, restructure shared components - Fix useXAxisHeight SSR crash: replace DOM measurement with canvas-based word-wrap simulation in useMemo (no more layout thrashing or first-render jump) - Fix useCanvasContextForLabelSize SSR crash: add typed stub guard that preserves font string for correct line-height computation on server - Extract useSeriesVisibility hook: unify hidden series toggle logic between useChartData and scatter orchestrator (eliminates drift risk) - Extract VerticalGrid to shared/cartesian/ component - Extract measureYAxisWidth utility to utils/styleUtils - Fix scatter palette bug: use useChartPalette (respects ThemeProvider overrides) - Fix scatter touch event: replace unsafe as-unknown cast with findNearestPoint(clientX, clientY, target) shared function - Fix scatter tooltip: use xAxisLabel/yAxisLabel props instead of hardcoded "X"/"Y" - Fix ScatterDots: pass originalIndex via VisibleDataset, remove fragile allDatasets.indexOf() reference comparison - Restructure shared/ into shared/core/ and shared/cartesian/ subdirectories Co-Authored-By: Claude Opus 4.6 (1M context) --- .../D3AreaChart/D3AreaChartCondensed.tsx | 6 +- .../D3AreaChart/D3AreaChartScrollable.tsx | 6 +- .../ChartsV2/D3AreaChart/d3AreaChart.scss | 2 +- .../D3AreaChart/parts/GradientDefs.tsx | 2 +- .../ChartsV2/D3AreaChart/parts/XAxis.tsx | 58 ------- .../D3BarChart/D3BarChartCondensed.tsx | 6 +- .../D3BarChart/D3BarChartScrollable.tsx | 6 +- .../ChartsV2/D3BarChart/d3BarChart.scss | 2 +- .../ChartsV2/D3BarChart/parts/XAxis.tsx | 57 ------- .../D3LineChart/D3LineChartCondensed.tsx | 8 +- .../D3LineChart/D3LineChartScrollable.tsx | 8 +- .../ChartsV2/D3LineChart/d3LineChart.scss | 2 +- .../ChartsV2/D3LineChart/parts/XAxis.tsx | 58 ------- .../ChartsV2/D3PieChart/D3PieChart.tsx | 6 +- .../ChartsV2/D3RadarChart/D3RadarChart.tsx | 6 +- .../ChartsV2/D3RadialChart/D3RadialChart.tsx | 6 +- .../D3ScatterChart/D3ScatterChart.tsx | 43 +----- .../D3ScatterChart/parts/ScatterDots.tsx | 12 +- .../src/components/ChartsV2/chartsV2.scss | 6 +- .../cartesian/useScatterChartOrchestrator.ts | 112 ++++++-------- .../hooks/cartesian/useXAxisHeight.ts | 146 ++++++++++-------- .../components/ChartsV2/hooks/core/index.ts | 1 + .../core/useCanvasContextForLabelSize.ts | 12 +- .../ChartsV2/hooks/core/useChartData.ts | 22 +-- .../hooks/core/useSeriesVisibility.ts | 23 +++ .../ChartsV2/hooks/core/useTooltipPayload.ts | 2 +- .../polar/useCategoricalChartOrchestrator.ts | 2 +- .../shared/{ => cartesian}/ClipDefs.tsx | 0 .../ChartsV2/shared/{ => cartesian}/Grid.tsx | 0 .../{ => cartesian}/LineDotCrosshair.tsx | 0 .../ScrollButtonsHorizontal.tsx | 2 +- .../scrollButtonsHorizontal.scss | 2 +- .../shared/cartesian/VerticalGrid.tsx | 31 ++++ .../{ => cartesian/axes}/AngledXAxis.tsx | 0 .../shared/{ => cartesian/axes}/XAxis.tsx | 2 +- .../{ => cartesian/axes}/XAxisLabel.tsx | 6 +- .../shared/{ => cartesian/axes}/YAxis.tsx | 2 +- .../shared/{ => cartesian}/chartBase.scss | 2 +- .../layouts}/CondensedChartLayout.tsx | 12 +- .../layouts}/ScrollableChartLayout.tsx | 14 +- .../DefaultLegend/DefaultLegend.tsx | 4 +- .../DefaultLegend/defaultLegend.scss | 2 +- .../DefaultLegend/hooks/useDefaultLegend.ts | 4 +- .../{ => core}/LabelTooltip/LabelTooltip.tsx | 0 .../{ => core}/PortalTooltip/ChartTooltip.tsx | 2 +- .../PortalTooltip/portalTooltip.scss | 2 +- .../{ => core}/PortalTooltip/utils/index.ts | 0 .../shared/{ => core}/useIsTruncated.ts | 0 .../src/components/ChartsV2/shared/index.ts | 30 ++-- .../components/ChartsV2/utils/styleUtils.ts | 24 ++- 50 files changed, 320 insertions(+), 441 deletions(-) delete mode 100644 packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx delete mode 100644 packages/react-ui/src/components/ChartsV2/D3BarChart/parts/XAxis.tsx delete mode 100644 packages/react-ui/src/components/ChartsV2/D3LineChart/parts/XAxis.tsx create mode 100644 packages/react-ui/src/components/ChartsV2/hooks/core/useSeriesVisibility.ts rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian}/ClipDefs.tsx (100%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian}/Grid.tsx (100%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian}/LineDotCrosshair.tsx (100%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian}/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx (96%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian}/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss (92%) create mode 100644 packages/react-ui/src/components/ChartsV2/shared/cartesian/VerticalGrid.tsx rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian/axes}/AngledXAxis.tsx (100%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian/axes}/XAxis.tsx (97%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian/axes}/XAxisLabel.tsx (79%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian/axes}/YAxis.tsx (92%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian}/chartBase.scss (98%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian/layouts}/CondensedChartLayout.tsx (92%) rename packages/react-ui/src/components/ChartsV2/shared/{ => cartesian/layouts}/ScrollableChartLayout.tsx (91%) rename packages/react-ui/src/components/ChartsV2/shared/{ => core}/DefaultLegend/DefaultLegend.tsx (97%) rename packages/react-ui/src/components/ChartsV2/shared/{ => core}/DefaultLegend/defaultLegend.scss (97%) rename packages/react-ui/src/components/ChartsV2/shared/{ => core}/DefaultLegend/hooks/useDefaultLegend.ts (95%) rename packages/react-ui/src/components/ChartsV2/shared/{ => core}/LabelTooltip/LabelTooltip.tsx (100%) rename packages/react-ui/src/components/ChartsV2/shared/{ => core}/PortalTooltip/ChartTooltip.tsx (98%) rename packages/react-ui/src/components/ChartsV2/shared/{ => core}/PortalTooltip/portalTooltip.scss (98%) rename packages/react-ui/src/components/ChartsV2/shared/{ => core}/PortalTooltip/utils/index.ts (100%) rename packages/react-ui/src/components/ChartsV2/shared/{ => core}/useIsTruncated.ts (100%) diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx index 2c8dc52b0..a47c5edb7 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartCondensed.tsx @@ -7,9 +7,9 @@ import { useXScale, useYScale, } from "../hooks"; -import { AngledXAxis } from "../shared/AngledXAxis"; -import { CondensedChartLayout } from "../shared/CondensedChartLayout"; -import { LineDotCrosshair } from "../shared/LineDotCrosshair"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx index b567f87a4..1ff1e7ff8 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/D3AreaChartScrollable.tsx @@ -7,9 +7,9 @@ import { useXScale, useYScale, } from "../hooks"; -import { LineDotCrosshair } from "../shared/LineDotCrosshair"; -import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; -import { XAxis } from "../shared/XAxis"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss index 2704aefe4..b138e86a1 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/d3AreaChart.scss @@ -1,4 +1,4 @@ -@use "../shared/chartBase" as base; +@use "../shared/cartesian/chartBase" as base; @include base.chart-base("area-chart"); @include base.crosshair-styles("area-chart"); diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx index e4a75dae0..b9566ff5c 100644 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/GradientDefs.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ClipDefs } from "../../shared/ClipDefs"; +import { ClipDefs } from "../../shared/cartesian/ClipDefs"; const GRADIENT_TOP_OPACITY = 0.6; const GRADIENT_BOTTOM_OPACITY = 0; diff --git a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx deleted file mode 100644 index 764da0096..000000000 --- a/packages/react-ui/src/components/ChartsV2/D3AreaChart/parts/XAxis.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { ScalePoint } from "d3-scale"; -import React from "react"; -import { XAxisLabel } from "../../shared/XAxisLabel"; -import { XAxisTickVariant } from "../../types"; - -const X_AXIS_TOP_GAP = 4; - -interface XAxisProps { - scale: ScalePoint; - data: Array>; - categoryKey: string; - tickVariant: XAxisTickVariant; - widthOfGroup: number; - labelHeight: number; - labelInterval?: number; -} - -export const XAxis: React.FC = ({ - scale, - data: _data, - categoryKey: _categoryKey, - tickVariant, - widthOfGroup, - labelHeight, - labelInterval = 1, -}) => { - const domain = scale.domain(); - - return ( - - {domain.map((category, i) => { - const x = scale(category) ?? 0; - 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/D3BarChart/D3BarChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx index 607dfa034..452a71bab 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartCondensed.tsx @@ -7,9 +7,9 @@ import { useXBandScale, useYScale, } from "../hooks"; -import { AngledXAxis } from "../shared/AngledXAxis"; -import { ClipDefs } from "../shared/ClipDefs"; -import { CondensedChartLayout } from "../shared/CondensedChartLayout"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx index fea97778a..66b24758d 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/D3BarChartScrollable.tsx @@ -7,9 +7,9 @@ import { useXBandScale, useYScale, } from "../hooks"; -import { ClipDefs } from "../shared/ClipDefs"; -import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; -import { XAxis } from "../shared/XAxis"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss b/packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss index ad3b79462..c36ad8168 100644 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss +++ b/packages/react-ui/src/components/ChartsV2/D3BarChart/d3BarChart.scss @@ -1,5 +1,5 @@ @use "../../../cssUtils" as cssUtils; -@use "../shared/chartBase" as base; +@use "../shared/cartesian/chartBase" as base; @include base.chart-base("bar-chart"); diff --git a/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/XAxis.tsx deleted file mode 100644 index 3a8a3bc23..000000000 --- a/packages/react-ui/src/components/ChartsV2/D3BarChart/parts/XAxis.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ScaleBand } from "d3-scale"; -import React from "react"; -import { XAxisLabel } from "../../shared/XAxisLabel"; -import { XAxisTickVariant } from "../../types"; - -const X_AXIS_TOP_GAP = 4; - -interface XAxisProps { - scale: ScaleBand; - data: Array>; - categoryKey: string; - tickVariant: XAxisTickVariant; - labelHeight: number; - labelInterval?: number; -} - -export const XAxis: React.FC = ({ - scale, - data: _data, - categoryKey: _categoryKey, - tickVariant, - labelHeight, - labelInterval = 1, -}) => { - const domain = scale.domain(); - const bandWidth = scale.bandwidth(); - - return ( - - {domain.map((category, i) => { - const x = scale(category) ?? 0; - 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/D3LineChart/D3LineChartCondensed.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx index 25d90d0af..832843d7e 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartCondensed.tsx @@ -1,10 +1,10 @@ import { useCallback } from "react"; import { useChartCondensedOrchestrator, usePrintContext, useXScale, useYScale } from "../hooks"; -import { AngledXAxis } from "../shared/AngledXAxis"; -import { ClipDefs } from "../shared/ClipDefs"; -import { CondensedChartLayout } from "../shared/CondensedChartLayout"; -import { LineDotCrosshair } from "../shared/LineDotCrosshair"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx index 479553fb9..eeb7d8245 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/D3LineChartScrollable.tsx @@ -1,10 +1,10 @@ import { useCallback } from "react"; import { useChartScrollableOrchestrator, usePrintContext, useXScale, useYScale } from "../hooks"; -import { ClipDefs } from "../shared/ClipDefs"; -import { LineDotCrosshair } from "../shared/LineDotCrosshair"; -import { ScrollableChartLayout } from "../shared/ScrollableChartLayout"; -import { XAxis } from "../shared/XAxis"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss b/packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss index f2f2a5afa..40b45b65e 100644 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss +++ b/packages/react-ui/src/components/ChartsV2/D3LineChart/d3LineChart.scss @@ -1,5 +1,5 @@ @use "../../../cssUtils" as cssUtils; -@use "../shared/chartBase" as base; +@use "../shared/cartesian/chartBase" as base; @include base.chart-base("line-chart"); @include base.crosshair-styles("line-chart"); diff --git a/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/XAxis.tsx deleted file mode 100644 index 675d21689..000000000 --- a/packages/react-ui/src/components/ChartsV2/D3LineChart/parts/XAxis.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { ScalePoint } from "d3-scale"; -import React from "react"; -import { XAxisLabel } from "../../shared/XAxisLabel"; -import { XAxisTickVariant } from "../../types"; - -const X_AXIS_TOP_GAP = 4; - -interface XAxisProps { - scale: ScalePoint; - data: Array>; - categoryKey: string; - tickVariant: XAxisTickVariant; - widthOfGroup: number; - labelHeight: number; - labelInterval?: number; -} - -export const XAxis: React.FC = ({ - scale, - data: _data, - categoryKey: _categoryKey, - tickVariant, - widthOfGroup, - labelHeight, - labelInterval = 1, -}) => { - const domain = scale.domain(); - - return ( - - {domain.map((category, i) => { - const x = scale(category) ?? 0; - 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/D3PieChart/D3PieChart.tsx b/packages/react-ui/src/components/ChartsV2/D3PieChart/D3PieChart.tsx index 75b54fd08..9dcfdf2ad 100644 --- a/packages/react-ui/src/components/ChartsV2/D3PieChart/D3PieChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3PieChart/D3PieChart.tsx @@ -3,9 +3,9 @@ import { arc, pie, type PieArcDatum } from "d3-shape"; import { useMemo } from "react"; import { type CategoricalSlice, useCategoricalChartOrchestrator } from "../hooks"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3RadarChart/D3RadarChart.tsx b/packages/react-ui/src/components/ChartsV2/D3RadarChart/D3RadarChart.tsx index 0a5844ae5..452eb8290 100644 --- a/packages/react-ui/src/components/ChartsV2/D3RadarChart/D3RadarChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3RadarChart/D3RadarChart.tsx @@ -2,9 +2,9 @@ import clsx from "clsx"; import { useCallback, useMemo } from "react"; import { useRadarChartOrchestrator } from "../hooks"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx b/packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx index 67d8e781f..35c051135 100644 --- a/packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3RadialChart/D3RadialChart.tsx @@ -2,9 +2,9 @@ import clsx from "clsx"; import { useMemo } from "react"; import { useCategoricalChartOrchestrator } from "../hooks"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; +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"; diff --git a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx index 6dd918893..d3f41aff1 100644 --- a/packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx +++ b/packages/react-ui/src/components/ChartsV2/D3ScatterChart/D3ScatterChart.tsx @@ -1,44 +1,16 @@ import clsx from "clsx"; -import type { ScaleLinear } from "d3-scale"; -import React from "react"; import { useScatterChartOrchestrator } from "../hooks"; -import { DefaultLegend } from "../shared/DefaultLegend/DefaultLegend"; -import { Grid } from "../shared/Grid"; -import { LabelTooltipProvider } from "../shared/LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "../shared/PortalTooltip/ChartTooltip"; -import { YAxis } from "../shared/YAxis"; +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"; -const MIN_TICK_SPACING = 60; - -interface VerticalGridProps { - xScale: ScaleLinear; - chartWidth: number; - chartHeight: number; - className?: string; -} - -const VerticalGrid: React.FC = ({ - xScale, - chartWidth, - chartHeight, - className, -}) => { - const tickCount = Math.max(2, Math.floor(chartWidth / MIN_TICK_SPACING)); - const ticks = xScale.ticks(tickCount); - - return ( - - {ticks.map((t) => ( - - ))} - - ); -}; - export function D3ScatterChart(props: D3ScatterChartProps) { const { data, @@ -68,6 +40,8 @@ export function D3ScatterChart(props: D3ScatterChartProps) { height, width, fitLegendInHeight, + xAxisLabel: typeof xAxisLabel === "string" ? xAxisLabel : undefined, + yAxisLabel: typeof yAxisLabel === "string" ? yAxisLabel : undefined, onClick, }); @@ -148,7 +122,6 @@ export function D3ScatterChart(props: D3ScatterChartProps) { ; yScale: ScaleLinear; colorMap: Record; @@ -17,7 +17,6 @@ interface ScatterDotsProps { export const ScatterDots: React.FC = ({ datasets, - allDatasets, xScale, yScale, colorMap, @@ -30,9 +29,8 @@ export const ScatterDots: React.FC = ({ return ( - {datasets.map((ds) => { + {datasets.map(({ dataset: ds, originalIndex }) => { const color = colorMap[ds.name] ?? "#000"; - const datasetIdx = allDatasets.indexOf(ds); const isHoveredDataset = hoveredPoint?.datasetName === ds.name; const hasHover = hoveredPoint !== null; @@ -45,7 +43,7 @@ export const ScatterDots: React.FC = ({ const opacity = hasHover && !isHoveredDataset ? 0.3 : 1; const animationClass = shouldAnimate ? "openui-d3-scatter-chart-dot--animated" : ""; - const animationDelay = shouldAnimate ? `${datasetIdx * 80}ms` : undefined; + const animationDelay = shouldAnimate ? `${originalIndex * 80}ms` : undefined; return ( (null); const legendRef = useRef(null); const [isLegendExpanded, setIsLegendExpanded] = useState(false); - const [hiddenSeries, setHiddenSeries] = useState>(new Set()); const [hoveredPoint, setHoveredPoint] = useState(null); const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); @@ -66,17 +75,15 @@ export function useScatterChartOrchestrator({ const shouldFitLegend = fitLegendInHeight ?? height !== undefined; - // --- Colors --- + // --- Colors (via shared useChartPalette — respects ThemeProvider overrides) --- const datasetNames = useMemo(() => data.map((ds) => ds.name), [data]); - const palette = useMemo(() => { - if (customPalette) return customPalette; - return getPalette(chartThemeName).colors; - }, [chartThemeName, customPalette]); - const distributedColors = useMemo( - () => getDistributedColors(palette, datasetNames.length), - [palette, datasetNames.length], - ); + const distributedColors = useChartPalette({ + chartThemeName, + customPalette, + themePaletteName: "defaultChartPalette", + dataLength: datasetNames.length, + }); const colorMap = useMemo(() => { const map: Record = {}; @@ -86,25 +93,14 @@ export function useScatterChartOrchestrator({ return map; }, [datasetNames, distributedColors]); - // --- Hidden series --- - const toggleSeries = useCallback( - (name: string) => { - setHiddenSeries((prev) => { - const next = new Set(prev); - if (next.has(name)) { - next.delete(name); - } else { - if (next.size >= datasetNames.length - 1) return prev; - next.add(name); - } - return next; - }); - }, - [datasetNames.length], - ); + // --- Hidden series (via shared useSeriesVisibility) --- + const { hiddenSeries, toggleSeries } = useSeriesVisibility(datasetNames); - const visibleDatasets = useMemo( - () => data.filter((ds) => !hiddenSeries.has(ds.name)), + const visibleDatasets: VisibleDataset[] = useMemo( + () => + data + .map((dataset, originalIndex) => ({ dataset, originalIndex })) + .filter(({ dataset }) => !hiddenSeries.has(dataset.name)), [data, hiddenSeries], ); @@ -131,17 +127,12 @@ export function useScatterChartOrchestrator({ return { xMin, xMax, yMin, yMax }; }, [data]); - // --- Y-axis width --- + // --- 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(); - let maxWidth = 0; - for (const tick of ticks) { - const w = context.measureText(numberTickFormatter(tick)).width; - if (w > maxWidth) maxWidth = w; - } - return Math.max(20, Math.min(200, Math.ceil(maxWidth) + 10)); + return measureYAxisWidth(ticks, context); }, [showYAxis, yMin, yMax, context]); // --- Dimensions --- @@ -166,15 +157,13 @@ export function useScatterChartOrchestrator({ ); // --- Hover (2D nearest-point) --- - const handleMouseMove = useCallback( - (event: React.MouseEvent) => { - const [mx, my] = pointer(event.nativeEvent, event.currentTarget); + 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 (let dsIdx = 0; dsIdx < visibleDatasets.length; dsIdx++) { - const ds = visibleDatasets[dsIdx]!; - const originalIdx = data.indexOf(ds); + 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); @@ -183,7 +172,7 @@ export function useScatterChartOrchestrator({ if (dist < minDist) { minDist = dist; closest = { - datasetIndex: originalIdx, + datasetIndex: originalIndex, pointIndex: ptIdx, point: pt, datasetName: ds.name, @@ -198,15 +187,16 @@ export function useScatterChartOrchestrator({ setHoveredPoint(null); } - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - setMousePos({ - x: event.clientX, - y: event.clientY, - }); - } + setMousePos({ x: clientX, y: clientY }); + }, + [visibleDatasets, xScale, yScale], + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + findNearestPoint(event.clientX, event.clientY, event.currentTarget); }, - [visibleDatasets, data, xScale, yScale], + [findNearestPoint], ); const handleMouseLeave = useCallback(() => { @@ -218,15 +208,9 @@ export function useScatterChartOrchestrator({ (event: React.TouchEvent) => { const touch = event.touches[0]; if (!touch) return; - const syntheticEvent = { - nativeEvent: touch, - currentTarget: event.currentTarget, - clientX: touch.clientX, - clientY: touch.clientY, - } as unknown as React.MouseEvent; - handleMouseMove(syntheticEvent); + findNearestPoint(touch.clientX, touch.clientY, event.currentTarget); }, - [handleMouseMove], + [findNearestPoint], ); const handleTouchEnd = useCallback(() => { @@ -248,18 +232,18 @@ export function useScatterChartOrchestrator({ [onClick, hoveredPoint], ); - // --- Tooltip --- + // --- 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: "X", value: hoveredPoint.point.x, color }, - { name: "Y", value: hoveredPoint.point.y, color }, + { name: xAxisLabel ?? "X", value: hoveredPoint.point.x, color }, + { name: yAxisLabel ?? "Y", value: hoveredPoint.point.y, color }, ], }; - }, [hoveredPoint, colorMap]); + }, [hoveredPoint, colorMap, xAxisLabel, yAxisLabel]); // --- Legend --- const legendItems: LegendItem[] = useMemo( diff --git a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts index d94f94fb8..a415492ab 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/cartesian/useXAxisHeight.ts @@ -1,78 +1,102 @@ -import { useEffect, useState } from "react"; -import { useTheme } from "../../../ThemeProvider"; +import { useMemo } from "react"; import { XAxisTickVariant } from "../../types"; +import { useCanvasContextForLabelSize } from "../core/useCanvasContextForLabelSize"; -const DEFAULT_HEIGHT = 30; +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; -export const useXAxisHeight = ( - data: Record[], - categoryKey: string, - tickVariant: XAxisTickVariant, - widthOfGroup = 70, -) => { - const { theme: userTheme } = useTheme(); +/** + * 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 [maxLabelHeight, setMaxLabelHeight] = useState(DEFAULT_HEIGHT); + const lhMatch = fontShorthand.match(/\/(\d+(?:\.\d+)?)(px)?/); + if (!lhMatch) return Math.ceil(fontSize * 1.2); // browser default - useEffect(() => { - if (typeof window === "undefined" || !data || data.length === 0) { - setMaxLabelHeight(DEFAULT_HEIGHT); - return; - } + const lhValue = parseFloat(lhMatch[1]!); + const hasPxUnit = !!lhMatch[2]; + return hasPxUnit ? lhValue : Math.ceil(fontSize * lhValue); +} - const largestLabel = data.reduce((max, item) => { - const label = String(item[categoryKey]); - if (max.length < label.length) { - return label; - } - return max; - }, ""); +/** + * 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 [div1, div2, div3] = [ - document.createElement("div"), - document.createElement("div"), - document.createElement("div"), - ]; + const words = text.split(/\s+/); + let lines = 1; + let currentLineWidth = 0; + const spaceWidth = context.measureText(" ").width; - div1.style.font = userTheme.textLabelXs ?? ""; - div1.style.letterSpacing = userTheme.textLabelXsLetterSpacing ?? ""; - div1.style.opacity = "0"; - div1.style.pointerEvents = "none"; + for (const word of words) { + const wordWidth = context.measureText(word).width; - div2.innerText = largestLabel; - div3.innerText = "a"; - div1.append(div2, div3); + 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; + } + } - div1.style.width = `${widthOfGroup}px`; - div1.style.maxWidth = `${widthOfGroup}px`; - div1.style.wordBreak = "break-word"; - div1.style.position = "absolute"; - div1.style.visibility = "hidden"; + return lines; +} - document.body.append(div1); +export const useXAxisHeight = ( + data: Record[], + categoryKey: string, + tickVariant: XAxisTickVariant, + widthOfGroup = 70, +) => { + const context = useCanvasContextForLabelSize(); - const largestLabelHeight = Math.min( - div2.getBoundingClientRect().height, - div3.getBoundingClientRect().height * 3, - ); + 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); - setMaxLabelHeight(largestLabelHeight); + if (tickVariant !== "multiLine") return singleLineHeight; + if (!data || data.length === 0) return singleLineHeight; + if (widthOfGroup <= 0) return singleLineHeight; - return () => { - div1.remove(); - }; - }, [ - data, - categoryKey, - tickVariant, - widthOfGroup, - userTheme.textLabelXs, - userTheme.textLabelXsLetterSpacing, - ]); + 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)); + } - if (tickVariant === "multiLine") { - return Math.max(maxLabelHeight + X_AXIS_LABEL_PADDING, DEFAULT_HEIGHT); - } - return DEFAULT_HEIGHT; + 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/core/index.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/index.ts index 27efad2d7..65a3c665d 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/core/index.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/index.ts @@ -5,5 +5,6 @@ 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 index b1523a001..47ad57a76 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/core/useCanvasContextForLabelSize.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useCanvasContextForLabelSize.ts @@ -5,10 +5,18 @@ 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")!; - - const font = userTheme.textLabelXs ?? "400 10px/12px Inter"; context.font = font; return context; }, [userTheme.textLabelXs]); diff --git a/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts index 98d44037a..bea533aae 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useChartData.ts @@ -1,7 +1,8 @@ -import React, { useCallback, useMemo, useState } from "react"; +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"; @@ -25,7 +26,7 @@ export function useChartData({ const catKey = String(categoryKey); const allDataKeys = useMemo(() => getDataKeys(data, catKey), [data, catKey]); - const [hiddenSeries, setHiddenSeries] = useState>(new Set()); + const { hiddenSeries, toggleSeries } = useSeriesVisibility(allDataKeys); const dataKeys = useMemo( () => allDataKeys.filter((k) => !hiddenSeries.has(k)), [allDataKeys, hiddenSeries], @@ -74,23 +75,6 @@ export function useChartData({ [allDataKeys, colors, icons], ); - const toggleSeries = useCallback( - (key: string) => { - setHiddenSeries((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - if (next.size < allDataKeys.length - 1) { - next.add(key); - } - } - return next; - }); - }, - [allDataKeys.length], - ); - return { catKey, allDataKeys, 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 index 3b37837f0..ca7ba8037 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/core/useTooltipPayload.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/core/useTooltipPayload.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import type { TooltipItem } from "../../shared/PortalTooltip/ChartTooltip"; +import type { TooltipItem } from "../../shared/core/PortalTooltip/ChartTooltip"; import type { ChartData } from "../../types"; export interface TooltipPayload { diff --git a/packages/react-ui/src/components/ChartsV2/hooks/polar/useCategoricalChartOrchestrator.ts b/packages/react-ui/src/components/ChartsV2/hooks/polar/useCategoricalChartOrchestrator.ts index ec0621187..50049b1bf 100644 --- a/packages/react-ui/src/components/ChartsV2/hooks/polar/useCategoricalChartOrchestrator.ts +++ b/packages/react-ui/src/components/ChartsV2/hooks/polar/useCategoricalChartOrchestrator.ts @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useRef, useState } from "react"; -import type { TooltipItem } from "../../shared/PortalTooltip/ChartTooltip"; +import type { TooltipItem } from "../../shared/core/PortalTooltip/ChartTooltip"; import type { ChartData } from "../../types"; import { buildContainerStyle } from "../../utils/buildContainerStyle"; import type { PaletteName } from "../../utils/paletteUtils"; diff --git a/packages/react-ui/src/components/ChartsV2/shared/ClipDefs.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ClipDefs.tsx similarity index 100% rename from packages/react-ui/src/components/ChartsV2/shared/ClipDefs.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/ClipDefs.tsx diff --git a/packages/react-ui/src/components/ChartsV2/shared/Grid.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/Grid.tsx similarity index 100% rename from packages/react-ui/src/components/ChartsV2/shared/Grid.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/Grid.tsx diff --git a/packages/react-ui/src/components/ChartsV2/shared/LineDotCrosshair.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/LineDotCrosshair.tsx similarity index 100% rename from packages/react-ui/src/components/ChartsV2/shared/LineDotCrosshair.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/LineDotCrosshair.tsx diff --git a/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx similarity index 96% rename from packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx index 66d189cac..33f70baa2 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/ScrollButtonsHorizontal.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { ChevronLeft, ChevronRight } from "lucide-react"; import React from "react"; -import { IconButton } from "../../../IconButton"; +import { IconButton } from "../../../../IconButton"; interface ScrollButtonsHorizontalProps { dataWidth: number; diff --git a/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss similarity index 92% rename from packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss index b75b39b1d..c254fcdf7 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/ScrollButtonsHorizontal/scrollButtonsHorizontal.scss @@ -1,4 +1,4 @@ -@use "../../../../cssUtils" as cssUtils; +@use "../../../../../cssUtils" as cssUtils; .openui-chart-horizontal-scroll { &-buttons-container { 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/AngledXAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/AngledXAxis.tsx similarity index 100% rename from packages/react-ui/src/components/ChartsV2/shared/AngledXAxis.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/AngledXAxis.tsx diff --git a/packages/react-ui/src/components/ChartsV2/shared/XAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxis.tsx similarity index 97% rename from packages/react-ui/src/components/ChartsV2/shared/XAxis.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxis.tsx index 70fdc7fad..16ce676ca 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/XAxis.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxis.tsx @@ -1,6 +1,6 @@ import type { ScaleBand, ScalePoint } from "d3-scale"; import React from "react"; -import type { XAxisTickVariant } from "../types"; +import type { XAxisTickVariant } from "../../../types"; import { XAxisLabel } from "./XAxisLabel"; const X_AXIS_TOP_GAP = 4; diff --git a/packages/react-ui/src/components/ChartsV2/shared/XAxisLabel.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxisLabel.tsx similarity index 79% rename from packages/react-ui/src/components/ChartsV2/shared/XAxisLabel.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxisLabel.tsx index 02bdf77b2..89474cf13 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/XAxisLabel.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/XAxisLabel.tsx @@ -1,7 +1,7 @@ import React, { useRef } from "react"; -import type { XAxisTickVariant } from "../types"; -import { LabelTooltip } from "./LabelTooltip/LabelTooltip"; -import { useIsTruncated } from "./useIsTruncated"; +import type { XAxisTickVariant } from "../../../types"; +import { LabelTooltip } from "../../core/LabelTooltip/LabelTooltip"; +import { useIsTruncated } from "../../core/useIsTruncated"; interface XAxisLabelProps { label: string; diff --git a/packages/react-ui/src/components/ChartsV2/shared/YAxis.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/YAxis.tsx similarity index 92% rename from packages/react-ui/src/components/ChartsV2/shared/YAxis.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/YAxis.tsx index 6f5297213..b76d590cc 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/YAxis.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/axes/YAxis.tsx @@ -1,6 +1,6 @@ import type { ScaleLinear } from "d3-scale"; import React from "react"; -import { numberTickFormatter } from "../utils/styleUtils"; +import { numberTickFormatter } from "../../../utils/styleUtils"; interface YAxisProps { scale: ScaleLinear; diff --git a/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss b/packages/react-ui/src/components/ChartsV2/shared/cartesian/chartBase.scss similarity index 98% rename from packages/react-ui/src/components/ChartsV2/shared/chartBase.scss rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/chartBase.scss index 11e35edff..ca5c7d170 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/chartBase.scss +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/chartBase.scss @@ -1,4 +1,4 @@ -@use "../../../cssUtils" as cssUtils; +@use "../../../../cssUtils" as cssUtils; @mixin chart-base($prefix) { .openui-d3-#{$prefix} { diff --git a/packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/CondensedChartLayout.tsx similarity index 92% rename from packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/CondensedChartLayout.tsx index be22eda9c..406027cbc 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/CondensedChartLayout.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/CondensedChartLayout.tsx @@ -2,12 +2,12 @@ import clsx from "clsx"; import type { ScaleLinear } from "d3-scale"; import React from "react"; -import type { useChartCondensedOrchestrator } from "../hooks"; -import { DefaultLegend } from "./DefaultLegend/DefaultLegend"; -import { Grid } from "./Grid"; -import { LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "./PortalTooltip/ChartTooltip"; -import { YAxis } from "./YAxis"; +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; diff --git a/packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx b/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/ScrollableChartLayout.tsx similarity index 91% rename from packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx rename to packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/ScrollableChartLayout.tsx index bdbc902df..654a10c77 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/ScrollableChartLayout.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/cartesian/layouts/ScrollableChartLayout.tsx @@ -2,13 +2,13 @@ import clsx from "clsx"; import type { ScaleLinear } from "d3-scale"; import React from "react"; -import type { useChartScrollableOrchestrator } from "../hooks"; -import { DefaultLegend } from "./DefaultLegend/DefaultLegend"; -import { Grid } from "./Grid"; -import { LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; -import { ChartTooltip } from "./PortalTooltip/ChartTooltip"; -import { ScrollButtonsHorizontal } from "./ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -import { YAxis } from "./YAxis"; +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; diff --git a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/DefaultLegend.tsx similarity index 97% rename from packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx rename to packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/DefaultLegend.tsx index 8841bc4e0..06c5d9920 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/DefaultLegend.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/DefaultLegend.tsx @@ -1,8 +1,8 @@ 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 { Button } from "../../../../Button/Button"; +import { type LegendItem } from "../../../types"; import { useDefaultLegend } from "./hooks/useDefaultLegend"; interface DefaultLegendProps { diff --git a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/defaultLegend.scss b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/defaultLegend.scss similarity index 97% rename from packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/defaultLegend.scss rename to packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/defaultLegend.scss index 3e3e23f71..8116146e0 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/defaultLegend.scss +++ b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/defaultLegend.scss @@ -1,4 +1,4 @@ -@use "../../../../cssUtils" as cssUtils; +@use "../../../../../cssUtils" as cssUtils; .openui-chart-legend-container { display: flex; diff --git a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/hooks/useDefaultLegend.ts similarity index 95% rename from packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts rename to packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/hooks/useDefaultLegend.ts index d50166496..a3f58e1aa 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/DefaultLegend/hooks/useDefaultLegend.ts +++ b/packages/react-ui/src/components/ChartsV2/shared/core/DefaultLegend/hooks/useDefaultLegend.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { useCanvasContextForLabelSize } from "../../../hooks"; -import { LegendItem } from "../../../types"; +import { useCanvasContextForLabelSize } from "../../../../hooks"; +import { LegendItem } from "../../../../types"; const CHARACTER_WIDTH = 7; const INDICATOR_WIDTH = 10; diff --git a/packages/react-ui/src/components/ChartsV2/shared/LabelTooltip/LabelTooltip.tsx b/packages/react-ui/src/components/ChartsV2/shared/core/LabelTooltip/LabelTooltip.tsx similarity index 100% rename from packages/react-ui/src/components/ChartsV2/shared/LabelTooltip/LabelTooltip.tsx rename to packages/react-ui/src/components/ChartsV2/shared/core/LabelTooltip/LabelTooltip.tsx diff --git a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/ChartTooltip.tsx b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/ChartTooltip.tsx similarity index 98% rename from packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/ChartTooltip.tsx rename to packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/ChartTooltip.tsx index 0a6680541..db5c46be3 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/ChartTooltip.tsx +++ b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/ChartTooltip.tsx @@ -2,7 +2,7 @@ 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 { useTheme } from "../../../../ThemeProvider"; import { tooltipNumberFormatter } from "./utils"; export interface TooltipItem { diff --git a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/portalTooltip.scss similarity index 98% rename from packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss rename to packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/portalTooltip.scss index f956fce66..7d6abf9e3 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/portalTooltip.scss +++ b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/portalTooltip.scss @@ -1,4 +1,4 @@ -@use "../../../../cssUtils" as cssUtils; +@use "../../../../../cssUtils" as cssUtils; .openui-portal-tooltip { pointer-events: none; diff --git a/packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/utils/index.ts b/packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/utils/index.ts similarity index 100% rename from packages/react-ui/src/components/ChartsV2/shared/PortalTooltip/utils/index.ts rename to packages/react-ui/src/components/ChartsV2/shared/core/PortalTooltip/utils/index.ts diff --git a/packages/react-ui/src/components/ChartsV2/shared/useIsTruncated.ts b/packages/react-ui/src/components/ChartsV2/shared/core/useIsTruncated.ts similarity index 100% rename from packages/react-ui/src/components/ChartsV2/shared/useIsTruncated.ts rename to packages/react-ui/src/components/ChartsV2/shared/core/useIsTruncated.ts diff --git a/packages/react-ui/src/components/ChartsV2/shared/index.ts b/packages/react-ui/src/components/ChartsV2/shared/index.ts index b9cefca9f..099b8d040 100644 --- a/packages/react-ui/src/components/ChartsV2/shared/index.ts +++ b/packages/react-ui/src/components/ChartsV2/shared/index.ts @@ -1,13 +1,17 @@ -export { AngledXAxis } from "./AngledXAxis"; -export { ClipDefs } from "./ClipDefs"; -export { DefaultLegend } from "./DefaultLegend/DefaultLegend"; -export { Grid } from "./Grid"; -export { LabelTooltip, LabelTooltipProvider } from "./LabelTooltip/LabelTooltip"; -export { LineDotCrosshair } from "./LineDotCrosshair"; -export { ChartTooltip } from "./PortalTooltip/ChartTooltip"; -export type { TooltipItem } from "./PortalTooltip/ChartTooltip"; -export { ScrollButtonsHorizontal } from "./ScrollButtonsHorizontal/ScrollButtonsHorizontal"; -export { useIsTruncated } from "./useIsTruncated"; -export { XAxis } from "./XAxis"; -export { XAxisLabel } from "./XAxisLabel"; -export { YAxis } from "./YAxis"; +// 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/utils/styleUtils.ts b/packages/react-ui/src/components/ChartsV2/utils/styleUtils.ts index ac2792f27..c76566764 100644 --- a/packages/react-ui/src/components/ChartsV2/utils/styleUtils.ts +++ b/packages/react-ui/src/components/ChartsV2/utils/styleUtils.ts @@ -20,4 +20,26 @@ const numberTickFormatter = (value: number) => { return String(value); }; -export { numberTickFormatter }; +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 }; From bb069c3a8d10149837b8e5b49020b9b08861eebc Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Thu, 19 Mar 2026 16:50:17 +0530 Subject: [PATCH 23/23] chore: update gitignore for AI-generated docs and planning files Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 ++ docs/.gitignore | 3 +++ 2 files changed, 5 insertions(+) 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