Domphy

chartPieInteractive

A Charts block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call chartPieInteractive() with no arguments for a working demo, or edit the code below live.

Implementation notes

Selection is a single State<string> source of truth driving three things together: the active wedge's radius (reactive style.d using CSS path() interpolation for a ~260ms ease-out grow/shrink transition), the donut's center total text, and the native <select>'s own value — matching the spec's architecture note. Two genuine browser-API gaps versus the visual spec: (1) native HTML <option> elements cannot host a child swatch element, so 'each option carries its own color swatch' is approximated by tinting the option's own text color instead of a true swatch icon; (2) a native <select>'s dropdown popup is rendered by the OS/browser chrome and cannot be given a custom fade+scale open/close transition in cross-browser CSS/JS without replacing it with a fully custom (non-native) listbox widget, which would go against reusing the existing select() primitive. Everything else (enlarge/shrink transition, center value swap, swatch next to the trigger, onSelectionChange callback) is fully implemented.

Status: partial · Reference: shadcn/ui original

// shadcn/ui "charts/pie-interactive" — clean-room reimplementation.
//
// A donut chart paired with a select control that lets the user pick one
// category; the chosen category's wedge is drawn enlarged and its value
// becomes the donut's center total. Selection is a single source-of-truth
// `State<string>` that both the active-wedge radius and the center text
// read from. Implemented purely from the block's public functional/visual
// spec — no upstream source was viewed.

import type { DomphyElement, Listener } from "@domphy/core";
import { toState } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";
import { motion, select } from "@domphy/ui";
import {
  type PieDatum,
  DEFAULT_DONUT_INNER_RADIUS,
  DEFAULT_PIE_DATA,
  PIE_OUTER_RADIUS,
  arcSlicePath,
  colorSwatch,
  createPieTooltipState,
  defaultValueFormatter,
  layoutPieSlices,
  pieCard,
  pieCardDescription,
  pieCardTitle,
  pieCenterText,
  pieChartContainer,
  resolveSliceColor,
  wedgeTooltipHandlers,
} from "./pie-chart-shared.js";

export interface ChartPieInteractiveProps {
  data?: PieDatum[];
  title?: string;
  description?: string;
  valueFormatter?: (value: number) => string;
  innerRadius?: number;
  /** Controlled selected category key. Defaults to the first record. */
  activeKey?: string;
  onSelectionChange?: (key: string) => void;
  centerCaption?: string;
  /** Extra outer-radius (viewBox units) the selected wedge grows to. */
  activeRadiusDelta?: number;
}

/**
 * A select-driven donut chart: the chosen category's wedge grows and its
 * value fills the donut's hollow center. Call with no arguments for a fully
 * working demo.
 */
function chartPieInteractive(props: ChartPieInteractiveProps = {}): DomphyElement<"div"> {
  const {
    data = DEFAULT_PIE_DATA,
    title = "Pie Chart - Interactive",
    description = "January - June 2024",
    valueFormatter = defaultValueFormatter,
    innerRadius = DEFAULT_DONUT_INNER_RADIUS,
    activeKey,
    onSelectionChange,
    centerCaption = "Visitors",
    activeRadiusDelta = 12,
  } = props;

  const selectedKey = toState(activeKey ?? data[0]?.key ?? "");
  const slices = layoutPieSlices(data);
  const tooltipState = createPieTooltipState();
  const containerRef = { current: null as HTMLElement | null };

  const setSelection = (key: string) => {
    selectedKey.set(key);
    onSelectionChange?.(key);
  };

  // Reactive per-wedge `style.d` (not the plain `d` attribute): CSS lets
  // browsers interpolate between two `path()` values sharing the same
  // command structure — which arcSlicePath's output always does — so the
  // active wedge's enlarge/shrink reads as a quick ease-out transition
  // instead of a hard snap.
  const wedges: DomphyElement<"path">[] = slices.map((slice) => {
    const isSelected = (l: Listener) => selectedKey.get(l) === slice.datum.key;
    return {
      path: null,
      style: {
        d: (l: Listener) => {
          const outerRadius = isSelected(l) ? PIE_OUTER_RADIUS + activeRadiusDelta : PIE_OUTER_RADIUS;
          return `path("${arcSlicePath(slice, innerRadius, outerRadius, 0.018)}")`;
        },
        transition: "d 260ms ease-out",
      },
      fill: (l: Listener) => themeColor(l, "shift-9", slice.color),
      strokeWidth: (l: Listener) => (isSelected(l) ? "2.5" : "1.5"),
      stroke: (l: Listener) => themeColor(l, "inherit"),
      strokeLinejoin: "round",
      cursor: "pointer",
      _key: slice.datum.key,
      ...wedgeTooltipHandlers(slice, { containerRef, tooltipState, valueFormatter }),
    } as DomphyElement<"path">;
  });

  const options: DomphyElement<"option">[] = data.map((datum, index) => ({
    option: datum.name,
    value: datum.key,
    // Native `<option>` elements cannot host a child swatch element — tinting
    // the option's own text color is the closest in-grammar approximation of
    // "each option carries its own color swatch" (see this block's fidelity
    // note in the port report).
    style: { color: (l: Listener) => themeColor(l, "shift-9", resolveSliceColor(datum, index)) },
    _key: datum.key,
  }));

  const centerValueText = (l: Listener) => {
    const key = selectedKey.get(l);
    const datum = data.find((record) => record.key === key) ?? data[0];
    return datum ? valueFormatter(datum.value) : "";
  };

  const selectedSwatchColor = (l: Listener) => {
    const key = selectedKey.get(l);
    const index = Math.max(
      data.findIndex((record) => record.key === key),
      0,
    );
    return resolveSliceColor(data[index] ?? { key: "", name: "", value: 0 }, index);
  };

  return pieCard([
    pieCardTitle(title, false),
    pieCardDescription(description, false),
    {
      aside: [
        colorSwatch(selectedSwatchColor),
        {
          select: options,
          value: (l: Listener) => selectedKey.get(l),
          onChange: (e: Event) => setSelection((e.target as HTMLSelectElement).value),
          ariaLabel: "Select a category",
          $: [select()],
        },
      ],
      style: { display: "flex", alignItems: "center", gap: themeSpacing(2) },
    },
    pieChartContainer(
      containerRef,
      [
        {
          g: wedges,
          ariaHidden: "true",
          style: { transformOrigin: "100px 100px" },
          $: [
            motion({
              initial: { opacity: 0, scale: 0.7 },
              animate: { opacity: 1, scale: 1 },
              transition: { duration: 700, easing: "ease-out" },
            }),
          ],
        } as DomphyElement<"g">,
        pieCenterText(centerValueText, centerCaption),
      ],
      tooltipState,
    ),
  ]);
}

export { chartPieInteractive };

← Back to shadcn/ui catalog