← Files DataARCHIVED FILE

templates/data-app/base/src/components/ChartExplorer.jsx

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

↓ Download file

import React, { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";

import { ChartRenderer } from "../charting/ChartRenderer.jsx";
import { annotationChartTypes } from "../charting/chart-annotations.js";
import { resolvedChartType } from "../charting/chart-data-shape.js";
import {
  changeChartEditorSpec,
  chartEditorChoices,
  chartEditorPreviewState,
  chartEditorSpecsEqual,
} from "../charting/chart-editor-state.js";
import { funnelStageColor, label as humanize, semanticColor } from "../charting/chart-theme.js";
import { chartColorIdentity, chartColorOptions, hexToHsv, hsvToHex, resolvedColorHex, resolvedColorName } from "./chart-color-utils.js";
import { NativeSelect } from "./contained-ui.jsx";
import { Icon } from "./Icon.jsx";
import { SegmentedControl } from "./SegmentedControl.jsx";
import { Switch } from "./Switch.jsx";

export { availableChartTypes } from "../charting/chart-editor-state.js";

const chartsWithoutAxes = new Set(["pie", "funnel", "sparkline", "rankedList", "sankey"]);
const chartsWithoutOptionalLabels = new Set(["waterfall", "funnel", "sparkline", "heatmap", "boxPlot", "rankedList"]);
const sortableCharts = new Set([
  "pie",
  "bar",
  "horizontalBar",
  "stackedBar",
  "stackedBar100",
  "horizontalStackedBar",
  "horizontalStackedBar100",
  "rankedList",
]);
export const chartTypeGroups = [
  { label: "Trends", choices: ["line", "area", "stackedArea", "sparkline"] },
  { label: "Comparisons", choices: ["bar", "horizontalBar", "rankedList", "waterfall"] },
  {
    label: "Composition",
    choices: ["stackedBar", "stackedBar100", "horizontalStackedBar", "horizontalStackedBar100", "pie"],
  },
  { label: "Distribution", choices: ["histogram", "scatter", "heatmap", "boxPlot"] },
  { label: "Flow", choices: ["funnel", "sankey"] },
];
const semanticColorCharts = new Set(["waterfall", "rankedList", "sankey", "heatmap"]);

function optionLabel(value) {
  if (value === "rankedList" || value === "leaderboard") return "Leaderboard";
  return humanize(String(value))
    .split(" ")
    .map((word, index) => (index && !/^[A-Z\d]+$/u.test(word) ? word.toLowerCase() : word))
    .join(" ")
    .replace(/([a-z])100$/u, "$1 (100%)");
}

function ChartMetadataField({ field, label, value, maxLength, rows = 1, disabled, onChange }) {
  const textareaRef = useRef(null);
  useLayoutEffect(() => {
    const textarea = textareaRef.current;
    if (!textarea) return undefined;
    const resize = () => {
      textarea.style.height = "auto";
      const borderHeight = textarea.offsetHeight - textarea.clientHeight;
      textarea.style.height = `${textarea.scrollHeight + borderHeight}px`;
    };
    let width = textarea.getBoundingClientRect().width;
    resize();
    const observer = new ResizeObserver(() => {
      const nextWidth = textarea.getBoundingClientRect().width;
      if (nextWidth === width) return;
      width = nextWidth;
      resize();
    });
    observer.observe(textarea);
    return () => observer.disconnect();
  }, [value, rows]);

  return (
    <label className="chart-editor-metadata-field">
      <span>{label}</span>
      <textarea
        ref={textareaRef}
        className="chart-editor-metadata-input"
        aria-label={`Chart ${field}`}
        value={value}
        maxLength={maxLength}
        rows={rows}
        disabled={disabled}
        onChange={(event) => onChange?.(field, event.target.value)}
      />
    </label>
  );
}

function ChartSwitch({ label, checked, onChange, disabled = false }) {
  return <Switch label={label} checked={checked} onChange={onChange} disabled={disabled} fullWidth />;
}

function PreviewSkeleton() {
  return (
    <div className="explorer-preview-skeleton" aria-hidden="true">
      <i />
      <i />
      <i />
      <i />
    </div>
  );
}

function MountedChart({ onReady, ...props }) {
  useEffect(() => {
    let second;
    const first = requestAnimationFrame(() => {
      second = requestAnimationFrame(onReady);
    });
    return () => {
      cancelAnimationFrame(first);
      cancelAnimationFrame(second);
    };
  }, []);
  return <ChartRenderer {...props} />;
}

function ChartColorControl({
  colorKey,
  color,
  swatchColor = color,
  automatic = false,
  onChange,
  portalContainer,
  themeRoot,
  contained = false,
  disabled = false,
}) {
  const name = optionLabel(colorKey);
  const triggerRef = useRef(null);
  const popoverRef = useRef(null);
  const [popoverPosition, setPopoverPosition] = useState(null);
  const [customOpen, setCustomOpen] = useState(false);
  const [themeColorLabels, setThemeColorLabels] = useState({});
  const emittedColor = useRef(null);
  const [customHsv, setCustomHsv] = useState(() => hexToHsv(/^#[\da-f]{6}$/iu.test(color ?? "") ? color : "#0285ff"));
  const customHue = customHsv.hue;
  const [customHex, setCustomHex] = useState(/^#[\da-f]{6}$/iu.test(color ?? "") ? color : "#0285ff");
  const identity = chartColorIdentity(color, { automatic });
  const colorLabel = identity.label;
  const customSelected = identity.customHex !== null;
  useEffect(() => {
    const target = triggerRef.current;
    if (!target) return undefined;
    function updateThemeColorLabels() {
      const labels = Object.fromEntries(chartColorOptions.map(({ token }) => [token, resolvedColorName(token, target)]));
      setThemeColorLabels((current) => Object.keys(labels).every((token) => labels[token] === current[token])
        ? current : labels);
    }
    updateThemeColorLabels();
    const observer = new MutationObserver(updateThemeColorLabels);
    const options = { attributes: true, attributeFilter: ["style", "data-app-theme", "data-color-scheme"] };
    observer.observe(target.ownerDocument.documentElement, options);
    if (themeRoot) observer.observe(themeRoot, options);
    return () => observer.disconnect();
  }, [themeRoot]);
  useEffect(() => {
    if (customSelected && color !== emittedColor.current) {
      setCustomHex(color);
      setCustomHsv(hexToHsv(color));
    }
  }, [color, customSelected]);

  function changeHsv(next) {
    const hex = hsvToHex(next);
    emittedColor.current = hex;
    setCustomHsv(next);
    setCustomHex(hex);
    onChange(hex);
  }

  function toggleCustom() {
    if (!customOpen) {
      const hex = resolvedColorHex(color, triggerRef.current);
      setCustomHex(hex);
      setCustomHsv(hexToHsv(hex));
    }
    setCustomOpen((open) => !open);
  }

  useEffect(() => {
    if (!popoverPosition) return undefined;
    const trigger = triggerRef.current;
    const ownerDocument = trigger?.ownerDocument ?? document;
    const ownerWindow = ownerDocument.defaultView ?? window;
    const keyTarget = contained ? trigger?.closest(".chart-editor-dialog") ?? trigger?.getRootNode() : ownerWindow;
    const closeOutside = (event) => {
      const path = event.composedPath?.() ?? [];
      if (
        !path.includes(popoverRef.current) &&
        !path.includes(triggerRef.current) &&
        !popoverRef.current?.contains(event.target) &&
        !triggerRef.current?.contains(event.target)
      ) {
        setPopoverPosition(null);
        setCustomOpen(false);
      }
    };
    const closeOnEscape = (event) => {
      if (event.key === "Escape" && !event.defaultPrevented) {
        event.preventDefault();
        event.stopPropagation();
        setPopoverPosition(null);
        setCustomOpen(false);
        trigger?.focus({ preventScroll: true });
      }
    };
    ownerDocument.addEventListener("pointerdown", closeOutside);
    keyTarget?.addEventListener("keydown", closeOnEscape, true);
    return () => {
      ownerDocument.removeEventListener("pointerdown", closeOutside);
      keyTarget?.removeEventListener("keydown", closeOnEscape, true);
    };
  }, [contained, popoverPosition]);

  function togglePopover() {
    if (popoverPosition) {
      setPopoverPosition(null);
      setCustomOpen(false);
      return;
    }
    if (disabled) return;
    const bounds = triggerRef.current.getBoundingClientRect();
    const ownerWindow = triggerRef.current.ownerDocument.defaultView ?? window;
    setPopoverPosition({
      left: Math.max(12, Math.min(bounds.right - 272, ownerWindow.innerWidth - 284)),
      top: bounds.bottom + 7,
    });
  }

  function selectArea(event) {
    if (event.type === "pointermove" && event.buttons !== 1) return;
    const bounds = event.currentTarget.getBoundingClientRect();
    const saturation = Math.max(0, Math.min(100, ((event.clientX - bounds.left) / bounds.width) * 100));
    const value = Math.max(0, Math.min(100, (1 - (event.clientY - bounds.top) / bounds.height) * 100));
    changeHsv({ hue: customHue, saturation, value });
    if (event.type === "pointerdown") event.currentTarget.setPointerCapture(event.pointerId);
  }

  return (
    <div className="explorer-color-row">
      <span className="explorer-property-name">{name}</span>
      <button
        ref={triggerRef}
        type="button"
        className="explorer-color-trigger"
        aria-label={`Color for ${name}`}
        aria-description={colorLabel}
        title={colorLabel}
        aria-expanded={Boolean(popoverPosition)}
        disabled={disabled}
        onClick={togglePopover}
      >
        <i style={{ "--explorer-swatch-color": swatchColor }} />
        <span>{colorLabel}</span>
      </button>
      {popoverPosition &&
        createPortal(
          <div
            ref={popoverRef}
            className="popover explorer-color-popover"
            aria-label={`Choose color for ${name}`}
            style={{
              left: popoverPosition.left,
              top: Math.max(
                12,
                Math.min(
                  popoverPosition.top,
                  (triggerRef.current?.ownerDocument.defaultView?.innerHeight ?? window.innerHeight) -
                    (customOpen ? 325 : 130) -
                    12,
                ),
              ),
            }}
          >
            <span className="explorer-color-popover-title">Theme colors</span>
            <div className="explorer-color-options" role="group" aria-label="Chart series colors">
              {chartColorOptions.map(({ label, token }) => (
                <button
                  key={token}
                  type="button"
                  className="explorer-color-option"
                  aria-label={label}
                  title={themeColorLabels[token] ? `${label} (${themeColorLabels[token]})` : label}
                  aria-description={themeColorLabels[token]}
                  aria-pressed={identity.explicitColor === token}
                  style={{ "--explorer-swatch-color": token }}
                  onClick={() => onChange(token)}
                />
              ))}
              <button
                type="button"
                className="explorer-color-option explorer-custom-color"
                aria-label={`Custom color for ${name}`}
                aria-expanded={customOpen}
                aria-pressed={customSelected}
                style={{ "--explorer-swatch-color": customSelected ? color : "var(--secondary)" }}
                onClick={toggleCustom}
              >
                <Icon name="plus" size={15} />
              </button>
            </div>
            {customOpen && (
              <div className="explorer-custom-picker">
                <div
                  className="explorer-color-area"
                  role="slider"
                  aria-label="Color saturation and brightness"
                  aria-valuemin={0}
                  aria-valuemax={100}
                  aria-valuenow={Math.round(customHsv.saturation)}
                  aria-valuetext={`${Math.round(customHsv.saturation)}% saturation, ${Math.round(customHsv.value)}% brightness`}
                  tabIndex={0}
                  style={{ "--explorer-custom-hue": `${customHue}deg` }}
                  onPointerDown={selectArea}
                  onPointerMove={selectArea}
                  onKeyDown={(event) => {
                    if (!event.key.startsWith("Arrow")) return;
                    event.preventDefault();
                    const step = event.shiftKey ? 10 : 1;
                    changeHsv({
                      ...customHsv,
                      saturation: Math.max(
                        0,
                        Math.min(
                          100,
                          customHsv.saturation +
                            (event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0),
                        ),
                      ),
                      value: Math.max(
                        0,
                        Math.min(
                          100,
                          customHsv.value + (event.key === "ArrowUp" ? step : event.key === "ArrowDown" ? -step : 0),
                        ),
                      ),
                    });
                  }}
                >
                  <span
                    className="explorer-color-handle"
                    aria-hidden="true"
                    style={{
                      left: `${customHsv.saturation}%`,
                      top: `${100 - customHsv.value}%`,
                      background: customHex,
                    }}
                  />
                </div>
                <input
                  className="explorer-hue-slider"
                  type="range"
                  min="0"
                  max="359"
                  aria-label="Color hue"
                  value={customHue}
                  onChange={(event) => {
                    const hue = Number(event.target.value);
                    changeHsv({ ...customHsv, hue });
                  }}
                />
                <label className="explorer-hex-color">
                  <span>Hex</span>
                  <input
                    aria-label={`Custom hex color for ${name}`}
                    value={customHex.toUpperCase()}
                    maxLength={7}
                    spellCheck={false}
                    onChange={(event) => {
                      const value = event.target.value.startsWith("#") ? event.target.value : `#${event.target.value}`;
                      setCustomHex(value);
                      if (/^#[\da-f]{6}$/iu.test(value)) onChange(value);
                    }}
                  />
                </label>
              </div>
            )}
          </div>,
          portalContainer ??
            triggerRef.current?.closest(".chart-editor-dialog") ??
            triggerRef.current?.ownerDocument.body ??
            document.body,
        )}
    </div>
  );
}

function BarPresentationControls({ spec, columns, numeric, update, SelectComponent, disabled, portalContainer, contained }) {
  const options = spec.barOptions ?? {};
  const option = (key, value) => update("barOptions")({ ...options, [key]: value });
  const control = (label, value, onChange, choices = numeric) => <label key={label}><span>{label}</span>
    <SelectComponent label={label} value={value} choices={choices} formatChoice={optionLabel} onChange={onChange}
      disabled={disabled} portalContainer={portalContainer} modal={!contained} /></label>;
  return <section className="explorer-section" aria-label="Reviewed data settings"><h3>Data</h3>
    {control("Category", spec.x, update("x"), columns)}
    {options.series?.length ? options.series.map((series, index) => control(series.label ?? `Measure ${index + 1}`, series.key,
      value => option("series", options.series.map((item, i) => i === index ? { ...item, key: value } : item))))
      : options.range?.length ? options.range.map((field, index) => control(index ? "Range end" : "Range start", field,
        value => option("range", options.range.map((item, i) => i === index ? value : item))))
        : control("Measure", spec.y, update("y"))}
    {options.target && control("Target", options.target, value => option("target", value))}
    {spec.presentation === "rangePosition" && control("Current value", spec.y, update("y"))}
    {options.projection && control("Projection", options.projection, value => option("projection", value))}
    {spec.presentation === "progress" && control("Goal", options.track?.max, value => option("track", { ...options.track, max: value }))}
  </section>;
}

export function ChartExplorer({
  SelectComponent = NativeSelect,
  component,
  rows,
  onChange,
  resolveColor,
  chartId,
  visibleSeries,
  zoomRange,
  canEdit = true,
  draft,
  getCapabilities,
  portalContainer,
  themeRoot,
  previewHeight = 500,
  contained = false,
  disabled = false,
  metadata,
  onMetadataChange,
  notice,
  error,
}) {
  const columns = useMemo(() => [...new Set(rows.flatMap(Object.keys))], [rows]);
  const numeric = useMemo(
    () =>
      columns.filter((column) => rows.some((row) => typeof row[column] === "number" && Number.isFinite(row[column]))),
    [columns, rows],
  );
  const initial = component.chart;
  const [localSpec, setLocalSpec] = useState(initial);
  const [previewReady, setPreviewReady] = useState(false);
  const [previewPainted, setPreviewPainted] = useState(false);
  const spec = useMemo(() => {
    const current = draft ?? localSpec;
    const type = resolvedChartType(current, rows);
    return type === current.type ? current : { ...current, type };
  }, [draft, localSpec, rows]);
  const previewSpec = useDeferredValue(spec);
  const capabilities = getCapabilities?.(spec, rows) ?? {};
  const { availableTypes, fieldsEditable, xFields, yFields, seriesFields } = chartEditorChoices(
    component.chart,
    spec,
    { columns, numeric },
    capabilities,
  );
  const notices = [
    ...new Set([notice, capabilities.notice].filter((value) => typeof value === "string" && value.trim())),
  ];
  const previewState = chartEditorPreviewState(component.chart, previewSpec, { visibleSeries, zoomRange });
  const previewKey = JSON.stringify([
    previewSpec.type,
    previewSpec.x,
    previewSpec.y,
    previewSpec.series ?? "",
    previewSpec.fields ?? [previewSpec.y],
  ]);
  const horizontal = spec.type.startsWith("horizontal");
  const categoryFieldLabel =
    spec.type === "pie" || spec.type === "funnel" ? "Group by" : horizontal ? "Y axis" : "X axis";
  const valueFieldLabel = spec.type === "pie" || spec.type === "funnel" ? "Measure" : horizontal ? "X axis" : "Y axis";
  const hasLegend = spec.type === "pie" || Boolean(spec.series) || (spec.fields ?? [spec.y]).length > 1;
  const hasAnnotations = annotationChartTypes.includes(spec.type)
    && Array.isArray(spec.annotations) && spec.annotations.length > 0;
  const colorKeys = useMemo(
    () =>
      semanticColorCharts.has(spec.type)
        ? []
        : spec.type === "pie" || spec.type === "funnel"
          ? [...new Set(rows.map((row) => row[spec.x]).filter((value) => value != null))]
          : spec.series
            ? [...new Set(rows.map((row) => row[spec.series]).filter((value) => value != null))]
            : (spec.fields ?? [spec.y]).filter(Boolean),
    [rows, spec.fields, spec.series, spec.type, spec.x, spec.y],
  );
  const colorForKey = (key, index) =>
    spec.colors?.[key] ??
    (spec.type === "funnel" ? spec.colors?.[spec.y]
      ?? resolveColor?.({ field: spec.y, index: 0 }) ?? semanticColor({ field: spec.y, index: 0 })
      : ["line", "sparkline"].includes(spec.type) && /(?:target|plan|forecast|projected|benchmark)/iu.test(String(key))
      ? "var(--secondary)"
      : resolveColor?.(
          spec.series || spec.type === "pie" || spec.type === "funnel"
            ? { field: spec.y, dimension: spec.series || spec.x, value: key, index }
            : { field: key, index },
        ));

  useEffect(() => {
    let revealFrame;
    const shellFrame = requestAnimationFrame(() => {
      revealFrame = requestAnimationFrame(() => setPreviewReady(true));
    });
    return () => {
      cancelAnimationFrame(shellFrame);
      if (revealFrame) cancelAnimationFrame(revealFrame);
    };
  }, []);

  const update = (field) => (value) => {
    if (
      disabled ||
      (field === "type" && !availableTypes.includes(value)) ||
      (field === "x" && (!fieldsEditable || !xFields.includes(value))) ||
      (field === "y" && (!fieldsEditable || !yFields.includes(value))) ||
      (field === "series" && (!fieldsEditable || !seriesFields.includes(value)))
    )
      return;
    const next = changeChartEditorSpec(draft ?? localSpec, field, value, { columns });
    setLocalSpec(next);
    onChange?.(next, !chartEditorSpecsEqual(next, initial), field);
  };

  return (
    <div className="chart-explorer">
      <div className="explorer-preview">
        <div className="explorer-chart" data-ready={previewPainted} aria-busy={!previewPainted}>
          <PreviewSkeleton />
          <div className="explorer-chart-content">
            {previewReady && (
              <MountedChart
                key={previewKey}
                onReady={() => setPreviewPainted(true)}
                spec={previewSpec}
                rows={rows}
                height={previewHeight}
                chartId={chartId ?? component.id}
                resolveColor={resolveColor}
                themeRoot={themeRoot}
                visibleSeries={previewState.visibleSeries}
                zoomRange={previewState.zoomRange}
              />
            )}
          </div>
        </div>
      </div>
      {canEdit && (
        <div className="explorer-controls" aria-label="Chart controls">
          {notices.map((value) => (
            <p key={value} className="chart-editor-notice">
              {value}
            </p>
          ))}
          {error && (
            <p className="chart-editor-error" role="alert">
              {error}
            </p>
          )}
          {metadata && (
            <section className="explorer-section chart-editor-metadata" aria-label="Chart details">
              <h3>Details</h3>
              <ChartMetadataField
                field="title"
                label="Title"
                value={metadata.title}
                maxLength={500}
                disabled={disabled}
                onChange={onMetadataChange}
              />
              <ChartMetadataField
                field="description"
                label="Description"
                value={metadata.description ?? ""}
                maxLength={2000}
                rows={1}
                disabled={disabled}
                onChange={onMetadataChange}
              />
            </section>
          )}
          {spec.presentation ? (fieldsEditable && <BarPresentationControls spec={spec} columns={columns} numeric={numeric}
            update={update} SelectComponent={SelectComponent} disabled={disabled} portalContainer={portalContainer}
            contained={contained} />) : <>
          <section className="explorer-section explorer-visualization-section" aria-label="Visualization settings">
            <h3>Visualization</h3>
            <label>
              <span>Chart type</span>
              <SelectComponent
                label="Chart type"
                value={spec.type}
                choices={availableTypes}
                groups={chartTypeGroups
                  .map((group) => ({
                    ...group,
                    choices: group.choices.filter((type) => availableTypes.includes(type)),
                  }))
                  .filter((group) => group.choices.length)}
                contentClassName="chart-type-menu"
                formatChoice={optionLabel}
                onChange={update("type")}
                disabled={disabled || availableTypes.length < 2}
                portalContainer={portalContainer}
                modal={!contained}
              />
            </label>
          </section>
          {(fieldsEditable || sortableCharts.has(spec.type)) && (
            <section className="explorer-section" aria-label="Reviewed data settings">
              <h3>Data</h3>
              {fieldsEditable && (
                <>
                  <label>
                    <span>{categoryFieldLabel}</span>
                    <SelectComponent
                      label={categoryFieldLabel}
                      value={spec.x}
                      choices={xFields}
                      formatChoice={optionLabel}
                      onChange={update("x")}
                      disabled={disabled || xFields.length < 2}
                      portalContainer={portalContainer}
                      modal={!contained}
                    />
                  </label>
                  <label>
                    <span>{valueFieldLabel}</span>
                    <SelectComponent
                      label={valueFieldLabel}
                      value={spec.y}
                      choices={yFields}
                      formatChoice={optionLabel}
                      onChange={update("y")}
                      disabled={disabled || yFields.length < 2}
                      portalContainer={portalContainer}
                      modal={!contained}
                    />
                  </label>
                  <label>
                    <span>Split by</span>
                    <SelectComponent
                      label="Split series by"
                      value={spec.series}
                      choices={seriesFields}
                      formatChoice={optionLabel}
                      onChange={update("series")}
                      disabled={disabled || seriesFields.length < 2}
                      portalContainer={portalContainer}
                      modal={!contained}
                    />
                  </label>
                </>
              )}
              {sortableCharts.has(spec.type) && (
                <label>
                  <span>Sort order</span>
                  <SelectComponent
                    label="Sort order"
                    value={
                      spec.sortOrder ?? (["leaderboard", "rankedList"].includes(spec.type) ? "descending" : "original")
                    }
                    choices={["original", "ascending", "descending"]}
                    formatChoice={optionLabel}
                    onChange={update("sortOrder")}
                    disabled={disabled}
                    portalContainer={portalContainer}
                    modal={!contained}
                  />
                </label>
              )}
            </section>
          )}
          {!chartsWithoutAxes.has(spec.type) && (
            <section className="explorer-section" aria-label="Chart axes settings">
              <h3>Axes</h3>
              {!spec.series && !spec.barFields?.length && (spec.fields ?? []).length > 1
                && ["line", "bar", "area", "horizontalBar"].includes(spec.type) && (
                <label><span>{horizontal ? "Secondary axis" : "Right axis"}</span>
                  <SelectComponent label="Secondary axis" value={spec.rightAxisFields === undefined ? "auto"
                    : spec.rightAxisFields[0] ?? "none"} choices={["auto", "none", ...spec.fields]}
                    formatChoice={(value) => value === "auto" ? "Automatic" : value === "none" ? "Shared scale" : optionLabel(value)}
                    onChange={(value) => update("rightAxisFields")(value === "auto" ? undefined : value === "none" ? [] : [value])}
                    disabled={disabled} portalContainer={portalContainer} modal={!contained} />
                </label>
              )}
              <label>
                <span>X axis label</span>
                <input
                  aria-label="X axis title"
                  value={spec.xLabel ?? (spec.type === "histogram" && spec.showXAxisLabel !== false ? humanize(spec.y) : "")}
                  disabled={disabled}
                  onChange={(event) => update("xLabel")(event.target.value)}
                />
              </label>
              {!spec.type.startsWith("horizontal") && (
                <label>
                  <span>Y axis label</span>
                  <input
                    aria-label="Y axis title"
                    value={spec.yLabel ?? (spec.type === "histogram" && spec.showYAxisLabel === true ? "Observations" : "")}
                    disabled={disabled}
                    onChange={(event) => update("yLabel")(event.target.value)}
                  />
                </label>
              )}
              <div className="explorer-control-row"><span>Y axis side</span>
                <SegmentedControl ariaLabel="Y axis side" size="default" value={spec.yAxisPosition ?? "left"}
                  options={[{ value: "left", label: "Left" }, { value: "right", label: "Right" }]}
                  onChange={update("yAxisPosition")} disabled={disabled} fullWidth />
              </div>
              {!["heatmap", "histogram"].includes(spec.type) && (
                <ChartSwitch
                  label="Start axis at zero"
                  checked={spec.startAtZero !== false}
                  onChange={update("startAtZero")}
                  disabled={disabled}
                />
              )}
            </section>
          )}
          {(colorKeys.length > 0 || spec.type === "heatmap") && (
            <section className="explorer-section" aria-label="Chart color settings">
              <h3>Colors</h3>
              {spec.type === "heatmap" && (
                <ChartColorControl
                  colorKey="Base color"
                  color={spec.baseColor ?? "var(--chart-1)"}
                  automatic={spec.baseColor == null}
                  onChange={update("baseColor")}
                  portalContainer={portalContainer}
                  contained={contained}
                  themeRoot={themeRoot}
                  disabled={disabled}
                />
              )}
              {colorKeys.map((key, index) => (
                <ChartColorControl
                  key={String(key)}
                  colorKey={key}
                  color={colorForKey(key, index)}
                  swatchColor={spec.type === "funnel" ? funnelStageColor(colorForKey(key, index),
                    rows.findIndex((row) => row[spec.x] === key), rows.length) : undefined}
                  automatic={spec.colors?.[key] == null}
                  onChange={(color) => update("colors")({ ...spec.colors, [key]: color })}
                  portalContainer={portalContainer}
                  contained={contained}
                  themeRoot={themeRoot}
                  disabled={disabled}
                />
              ))}
            </section>
          )}
          {(!chartsWithoutOptionalLabels.has(spec.type) || hasLegend || hasAnnotations) && (
            <section className="explorer-section" aria-label="Chart appearance settings">
              <h3>Appearance</h3>
              {!chartsWithoutOptionalLabels.has(spec.type) && (
                <ChartSwitch
                  label="Show values"
                  checked={spec.showValues === true}
                  onChange={update("showValues")}
                  disabled={disabled}
                />
              )}
              {hasLegend && (
                <ChartSwitch
                  label="Show legend"
                  checked={spec.showLegend !== false}
                  onChange={update("showLegend")}
                  disabled={disabled}
                />
              )}
              {hasAnnotations && <ChartSwitch label="Show annotations"
                checked={spec.showAnnotations !== false} onChange={update("showAnnotations")} disabled={disabled} />}
            </section>
          )}
          </>}
        </div>
      )}
    </div>
  );
}

SHA-256: e23683cddd6efe4d323844fa97cc5a6fdfef665cd7312c5fece80618696c56d3