import { useId, useState, type MouseEvent, type ReactNode } from "react" import { cn } from "cn" import { Pie, PieChart, usePlotArea, type PieSectorShapeProps } from "recharts" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart" import { CenterText, type HMDonutDatum } from "./donut-chart" import { seriesSideColor, seriesToConfig } from "./series" /* * Solid (thick) Pie and Donut, drawn as SVG - not a rotatable 3D scene. * * The disc is seen from above at a fixed tilt: the top face is an ellipse (rx, ry = rx * TILT) and the walls are that ellipse * shifted down by the thickness. Only Recharts' sector angles are used (angles, order and tooltip stay the real data); the * geometry is projected here, so text and tooltips are never skewed. * * Draw order inside one slice, which is also the whole occlusion rule (slices are flush, so no slice hides another): * 1. front part of the outer wall (angles 180-360, the half that faces the viewer; the back wall is hidden by the top face) * 2. back part of the inner wall (donut only, angles 0-180, the half seen through the hole; clipped to the hole opening so * it never paints over the front part of the ring) * 3. top face * Walls and the top face of different slices never overlap, so the layer order of the slices does not matter. Every part sits in * the slice's own Recharts layer, so hovering the top or a wall gives the same tooltip. */ const RAD = Math.PI / 180 const TILT = 0.6 // ry / rx of the top ellipse const THICKNESS = 0.2 // wall height / rx const MIN_THICKNESS = 8 const HOLE = 0.55 // donut hole radius / outer radius const SIDE_PAD = 4 const MIN_SPAN = 0.01 // degrees; narrower slices are not drawn const FULL = 359.99 type Projection = { cx: number; cy: number; rx: number; ry: number; h: number } /** Largest tilted disc (with its wall) that fits the plot area, centered in it. Scales with the chart width on small screens. */ function project(plot: { x: number; y: number; width: number; height: number }): Projection { const fit = Math.min((plot.width - SIDE_PAD * 2) / 2, (plot.height - SIDE_PAD * 2) / (2 * TILT + THICKNESS), plot.height * 0.55) const rx = Math.max(fit, 8) const h = Math.max(rx * THICKNESS, MIN_THICKNESS) const ry = rx * TILT const top = plot.y + (plot.height - (2 * ry + h)) / 2 return { cx: plot.x + plot.width / 2, cy: top + ry, rx, ry, h } } const num = (value: number) => Math.round(value * 100) / 100 /** Point on the (optionally scaled) top ellipse at a Recharts angle (degrees, counter-clockwise from 3 o'clock). */ const at = (p: Projection, scale: number, angle: number, dy = 0) => `${num(p.cx + p.rx * scale * Math.cos(angle * RAD))},${num(p.cy - p.ry * scale * Math.sin(angle * RAD) + dy)}` /** Elliptical arc between two angles. `forward` follows increasing angles (counter-clockwise on screen). */ function arc(p: Projection, scale: number, from: number, to: number, forward: boolean, dy = 0) { const span = Math.abs(to - from) return `A${num(p.rx * scale)},${num(p.ry * scale)} 0 ${span > 180 ? 1 : 0} ${forward ? 0 : 1} ${at(p, scale, to, dy)}` } /** Closed outline of a full ellipse (two half arcs, since one arc cannot start and end at the same point). */ const ellipse = (p: Projection, scale: number, dy = 0) => `M${at(p, scale, 0, dy)}A${num(p.rx * scale)},${num(p.ry * scale)} 0 1 0 ${at(p, scale, 180, dy)}A${num(p.rx * scale)},${num(p.ry * scale)} 0 1 0 ${at(p, scale, 360, dy)}Z` /** Top face of one slice: a wedge (pie) or a ring segment (donut). */ function topPath(p: Projection, hole: number, from: number, to: number) { if (to - from >= FULL) return hole > 0 ? `${ellipse(p, 1)}${ellipse(p, hole)}` : ellipse(p, 1) if (hole <= 0) return `M${p.cx},${p.cy}L${at(p, 1, from)}${arc(p, 1, from, to, true)}Z` return `M${at(p, 1, from)}${arc(p, 1, from, to, true)}L${at(p, hole, to)}${arc(p, hole, to, from, false)}Z` } /** Wall band along an ellipse between two angles: the top edge, then the same edge `h` lower, walked back. */ function wallPath(p: Projection, scale: number, from: number, to: number) { return `M${at(p, scale, from)}${arc(p, scale, from, to, true)}L${at(p, scale, to, p.h)}${arc(p, scale, to, from, false, p.h)}Z` } type SolidSliceProps = { sector: PieSectorShapeProps hole: number sideColor: string topColor: string center?: ReactNode | ((p: Projection) => ReactNode) } function SolidSlice({ sector, hole, sideColor, topColor, center }: SolidSliceProps) { const plot = usePlotArea() const clipId = `solid-hole-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}` if (!plot || plot.width <= 0 || plot.height <= 0) return null const p = project(plot) const from = Math.max(Math.min(sector.startAngle, sector.endAngle), 0) const to = Math.min(Math.max(sector.startAngle, sector.endAngle), 360) const drawn = to - from > MIN_SPAN const frontFrom = Math.max(from, 180) const frontTo = Math.min(to, 360) const backFrom = Math.max(from, 0) const backTo = Math.min(to, 180) const seam = "var(--hm-sem-bg-surface)" // The seam between slices is a surface-colored stroke; on a very thin slice it thins out so the slice stays visible. const arcPx = (to - from) * RAD * p.rx const seamTop = arcPx < 4 ? 0.5 : arcPx < 10 ? 1 : 2 const seamWall = Math.min(1.5, seamTop) return ( {drawn && frontTo - frontFrom > MIN_SPAN ? ( ) : null} {drawn && hole > 0 && backTo - backFrom > MIN_SPAN ? ( <> ) : null} {drawn ? ( ) : null} {typeof center === "function" ? center(p) : center} ) } /** * Tooltip position that follows the pointer. Recharts places a pie tooltip from the circle it would draw (not from the tilted * ellipse), which would put it away from the hovered face; the pointer is the same point for the top face and the walls. * Only the position changes: the tooltip content and style are the regular ones. */ function useFollowTooltip() { const [position, setPosition] = useState<{ x: number; y: number } | undefined>() const onMouseMove = (_data: unknown, _index: number, event: MouseEvent) => { const svg = event.currentTarget.ownerSVGElement const wrapper = svg?.closest(".recharts-wrapper") if (!svg || !wrapper) return const box = svg.getBoundingClientRect() const tip = wrapper.querySelector(".recharts-tooltip-wrapper") const width = tip?.offsetWidth ?? 0 const height = tip?.offsetHeight ?? 0 const px = event.clientX - box.left const py = event.clientY - box.top let x = px + 14 if (x + width > box.width) x = px - 14 - width let y = py + 14 if (y + height > box.height) y = py - 14 - height setPosition({ x: Math.max(0, x), y: Math.max(0, y) }) } return { position, onMouseMove } } const sliceColors = (data: readonly HMDonutDatum[]) => data.map((item, index) => ({ id: item.id, top: `var(--color-${item.id})`, side: seriesSideColor(index, item.color) })) /** Neutral stand-in drawn when the total is 0, so an empty chart still shows a disc / ring. */ const EMPTY_TOP = "var(--hm-sem-bg-subtle)" const EMPTY_SIDE = "var(--hm-sem-stroke-default)" export type HMSolidPieChartProps = { data: readonly HMDonutDatum[] /** Accessible name of the chart. */ label: string className?: string } /** * Solid pie chart: a thick tilted disc with HM series colors on the top face and a darker tone of the same family on the sides. * Same data, order, angles, tooltip and legend as `HMPieChart`. Drawn as SVG (no rotation). */ export function HMSolidPieChart({ data, label, className }: HMSolidPieChartProps) { const follow = useFollowTooltip() const total = data.reduce((sum, item) => sum + Math.max(item.value, 0), 0) const slices = data.map(item => ({ ...item, fill: `var(--color-${item.id})` })) const config = seriesToConfig(data.map(item => ({ key: item.id, label: item.label, color: item.color }))) const colors = sliceColors(data) return ( } position={follow.position} /> } /> {total <= 0 ? ( } /> ) : null} ( )} /> ) } export type HMSolidDonutChartProps = { data: readonly HMDonutDatum[] /** Caption under the total in the hole. Omit it to hide the center total. */ totalLabel?: string /** Accessible name of the chart. */ label: string className?: string } /** * Solid donut chart: a thick tilted ring with an outer wall and an inner wall (the back of the hole). The center total sits in * the tilted hole. Same data, order, angles, tooltip and legend as `HMDonutChart`. A total of 0 draws an empty ring. */ export function HMSolidDonutChart({ data, totalLabel, label, className }: HMSolidDonutChartProps) { const follow = useFollowTooltip() const total = data.reduce((sum, item) => sum + Math.max(item.value, 0), 0) const slices = data.map(item => ({ ...item, fill: `var(--color-${item.id})` })) const config = seriesToConfig(data.map(item => ({ key: item.id, label: item.label, color: item.color }))) const colors = sliceColors(data) // The center text is drawn once, with the first slice that is actually drawn (a 0 slice has no layer). const centerIndex = data.findIndex(item => item.value > 0) const centerOf = (p: Projection) => { if (!totalLabel) return null const holeRx = p.rx * HOLE // The wall hides the top `h` of the hole opening, so the text sits lower; its size follows the free height of the opening. const free = 2 * p.ry * HOLE - p.h const maxSize = Math.max(14, Math.min(24, Math.floor((free * 0.9 - 26) / 0.75))) return ( ) } return ( } position={follow.position} /> } /> {total <= 0 ? ( ( )} /> ) : null} ( 0 && index === centerIndex ? centerOf : undefined} /> )} /> ) }