@tanstack/charts
Version:
A chart grammar for TypeScript and JavaScript. Marks consume your data directly, channels describe visual encodings, and the engine compiles them into a renderer-neutral keyed scene. TanStack's compact scales cover common numeric and categorical mappings.
646 lines (645 loc) • 21.7 kB
JavaScript
import { placeTooltip } from "./tooltip-position.js";
import {
createChartTooltipContent,
orderChartTooltipPoints,
resolveChartTooltipAnchor
} from "./tooltip-model.js";
const tooltip = {
id: "tooltip",
__chartExtensionType: "tooltip",
__chartTooltipHost: "dom",
create: createTooltipExtension
};
function createTooltipExtension(extensionContext) {
let options = {};
let element;
let bodyElement;
let activeBodyChange;
let bodyVisible = false;
let bodyScene;
let bodyPoints = [];
let bodyPinned = false;
let bodyDirty = false;
let paintContext;
let anchor = null;
let positionFrame;
let resizeObserver;
let portalExtension;
let portalInstance;
const { container } = extensionContext;
const view = container.ownerDocument.defaultView;
const tooltipMotion = extensionContext.motion;
function update(nextOptions) {
if (options !== nextOptions) bodyDirty = true;
options = nextOptions;
if (element) syncPortal();
}
function paint(nextContext) {
paintContext = nextContext;
if (options.visibility === "pinned" && !nextContext.pinned) {
hide();
return;
}
const tooltipElement = ensureElement();
syncPortal();
const motionSnapshot = tooltipMotion?.beforePaint(tooltipElement);
tooltipElement.style.visibility = "hidden";
tooltipElement.removeAttribute("hidden");
tooltipElement.className = options.className ? `ts-chart-tooltip ${options.className}` : "ts-chart-tooltip";
const points = orderChartTooltipPoints(
nextContext.points,
nextContext.scene,
options.sort
);
const resolvedContent = createChartTooltipContent(
points,
nextContext.scene,
nextContext.pinned,
options,
nextContext.point
);
const custom = renderTooltipBody(
tooltipElement,
points,
resolvedContent,
nextContext.pinned
);
if (!custom) {
if (typeof resolvedContent === "string") {
paintPlainTooltip(tooltipElement, resolvedContent);
} else {
paintStructuredTooltip(tooltipElement, resolvedContent);
}
}
configureTooltipSemantics(
tooltipElement,
resolvedContent,
custom,
nextContext.pinned
);
tooltipElement.style.pointerEvents = nextContext.pinned ? "auto" : "none";
tooltipElement.style.userSelect = nextContext.pinned ? "text" : "none";
tooltipElement.dataset.sticky = String(nextContext.pinned);
anchor = resolveChartTooltipAnchor(
nextContext.point,
points,
nextContext.scene,
nextContext.pointer,
options,
nextContext.focus
);
position();
tooltipElement.style.removeProperty("visibility");
if (motionSnapshot) {
tooltipMotion?.afterPaint(tooltipElement, motionSnapshot, options.motion);
}
}
function ensureElement() {
if (element) return element;
element = createTooltip(container.ownerDocument);
element.addEventListener("keydown", handleKeyDown);
resizeObserver = view?.ResizeObserver ? new view.ResizeObserver(schedulePosition) : void 0;
resizeObserver?.observe(element);
container.append(element);
return element;
}
function handleKeyDown(event) {
if (event.key !== "Escape" || !paintContext?.pinned) return;
event.preventDefault();
event.stopPropagation();
extensionContext.dismiss();
}
function schedulePosition() {
if (!paintContext || !anchor || positionFrame !== void 0) return;
if (!view?.requestAnimationFrame) {
position();
return;
}
positionFrame = view.requestAnimationFrame(() => {
positionFrame = void 0;
position();
});
}
function position() {
if (!paintContext || !element || !anchor) return;
if (portalInstance) {
const visible = portalInstance.position({
scene: paintContext.scene,
surface: paintContext.surface,
anchor,
placement: options.placement,
offset: options.offset
});
if (!visible) element.setAttribute("hidden", "");
return;
}
placeTooltip(
element,
anchor.x,
anchor.y,
{
left: 0,
top: 0,
right: paintContext.scene.width,
bottom: paintContext.scene.height
},
options.placement,
options.offset
);
}
function syncPortal() {
if (!element) return;
const input = options.portal;
const nextExtension = input ? "create" in input ? input : input.use : void 0;
const nextOptions = input && "use" in input ? input : {};
if (nextExtension !== portalExtension) {
portalInstance?.destroy();
portalInstance = void 0;
portalExtension = nextExtension;
if (nextExtension) {
portalInstance = nextExtension.create(
{
container,
element,
schedulePosition
},
nextOptions
);
} else {
moveToContainer();
}
} else {
portalInstance?.update(nextOptions);
}
}
function moveToContainer() {
if (!element) return;
if (element.parentNode !== container) container.append(element);
element.removeAttribute("popover");
delete element.dataset.tsChartTooltipPortal;
Object.assign(element.style, {
position: "absolute",
zIndex: "1",
right: "auto",
bottom: "auto",
margin: "0"
});
}
function renderTooltipBody(tooltipElement, points, content, pinned) {
const callback = extensionContext.bodyChange();
if (!callback) {
deactivateTooltipBody();
return false;
}
if (activeBodyChange !== callback) {
activeBodyChange?.(null);
activeBodyChange = callback;
bodyVisible = false;
bodyElement = void 0;
}
if (!bodyElement) {
bodyElement = tooltipElement.ownerDocument.createElement("div");
bodyElement.className = "ts-chart-tooltip__body";
tooltipElement.replaceChildren(bodyElement);
}
bodyElement.toggleAttribute("inert", !pinned);
setTooltipContentAccessibility(tooltipElement, content);
const changed = bodyDirty || !bodyVisible || bodyScene !== paintContext?.scene || bodyPinned !== pinned || !samePointList(points, bodyPoints);
bodyDirty = false;
bodyVisible = true;
bodyScene = paintContext?.scene;
bodyPoints = points;
bodyPinned = pinned;
if (changed) {
callback({
element: bodyElement,
points,
content,
pinned,
dismiss: extensionContext.dismiss
});
}
return true;
}
function hideTooltipBody() {
if (!bodyVisible) return;
bodyVisible = false;
activeBodyChange?.(null);
}
function deactivateTooltipBody() {
hideTooltipBody();
activeBodyChange = void 0;
bodyElement = void 0;
bodyScene = void 0;
bodyPoints = [];
}
function hide() {
paintContext = void 0;
anchor = null;
const currentElement = element;
if (!currentElement || currentElement.hidden) {
portalInstance?.hide();
hideTooltipBody();
return;
}
const complete = () => {
portalInstance?.hide();
currentElement.setAttribute("hidden", "");
hideTooltipBody();
};
if (tooltipMotion?.hide(currentElement, options.motion, complete)) return;
complete();
}
function destroy() {
hide();
deactivateTooltipBody();
portalInstance?.destroy();
portalInstance = void 0;
portalExtension = void 0;
if (positionFrame !== void 0) {
view?.cancelAnimationFrame?.(positionFrame);
positionFrame = void 0;
}
tooltipMotion?.destroy(element);
resizeObserver?.disconnect();
resizeObserver = void 0;
element?.remove();
element = void 0;
}
return {
update,
paint,
hide,
contains: (target) => Boolean(target && element?.contains(target)),
destroy
};
}
function samePointList(left, right) {
return left.length === right.length && left.every(
(point, index) => point.key === right[index]?.key && point.markId === right[index]?.markId && point.datumIndex === right[index]?.datumIndex
);
}
function createTooltip(document) {
const tooltipElement = document.createElement("div");
tooltipElement.className = "ts-chart-tooltip";
tooltipElement.setAttribute("role", "status");
tooltipElement.setAttribute("aria-live", "polite");
Object.assign(tooltipElement.style, {
position: "absolute",
zIndex: "1",
maxWidth: "var(--ts-chart-tooltip-max-width, min(24rem, 80%))",
padding: "var(--ts-chart-tooltip-padding, 0.4rem 0.55rem)",
border: "var(--ts-chart-tooltip-border, 1px solid color-mix(in srgb, CanvasText 18%, transparent))",
borderRadius: "var(--ts-chart-tooltip-border-radius, 0.45rem)",
background: "var(--ts-chart-tooltip-background, Canvas)",
color: "var(--ts-chart-tooltip-color, CanvasText)",
boxShadow: "var(--ts-chart-tooltip-shadow, 0 6px 24px rgb(0 0 0 / 0.14))",
font: "var(--ts-chart-tooltip-font, 500 0.75rem/1.3 system-ui, sans-serif)",
pointerEvents: "none",
overflowWrap: "anywhere"
});
tooltipElement.hidden = true;
return tooltipElement;
}
function createTooltipContentContext(scene, pinned, options) {
const x = findTooltipChannelItem(options?.items, "x");
const y = findTooltipChannelItem(options?.items, "y");
return {
pinned,
xLabel: x?.label ?? findSceneLabel(scene, "x-label") ?? "x",
yLabel: y?.label ?? findSceneLabel(scene, "y-label") ?? "y",
formatX: formatValue,
formatY: formatValue
};
}
function defaultTooltipContent(points, scene, options, context) {
const point = points[0];
if (!point) return { rows: [] };
const x = findTooltipChannelItem(options?.items, "x");
const y = findTooltipChannelItem(options?.items, "y");
const group = findTooltipChannelItem(options?.items, "group");
const sharedX = points.length > 1 && points.every((candidate) => chartValueEqual(candidate.xValue, point.xValue));
const sharedY = points.length > 1 && points.every((candidate) => chartValueEqual(candidate.yValue, point.yValue));
if (sharedX || sharedY) {
const axis = sharedX ? "x" : "y";
const axisItem = sharedX ? x : y;
const label = axisItem?.label ?? findSceneLabel(scene, `${axis}-label`);
const value = formatPointAxis(point, axis, axisItem, context);
return {
title: label ? `${label}: ${value}` : value,
rows: points.map((candidate) => ({
label: formatTooltipGroup(candidate, group, context),
value: formatPointAxis(
candidate,
sharedX ? "y" : "x",
sharedX ? y : x,
context
),
color: candidate.color
}))
};
}
if (points.length > 1) {
return {
rows: points.map((candidate) => ({
label: formatTooltipGroup(candidate, group, context),
value: `${formatPointAxis(candidate, "x", x, context)} \xB7 ${formatPointAxis(candidate, "y", y, context)}`,
color: candidate.color
}))
};
}
const items = options?.items;
return {
title: point.group == null || items?.some(isTooltipGroupItem) ? void 0 : formatTooltipGroup(point, group, context),
color: point.group == null || items?.some(isTooltipGroupItem) ? void 0 : point.color,
rows: items ? tooltipItemRows(point, items, context) : [
{
label: context.xLabel,
value: formatPointAxis(point, "x", x, context)
},
{
label: context.yLabel,
value: formatPointAxis(point, "y", y, context)
}
]
};
}
function orderTooltipPoints(points, scene, sort) {
if (sort === "focus") return [...points];
if (typeof sort === "function") return [...points].sort(sort);
if (sort !== "color-domain") {
const first = points[0];
const sharedX = first !== void 0 && points.every((point) => chartValueEqual(point.xValue, first.xValue));
const sharedY = first !== void 0 && points.every((point) => chartValueEqual(point.yValue, first.yValue));
return [...points].sort(
(left, right) => sharedY && !sharedX ? left.x - right.x || left.y - right.y : left.y - right.y || left.x - right.x
);
}
return [...points].sort(
(left, right) => colorOrder(scene, left.group) - colorOrder(scene, right.group)
);
}
function colorOrder(scene, group) {
const index = group == null ? -1 : scene.colors.domain.indexOf(group);
return index < 0 ? Number.MAX_SAFE_INTEGER : index;
}
function tooltipItemRows(point, items, context) {
return items.flatMap((item) => {
if (typeof item === "string") {
if (item === "group") {
return [
{
label: "Group",
value: point.groupLabel,
color: point.color
}
];
}
return [
{
label: item === "x" ? context.xLabel : context.yLabel,
value: formatPointAxis(point, item, void 0, context)
}
];
}
if ("channel" in item) {
const text = item.text?.(point, context);
if (item.text && text == null) return [];
if (item.channel === "group") {
return [
{
label: item.label ?? "Group",
value: text ?? point.groupLabel,
color: point.color
}
];
}
return [
{
label: item.label ?? (item.channel === "x" ? context.xLabel : context.yLabel),
value: text ?? formatPointAxis(point, item.channel, void 0, context)
}
];
}
if ("field" in item) {
const value2 = point.datum[item.field];
if (value2 == null) return [];
const text = item.text?.(point, context);
if (item.text && text == null) return [];
return [
{
label: item.label ?? item.field,
value: text ?? formatValue(value2)
}
];
}
const value = item.text(point, context);
return value == null ? [] : [{ label: item.label ?? item.id, value }];
});
}
function findTooltipChannelItem(items, channel) {
const item = items?.find(
(candidate) => tooltipItemChannel(candidate) === channel
);
return typeof item === "object" && "channel" in item ? item : void 0;
}
function tooltipItemChannel(item) {
return typeof item === "string" ? item : "channel" in item ? item.channel : void 0;
}
function isTooltipGroupItem(item) {
return tooltipItemChannel(item) === "group";
}
function formatTooltipGroup(point, item, context) {
return item?.text?.(point, context) ?? point.groupLabel;
}
function formatPointAxis(point, axis, item, context) {
const itemText = item?.text?.(point, context);
if (itemText != null) return itemText;
const start = axis === "x" ? point.x1Value : point.y1Value;
const end = axis === "x" ? point.x2Value : point.y2Value;
const interval = axis === "x" ? point.xInterval : point.yInterval;
if (interval === "difference" && typeof start === "number" && typeof end === "number") {
return formatValue(end - start);
}
if (interval === "range" && start !== void 0 && end !== void 0 && !chartValueEqual(start, end)) {
return `${formatValue(start)}\u2013${formatValue(end)}`;
}
return formatValue(axis === "x" ? point.xValue : point.yValue);
}
function findSceneLabel(scene, key) {
const axes = scene.nodes.find(
(node) => node.kind === "group" && node.key === "axes"
);
if (axes?.kind !== "group") return void 0;
const label = axes.children.find((node) => node.key === key);
return label?.kind === "label" ? label.text : void 0;
}
function paintPlainTooltip(tooltipElement, text) {
setTooltipContentAccessibility(tooltipElement, text);
tooltipElement.textContent = text;
}
function paintStructuredTooltip(tooltipElement, content) {
const document = tooltipElement.ownerDocument;
const children = [];
if (content.title) {
const title = document.createElement("div");
title.className = "ts-chart-tooltip__title";
title.style.cssText = `display:flex;align-items:center;gap:.4rem;font-weight:650;margin-bottom:${content.rows.length ? ".3rem" : "0"}`;
if (content.color)
title.append(createTooltipSwatch(document, content.color));
title.append(content.title);
children.push(title);
}
if (content.rows.length) {
const rows = document.createElement("div");
rows.className = "ts-chart-tooltip__rows";
rows.setAttribute("aria-hidden", "true");
for (const row of content.rows) {
const line = document.createElement("div");
line.className = "ts-chart-tooltip__row";
line.style.cssText = "display:grid;grid-template-columns:.55rem minmax(0,1fr) auto;align-items:center;column-gap:.4rem";
const swatch = row.color ? createTooltipSwatch(document, row.color) : document.createElement("span");
const label = document.createElement("span");
label.textContent = row.label;
const value = document.createElement("span");
value.textContent = row.value;
value.style.cssText = "text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap";
line.append(swatch, label, value);
rows.append(line);
}
children.push(rows);
}
tooltipElement.replaceChildren(...children);
setTooltipContentAccessibility(tooltipElement, content);
}
function setTooltipContentAccessibility(tooltipElement, content) {
if (typeof content === "string") {
tooltipElement.removeAttribute("aria-label");
tooltipElement.style.whiteSpace = "pre-wrap";
return;
}
tooltipElement.style.whiteSpace = "normal";
tooltipElement.setAttribute(
"aria-label",
[content.title, ...content.rows.map((row) => `${row.label}: ${row.value}`)].filter(Boolean).join("\n")
);
}
function configureTooltipSemantics(tooltipElement, content, custom, pinned) {
if (custom && typeof content === "string") {
tooltipElement.setAttribute("aria-label", content);
}
if (custom && pinned) {
tooltipElement.setAttribute("role", "dialog");
tooltipElement.setAttribute("aria-modal", "false");
tooltipElement.removeAttribute("aria-live");
return;
}
tooltipElement.setAttribute("role", "status");
tooltipElement.setAttribute("aria-live", "polite");
tooltipElement.removeAttribute("aria-modal");
if (!custom && typeof content === "string") {
tooltipElement.removeAttribute("aria-label");
}
}
function createTooltipSwatch(document, color) {
const swatch = document.createElement("span");
swatch.className = "ts-chart-tooltip__swatch";
swatch.setAttribute("aria-hidden", "true");
swatch.style.cssText = "display:block;width:.55rem;height:.55rem;border-radius:.15rem;box-shadow:inset 0 0 0 1px rgb(0 0 0/.12)";
swatch.style.background = color;
return swatch;
}
function formatValue(value) {
return value instanceof Date ? Number.isNaN(+value) ? "Invalid Date" : value.toISOString().replace("T00:00:00.000Z", "") : typeof value === "number" ? value.toLocaleString() : String(value);
}
function resolveTooltipAnchor(point, points, scene, pointer, options, focus = {
primary: point,
group: points,
source: "programmatic",
pinned: false
}) {
const fallback = { x: point.x, y: point.y };
const anchor = options?.anchor ?? "point";
if (anchor === "point") return fallback;
if (anchor === "pointer") return pointer ?? fallback;
if (anchor === "group-center") {
let x1 = point.x;
let x2 = point.x;
let y1 = point.y;
let y2 = point.y;
for (const candidate of points) {
x1 = Math.min(x1, candidate.x);
x2 = Math.max(x2, candidate.x);
y1 = Math.min(y1, candidate.y);
y2 = Math.max(y2, candidate.y);
}
return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
}
if (typeof anchor === "object") {
return {
x: resolveTooltipCoordinate(
"x",
anchor.x,
point,
points,
scene,
pointer,
fallback.x
),
y: resolveTooltipCoordinate(
"y",
anchor.y,
point,
points,
scene,
pointer,
fallback.y
)
};
}
const resolved = anchor(points, {
focus,
pointer,
plot: scene.chart,
surface: { width: scene.width, height: scene.height },
scales: scene.scales
});
return resolved && Number.isFinite(resolved.x) && Number.isFinite(resolved.y) ? resolved : fallback;
}
function resolveTooltipCoordinate(axis, source, point, points, scene, pointer, fallback) {
if (source === "point") return axis === "x" ? point.x : point.y;
if (source === "pointer") return pointer?.[axis] ?? fallback;
if (source === "value") {
const value = axis === "x" ? point.xValue : point.yValue;
const scale = scene.scales[axis];
const position = (scale?.viewport?.map ?? scale?.map)?.(value);
return position !== void 0 && Number.isFinite(position) ? position : fallback;
}
if (source === "group-center") {
let minimum = axis === "x" ? point.x : point.y;
let maximum = minimum;
for (const candidate of points) {
const position = axis === "x" ? candidate.x : candidate.y;
minimum = Math.min(minimum, position);
maximum = Math.max(maximum, position);
}
return (minimum + maximum) / 2;
}
const plot = scene.chart;
if (axis === "x") {
if (source === "plot-left") return plot.x;
if (source === "plot-center") return plot.x + plot.width / 2;
if (source === "plot-right") return plot.x + plot.width;
} else {
if (source === "plot-top") return plot.y;
if (source === "plot-center") return plot.y + plot.height / 2;
if (source === "plot-bottom") return plot.y + plot.height;
}
return fallback;
}
function chartValueEqual(left, right) {
return left instanceof Date && right instanceof Date ? left.getTime() === right.getTime() : left === right;
}
export {
tooltip
};