← Files DataARCHIVED FILE

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

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

↓ Download file

import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { useDashboardAsk } from "../components/DashboardAsk.jsx";
import { Tooltip } from "../components/ui.jsx";
import { chartMarkTooltipPosition } from "../dashboard-ask.js";

/** Capture an already-visible plot tooltip without moving its selected-state anchor. */
export function captureChartHoverCard(element, tooltip) {
  if (!element || !tooltip) return undefined;
  const bounds = tooltip.getBoundingClientRect(), chartBounds = element.getBoundingClientRect();
  if (!bounds.width || !bounds.height) return undefined;
  const style = getComputedStyle(tooltip);
  return { element, width: bounds.width, height: bounds.height,
    left: bounds.left - chartBounds.left, top: bounds.top - chartBounds.top,
    radius: style.borderRadius, shadow: style.boxShadow, background: style.backgroundColor };
}

/** A custom visualization's native button, using the same selection card as ChartRenderer. */
export function ChartMark({ context, tooltip, tooltipEnabled = true, children, ...props }) {
  const { selectChartMark, canSelectChartMark, selectedMarkElement, pinnedChartElement, dismissChartMark } = useDashboardAsk();
  const markRef = useRef(null), tooltipRef = useRef(null);
  const pointer = useRef(null), placeRef = useRef(null);
  const id = useId();
  const [hover, setHover] = useState(false);
  const [positioned, setPositioned] = useState(false);
  const [pendingClick, setPendingClick] = useState(null);
  const selected = selectedMarkElement === markRef.current && selectedMarkElement != null;
  const root = () => markRef.current?.closest("[data-chart-interaction-root]") ?? markRef.current;
  const blocked = () => pinnedChartElement === root() || root()?.hasAttribute("data-chart-tooltip-pinned");
  const visible = tooltipEnabled && hover && !blocked();
  const show = () => { if (!blocked()) setHover(true); };
  function move(event) {
    pointer.current = { x: event.clientX, y: event.clientY };
    if (visible) placeRef.current?.();
  }
  useLayoutEffect(() => {
    if (!visible) return undefined;
    let geometry;
    const mark = markRef.current, card = tooltipRef.current?.querySelector(".chart-tooltip");
    const wrapper = tooltipRef.current?.closest(".chart-mark-tooltip");
    if (!mark || !card || !wrapper) return undefined;
    function place() {
      geometry ??= { mark: mark.getBoundingClientRect(), card: card.getBoundingClientRect() };
      const next = chartMarkTooltipPosition(geometry.mark, geometry.card,
        { width: window.innerWidth, height: window.innerHeight }, 12, 8, pointer.current);
      wrapper.style.transform = `translate3d(${next.left}px, ${next.top}px, 0)`;
      wrapper.style.visibility = "visible";
    }
    const invalidate = () => { geometry = null; place(); };
    const observer = new ResizeObserver(invalidate);
    observer.observe(mark); observer.observe(card);
    placeRef.current = place;
    place();
    setPositioned(true);
    window.addEventListener("resize", invalidate);
    document.addEventListener("scroll", invalidate, true);
    return () => { placeRef.current = null;
      observer.disconnect();
      window.removeEventListener("resize", invalidate); document.removeEventListener("scroll", invalidate, true); };
  }, [visible]);
  useLayoutEffect(() => {
    if (!pendingClick || !visible || !positioned) return;
    const element = markRef.current, card = tooltipRef.current?.querySelector(".chart-tooltip");
    if (!card || !element) return;
    const bounds = card.getBoundingClientRect(), mark = element.getBoundingClientRect(), style = getComputedStyle(card);
    const selected = selectChartMark({ ...context, hoverCard: { element, rootElement: root(), width: bounds.width, height: bounds.height,
      left: bounds.left - mark.left, top: bounds.top - mark.top,
      radius: style.borderRadius, shadow: style.boxShadow, background: style.backgroundColor } }, pendingClick);
    setPendingClick(null);
    setHover(false);
  }, [pendingClick, visible, positioned, context, selectChartMark]);
  useEffect(() => {
    const mark = markRef.current;
    setHover(false); setPositioned(false); setPendingClick(null);
    return () => dismissChartMark(mark);
  }, [context.row]);
  function click(event) {
    props.onClick?.(event);
    if (event.defaultPrevented || !canSelectChartMark(context) || blocked()) return;
    if (!tooltipEnabled) { selectChartMark(context, event); return; }
    // A tap or keyboard activation first measures the same hover card before pinning it.
    if (!visible) setPositioned(false);
    setHover(true);
    setPendingClick({ target: event.currentTarget, detail: event.detail });
  }
  return <>
    <button {...props} ref={markRef} type="button" data-chart-mark
      aria-haspopup={canSelectChartMark(context) ? "dialog" : undefined} aria-expanded={selected}
      aria-describedby={visible ? id : undefined}
      onPointerEnter={event => { props.onPointerEnter?.(event); move(event); show(); }}
      onPointerMove={event => { props.onPointerMove?.(event); move(event); }}
      onPointerLeave={event => { props.onPointerLeave?.(event); if (event.pointerType !== "touch") setHover(false); }}
      onPointerCancel={event => { props.onPointerCancel?.(event); pointer.current = null; setHover(false); }}
      onFocus={event => { props.onFocus?.(event); if (event.currentTarget.matches(":focus-visible")) pointer.current = null; show(); }}
      onBlur={event => { props.onBlur?.(event); setHover(false); }}
      onKeyDown={event => { props.onKeyDown?.(event); if (event.key === "Escape") setHover(false); }} onClick={click}>{children}</button>
    {visible && <Tooltip portal visible id={id} className="chart-mark-tooltip" style={{ left: 0, top: 0, visibility: "hidden" }}>
      <span ref={tooltipRef}>{tooltip}</span>
    </Tooltip>}
  </>;
}

SHA-256: bad3bfefaef82395ea67aaef44c75941d6087eb45137214bb78d04b2978565b1