← Files DataARCHIVED FILE

templates/data-app/base/src/charting/ChartFrame.jsx

6.24 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import React, { useEffect, useRef, useState } from "react";
import { ResponsiveContainer } from "recharts";

export function collapsedLegendLayout(items) {
  const tops = [...new Set(items.map(item => item.offsetTop))].sort((a, b) => a - b);
  const visible = items.filter(item => item.offsetTop <= (tops[1] ?? tops[0]));
  return { height: Math.max(0, ...visible.map(item => item.offsetTop + item.offsetHeight)), visibleCount: visible.length };
}

export function ChartLegend({ items = [], onToggle, onIsolate, position = "bottom" }) {
  const legendRef = useRef(null);
  const [layout, setLayout] = useState({ height: 38, visibleCount: items.length });
  const [expanded, setExpanded] = useState(false);
  const labels = items.map(item => item.label).join("\n");
  const overflowing = layout.visibleCount < items.length;

  useEffect(() => {
    const legend = legendRef.current;
    if (!legend || position === "right") return undefined;
    const measure = () => {
      const { height, visibleCount } = collapsedLegendLayout([...legend.children]);
      setLayout(previous => previous.height === height && previous.visibleCount === visibleCount
        ? previous : { height, visibleCount });
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(legend);
    return () => observer.disconnect();
  }, [labels, position]);

  if (!items.length) return null;
  return <>
    <ul className={["chart-legend", position === "right" ? "chart-legend--right" : ""].filter(Boolean).join(" ")}
      ref={legendRef} data-expanded={expanded || undefined} data-overflowing={overflowing || undefined}
      aria-label="Chart legend" data-legend-position={position}
      style={position !== "right" ? { "--legend-collapsed-height": `${layout.height}px` } : undefined}>
      {items.map((item, index) => <li key={`${item.label}-${index}`}
        data-collapsed={!expanded && position !== "right" && index >= layout.visibleCount || undefined}
        inert={!expanded && position !== "right" && index >= layout.visibleCount ? true : undefined}>
        <button type="button" className="chart-legend-button"
          aria-label={`Toggle ${item.label}`} aria-pressed={item.visible !== false}
          title="Click to toggle. Double-click or press Shift+Enter to isolate."
          onClick={() => onToggle?.(item.value ?? item.label)}
          onDoubleClick={() => onIsolate?.(item.value ?? item.label)}
          onKeyDown={(event) => {
            if (event.shiftKey && ["Enter", " "].includes(event.key)) {
              event.preventDefault();
              onIsolate?.(item.value ?? item.label);
            }
          }} disabled={!onToggle}>
          <span className={`chart-legend-mark ${item.type ?? "square"}`}
            style={{ "--legend-color": item.color, opacity: item.opacity }} aria-hidden="true" />
          <span>{item.label}</span>
        </button>
      </li>)}
    </ul>
    {position !== "right" && (overflowing || expanded) && <button
      type="button"
      className="chart-legend-toggle"
      aria-expanded={expanded}
      onClick={() => setExpanded((current) => !current)}
    >
      {expanded ? "Show fewer categories" : `Show all ${items.length} categories`}
    </button>}
  </>;
}

export function ChartFrame({ chart, height = 240, xLabel, xLabelPosition = "bottom", legend = [], onLegendToggle,
  onLegendIsolate, onChartClick, plotInset, zoomed = false, onResetZoom, legendPosition, scaleLegend, frameRef, accessibleLabel, annotationNotes
}) {
  const right = legendPosition === "right";
  const footerXLabel = xLabel && !["top", "inline-right"].includes(xLabelPosition);
  const bandLegendGroups = scaleLegend?.bands?.reduce((groups, band) => {
    const label = band.legendGroup ?? band.label;
    if (!label) return groups;
    const existing = groups.find((group) => group.label === label);
    if (existing) existing.colors.push(band.color);
    else groups.push({ label, colors: [band.color] });
    return groups;
  }, []);
  const legendContent = <ChartLegend items={legend} onToggle={onLegendToggle}
    onIsolate={onLegendIsolate} position={right ? "right" : "bottom"} />;

  return (
    <div className={["chart-layout", right ? "chart-layout--legend-right" : ""].filter(Boolean).join(" ")}
      style={plotInset ? {
        "--chart-plot-left": `${plotInset.left ?? 0}px`,
        "--chart-plot-right": `${plotInset.right ?? 0}px`,
      } : undefined}>
      {zoomed && onResetZoom && <button type="button" className="chart-reset-zoom"
        onClick={onResetZoom}>Reset zoom</button>}
      {xLabel && xLabelPosition === "top" && <p className="chart-axis-label chart-axis-label--top">{xLabel}</p>}
      <div ref={frameRef} className="chart-frame" style={{ minHeight: height }} role="group" aria-label={accessibleLabel || xLabel || "Reviewed data chart"} onClick={onChartClick}>
        <ResponsiveContainer width="100%" height="100%">{chart}</ResponsiveContainer>
        {xLabel && xLabelPosition === "inline-right"
          && <p className="chart-axis-label chart-axis-label--inline-right">{xLabel}</p>}
      </div>
      {right && legendContent}
      {Boolean(footerXLabel || scaleLegend || (!right && legend.length)) && <div className="chart-footer">
        {footerXLabel && <p className="chart-axis-label">{xLabel}</p>}
        {scaleLegend && (bandLegendGroups?.length
          ? <div className="chart-scale-band-legend" aria-label={`${scaleLegend.label} color bands`}>
              {bandLegendGroups.map(({ label, colors }) => <span key={label}
                data-single-band={colors.length === 1 || undefined}
                style={colors.length === 1 ? { "--chart-band-color": colors[0] } : undefined}>
                <span className="chart-scale-band-swatches" aria-hidden="true">
                  {colors.map((color, index) => <i key={`${color}-${index}`} style={{ background: color }} />)}
                </span>
                {label}
              </span>)}
            </div>
          : <div className="chart-scale-legend" aria-label={`${scaleLegend.label} color scale`}>
              <span>{scaleLegend.minimum}</span>
              <i aria-hidden="true" style={{ "--chart-scale-color": scaleLegend.color }} />
              <span>{scaleLegend.maximum}</span>
            </div>)}
        {!right && legendContent}
      </div>}
      {annotationNotes}
    </div>
  );
}

SHA-256: bb0925854283f32cc398144a79e69113e4874018375ddf4e241d3f5fd06ddcf0