@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.
701 lines (700 loc) • 24 kB
JavaScript
import { select } from "d3-selection";
import { zoom as createD3Zoom, zoomIdentity } from "d3-zoom";
import { createInteractionAxis } from "./interaction-axis-internal.js";
import {
cloneInteractionRange,
normalizeInteractionRange,
sameInteractionRange
} from "./interaction-range-internal.js";
const defaultId = "zoom-x";
const defaultAriaLabel = "Zoomable horizontal chart region";
function zoomX(options) {
const id = options.id?.trim() || defaultId;
if (options.id !== void 0 && !options.id.trim()) {
throw new TypeError("zoomX id cannot be empty");
}
const scaleExtent = resolveScaleExtent(options.scaleExtent);
return {
id,
resolve(context) {
assertWindowKinds(options.window.value, options.extent);
const axis = createInteractionAxis({
axis: "x",
scale: context.scales.x,
extent: [context.chart.x, context.chart.x + context.chart.width],
sample: options.window.value.start
});
const extent = normalizeInteractionRange(axis, {
start: options.extent[0],
end: options.extent[1]
});
if (sameInteractionValue(axis, extent.start, extent.end)) {
throw new TypeError("zoomX extent must contain two distinct values");
}
const input = normalizeInteractionRange(
axis,
options.window.value
);
const window = constrainWindow(axis, input, extent, scaleExtent);
const control = {
kind: "zoom-x",
key: id,
id,
extension: zoomXControlExtension,
bounds: context.chart,
width: context.width,
height: context.height,
axis,
window,
extent,
scaleExtent,
keyboard: options.keyboard !== false,
ariaLabel: options.ariaLabel ?? defaultAriaLabel,
ariaDescription: options.ariaDescription,
format: options.format ?? defaultFormat,
change(value, reason) {
options.window.onChange(cloneWindow(value), {
reason: cloneChange(reason)
});
},
activeChange: options.onActiveChange
};
return { controls: [control] };
}
};
}
const zoomXControlExtension = {
id: "zoom-x",
create: createZoomXControl
};
function createZoomXControl({
container,
surface
}) {
const namespace = "http://www.w3.org/2000/svg";
const root = container.ownerDocument.createElementNS(namespace, "svg");
const hitTarget = container.ownerDocument.createElementNS(namespace, "rect");
root.append(hitTarget);
Object.assign(root.style, {
position: "absolute",
inset: "0",
zIndex: "1",
width: "100%",
height: "100%",
overflow: "visible",
pointerEvents: "none"
});
hitTarget.setAttribute("fill", "transparent");
hitTarget.setAttribute("pointer-events", "all");
hitTarget.setAttribute("vector-effect", "non-scaling-stroke");
hitTarget.style.outline = "none";
const selection = select(hitTarget);
const behavior = createD3Zoom().touchable(true).clickDistance(3).filter(filterD3Input).on("start.chart-zoom-x", handleD3Start).on("zoom.chart-zoom-x", handleD3Zoom).on("end.chart-zoom-x", handleD3End);
let control;
let scene;
let active = false;
let syncing = false;
let destroying = false;
let lastAction = "none";
let wheelCaptured = false;
let wheelTimer;
let wheelOrigin;
let wheelValue;
let wheelAction;
let gestureControl;
let gestureOrigin;
let gestureValue;
let gestureAction = "pan";
let gestureSource = "pointer";
let gestureActive = false;
let gestureChanged = false;
let gestureCancelled = false;
let cancelEmitted = false;
let gestureResyncWindow;
let activeView;
hitTarget.addEventListener("pointerdown", handlePointerDown);
hitTarget.addEventListener("pointercancel", handlePointerCancel, true);
hitTarget.addEventListener("focus", handleFocus);
hitTarget.addEventListener("blur", handleBlur);
hitTarget.addEventListener("wheel", observeWheel, { capture: true });
hitTarget.addEventListener("wheel", handleWheel, { passive: false });
hitTarget.addEventListener("keydown", handleKeyDown);
return {
update(nextControl, nextScene) {
const next = asZoomXControl(nextControl);
const gestureDiverged = gestureActive && !gestureCancelled && (Boolean(
gestureControl && (next.width !== gestureControl.width || next.height !== gestureControl.height)
) || (gestureChanged ? !gestureValue || !sameWindow(next.axis, next.window, gestureValue) : Boolean(
control && !sameWindow(next.axis, next.window, control.window)
)));
const abortPointerGesture = gestureDiverged && gestureSource === "pointer";
const preserveGestureFrame = gestureActive && !gestureCancelled && !gestureDiverged && gestureControl !== void 0;
const wheelDiverged = Boolean(
wheelValue && !sameWindow(next.axis, next.window, wheelValue)
);
control = next;
scene = nextScene;
if (gestureDiverged) abortGesture(next.window);
else if (gestureResyncWindow) {
gestureResyncWindow = cloneWindow(next.window);
}
if (wheelDiverged) clearWheel();
root.dataset.chartZoom = next.id;
root.setAttribute("viewBox", `0 0 ${next.width} ${next.height}`);
root.setAttribute("width", "100%");
root.setAttribute("height", "100%");
hitTarget.dataset.chartZoomSurface = next.id;
hitTarget.setAttribute("x", String(next.bounds.x));
hitTarget.setAttribute("y", String(next.bounds.y));
hitTarget.setAttribute("width", String(next.bounds.width));
hitTarget.setAttribute("height", String(next.bounds.height));
hitTarget.setAttribute("aria-label", next.ariaLabel);
hitTarget.setAttribute(
"aria-description",
next.ariaDescription ?? defaultDescription(next)
);
if (next.keyboard) {
hitTarget.setAttribute("role", "application");
hitTarget.setAttribute("tabindex", "0");
hitTarget.setAttribute(
"aria-keyshortcuts",
"ArrowLeft ArrowRight + - Home Escape"
);
} else {
hitTarget.removeAttribute("role");
hitTarget.setAttribute("tabindex", "-1");
hitTarget.removeAttribute("aria-keyshortcuts");
}
if (!root.isConnected || root.parentElement !== container) {
container.append(root);
}
configureBehavior(preserveGestureFrame ? gestureControl ?? next : next);
if (!preserveGestureFrame) syncTransform(next.window);
if (abortPointerGesture) endMouseGesture();
updatePresentation();
},
contains(target) {
return Boolean(target && root.contains(target));
},
destroy() {
destroying = true;
clearWheel();
if (gestureActive && gestureSource === "pointer") endMouseGesture();
hitTarget.removeEventListener("pointerdown", handlePointerDown);
hitTarget.removeEventListener("pointercancel", handlePointerCancel, true);
hitTarget.removeEventListener("focus", handleFocus);
hitTarget.removeEventListener("blur", handleBlur);
hitTarget.removeEventListener("wheel", observeWheel, true);
hitTarget.removeEventListener("wheel", handleWheel);
hitTarget.removeEventListener("keydown", handleKeyDown);
selection.on(".zoom", null);
if (active) {
active = false;
control?.activeChange?.(false);
}
root.remove();
control = void 0;
scene = void 0;
gestureActive = false;
activeView = void 0;
}
};
function filterD3Input(event) {
if (!active || event.type === "wheel" || event.type === "dblclick") {
return false;
}
return !("button" in event) || Number(event.button) === 0;
}
function handlePointerDown() {
hitTarget.focus();
}
function handlePointerCancel() {
if (gestureActive) cancelGesture(gestureSource);
}
function handleFocus() {
setActive(true);
}
function handleBlur() {
if (gestureActive) cancelGesture(gestureSource);
else if (wheelTimer) cancelWheel("wheel");
setActive(false);
}
function setActive(next) {
if (active === next) return;
active = next;
updatePresentation();
control?.activeChange?.(next);
}
function observeWheel() {
wheelCaptured = false;
updatePresentation();
}
function handleWheel(event) {
if (!active || !control || !scene) return;
const vertical = Math.abs(event.deltaY) >= Math.abs(event.deltaX);
const rawDelta = vertical ? event.deltaY : event.deltaX;
if (!rawDelta) return;
const anchor = surface.clientToScene?.(scene, event.clientX, event.clientY);
if (!anchor || !containsPoint(control.bounds, anchor.x, anchor.y)) return;
event.preventDefault();
wheelCaptured = true;
const action = vertical ? "zoom" : "pan";
if (wheelAction && wheelAction !== action) commitWheel();
if (!wheelOrigin || !wheelValue) {
wheelOrigin = cloneWindow(control.window);
wheelValue = cloneWindow(control.window);
wheelAction = action;
}
const delta = normalizedWheelDelta(event, vertical ? "y" : "x");
const next = vertical ? zoomWindow(control, wheelValue, anchor.x, 2 ** (delta / 240)) : panWindow(control, wheelValue, delta / 880 * control.bounds.width);
lastAction = action;
updatePresentation();
if (!sameWindow(control.axis, next, wheelValue)) {
wheelValue = cloneWindow(next);
emit(next, {
type: "preview",
value: next,
origin: wheelOrigin,
source: "wheel",
action
});
}
if (!wheelOrigin || !wheelValue || !wheelAction) return;
if (wheelTimer) clearTimeout(wheelTimer);
wheelTimer = setTimeout(commitWheel, 150);
}
function commitWheel() {
if (!wheelOrigin || !wheelValue || !wheelAction) {
clearWheel();
return;
}
const origin = wheelOrigin;
const value = wheelValue;
const action = wheelAction;
clearWheel();
emit(value, {
type: "commit",
value,
origin,
source: "wheel",
action
});
}
function cancelWheel(source) {
if (!wheelOrigin || !wheelAction) {
clearWheel();
return;
}
const origin = wheelOrigin;
const action = wheelAction;
clearWheel();
emit(origin, {
type: "cancel",
value: origin,
origin,
source,
action
});
}
function clearWheel() {
if (wheelTimer) clearTimeout(wheelTimer);
wheelTimer = void 0;
wheelOrigin = void 0;
wheelValue = void 0;
wheelAction = void 0;
}
function handleD3Start(event) {
if (syncing || destroying || !control || !event.sourceEvent) return;
gestureActive = true;
gestureChanged = false;
gestureCancelled = false;
cancelEmitted = false;
gestureResyncWindow = void 0;
gestureControl = control;
gestureOrigin = cloneWindow(control.window);
gestureValue = cloneWindow(control.window);
gestureSource = isTouchEvent(event.sourceEvent) ? "touch" : "pointer";
gestureAction = touchCount(event.sourceEvent) > 1 ? "zoom" : "pan";
activeView = eventView(event.sourceEvent);
}
function handleD3Zoom(event) {
if (syncing || destroying || gestureCancelled || !gestureActive || !control || !gestureOrigin) {
return;
}
if (touchCount(event.sourceEvent) > 1) gestureAction = "zoom";
const frame = gestureControl ?? control;
const next = windowFromTransform(frame, event.transform);
if (gestureValue && sameWindow(frame.axis, next, gestureValue)) return;
gestureValue = cloneWindow(next);
gestureChanged = true;
lastAction = gestureAction;
updatePresentation();
emit(next, {
type: "preview",
value: next,
origin: gestureOrigin,
source: gestureSource,
action: gestureAction
});
}
function handleD3End(event) {
if (syncing || destroying || !gestureActive || !control) return;
if (event.sourceEvent?.type === "touchcancel") {
gestureCancelled = true;
}
if (gestureCancelled) {
if (!cancelEmitted) emitGestureCancel(gestureSource);
const resyncWindow = gestureResyncWindow ?? gestureOrigin;
configureBehavior(control);
if (resyncWindow) syncTransform(resyncWindow);
resetGesture();
return;
}
if (gestureChanged && gestureOrigin && gestureValue) {
const origin = gestureOrigin;
const value = gestureValue;
const source = gestureSource;
const action = gestureAction;
configureBehavior(control);
syncTransform(control.window);
resetGesture();
emit(value, {
type: "commit",
value,
origin,
source,
action
});
return;
}
configureBehavior(control);
syncTransform(control.window);
resetGesture();
}
function cancelGesture(source) {
if (!gestureActive || !gestureOrigin || gestureCancelled) return;
gestureCancelled = true;
gestureResyncWindow = void 0;
syncTransform(gestureOrigin);
emitGestureCancel(source);
if (gestureSource === "pointer") endMouseGesture();
}
function abortGesture(window) {
gestureCancelled = true;
cancelEmitted = true;
gestureValue = void 0;
gestureResyncWindow = cloneWindow(window);
}
function emitGestureCancel(source) {
if (!gestureOrigin || cancelEmitted) return;
cancelEmitted = true;
const origin = gestureOrigin;
emit(origin, {
type: "cancel",
value: origin,
origin,
source,
action: gestureAction
});
}
function endMouseGesture() {
if (!activeView) return;
const end = select(activeView).on("mouseup.zoom");
if (!end) return;
const event = new activeView.MouseEvent("mouseup", {
bubbles: true,
cancelable: true
});
Object.defineProperty(event, "view", { value: activeView });
end.call(activeView, event);
}
function resetGesture() {
gestureControl = void 0;
gestureOrigin = void 0;
gestureValue = void 0;
gestureAction = "pan";
gestureSource = "pointer";
gestureActive = false;
gestureChanged = false;
gestureCancelled = false;
cancelEmitted = false;
gestureResyncWindow = void 0;
activeView = void 0;
}
function handleKeyDown(event) {
if (event.key === "Escape") {
if (gestureActive) {
event.preventDefault();
event.stopPropagation();
cancelGesture("keyboard");
} else if (wheelTimer) {
event.preventDefault();
event.stopPropagation();
cancelWheel("keyboard");
}
return;
}
if (!control?.keyboard) return;
let next;
let action;
if (event.key === "+" || event.key === "=") {
next = zoomWindow(
control,
control.window,
control.bounds.x + control.bounds.width / 2,
0.5
);
action = "zoom";
} else if (event.key === "-") {
next = zoomWindow(
control,
control.window,
control.bounds.x + control.bounds.width / 2,
2
);
action = "zoom";
} else if (event.key === "ArrowLeft") {
next = panWindow(control, control.window, -control.bounds.width * 0.125);
action = "pan";
} else if (event.key === "ArrowRight") {
next = panWindow(control, control.window, control.bounds.width * 0.125);
action = "pan";
} else if (event.key === "Home") {
next = cloneWindow(control.extent);
action = "reset";
}
if (!next || !action) return;
event.preventDefault();
event.stopPropagation();
lastAction = action;
updatePresentation();
if (sameWindow(control.axis, next, control.window)) return;
const origin = cloneWindow(control.window);
emit(next, {
type: "commit",
value: next,
origin,
source: "keyboard",
action
});
}
function syncTransform(window) {
if (!control) return;
syncing = true;
selection.call(behavior.transform, transformForWindow(control, window));
syncing = false;
}
function configureBehavior(frame) {
behavior.extent([
[frame.bounds.x, frame.bounds.y],
[
frame.bounds.x + frame.bounds.width,
frame.bounds.y + frame.bounds.height
]
]).translateExtent([
[frame.bounds.x, frame.bounds.y],
[
frame.bounds.x + frame.bounds.width,
frame.bounds.y + frame.bounds.height
]
]).scaleExtent([frame.scaleExtent[0], frame.scaleExtent[1]]);
selection.call(behavior);
selection.on("dblclick.zoom", null);
}
function emit(value, reason) {
control?.change(value, reason);
}
function updatePresentation() {
hitTarget.dataset.zoomActive = String(active);
hitTarget.dataset.zoomWheelCaptured = String(wheelCaptured);
hitTarget.dataset.zoomLastAction = lastAction;
hitTarget.style.touchAction = active ? "none" : "pan-y";
hitTarget.setAttribute("stroke", active ? "currentColor" : "transparent");
hitTarget.setAttribute("stroke-width", "3");
hitTarget.setAttribute("stroke-dasharray", "6 4");
}
}
function asZoomXControl(control) {
if (!("kind" in control) || control.kind !== "zoom-x") {
throw new TypeError("Expected a horizontal zoom control");
}
return control;
}
function windowFromTransform(control, nextTransform) {
const currentTransform = transformForWindow(control, control.window);
const left = control.bounds.x;
const right = left + control.bounds.width;
return constrainPositions(
control.axis,
control.extent,
control.scaleExtent,
currentTransform.applyX(nextTransform.invertX(left)),
currentTransform.applyX(nextTransform.invertX(right))
);
}
function transformForWindow(control, window) {
const left = control.bounds.x;
const right = left + control.bounds.width;
const extentStart = control.axis.position(control.extent.start);
const extentEnd = control.axis.position(control.extent.end);
const baseStart = extentEnd > extentStart ? left : right;
const fullScale = Math.abs(extentEnd - extentStart) / control.bounds.width;
const fullTranslate = extentStart - fullScale * baseStart;
const current = zoomIdentity.translate(fullTranslate, 0).scale(fullScale);
const windowStart = current.invertX(control.axis.position(window.start));
const windowEnd = current.invertX(control.axis.position(window.end));
const outputStart = windowEnd > windowStart ? left : right;
const outputEnd = windowEnd > windowStart ? right : left;
const scale = (outputEnd - outputStart) / (windowEnd - windowStart);
const translate = outputStart - scale * windowStart;
return zoomIdentity.translate(translate, 0).scale(scale);
}
function zoomWindow(control, window, anchor, factor) {
const start = control.axis.position(window.start);
const end = control.axis.position(window.end);
return constrainPositions(
control.axis,
control.extent,
control.scaleExtent,
anchor + (start - anchor) * factor,
anchor + (end - anchor) * factor
);
}
function panWindow(control, window, delta) {
return constrainPositions(
control.axis,
control.extent,
control.scaleExtent,
control.axis.position(window.start) + delta,
control.axis.position(window.end) + delta
);
}
function constrainWindow(axis, window, extent, scaleExtent) {
const constrained = constrainPositions(
axis,
extent,
scaleExtent,
axis.position(window.start),
axis.position(window.end)
);
return sameMappedWindow(axis, constrained, window) ? cloneWindow(window) : constrained;
}
function constrainPositions(axis, extent, scaleExtent, first, second) {
const extentStart = axis.position(extent.start);
const extentEnd = axis.position(extent.end);
const direction = Math.sign(extentEnd - extentStart);
const fullSpan = Math.abs(extentEnd - extentStart);
if (!direction || !Number.isFinite(fullSpan)) {
throw new TypeError("zoomX extent must map to a finite non-zero span");
}
let start = direction * (first - extentStart);
let end = direction * (second - extentStart);
if (start > end) [start, end] = [end, start];
const minimumSpan = Number.isFinite(scaleExtent[1]) ? fullSpan / scaleExtent[1] : 0;
const maximumSpan = fullSpan / scaleExtent[0];
let span = Math.max(minimumSpan, Math.min(maximumSpan, end - start));
if (!Number.isFinite(span) || span <= 0) {
throw new TypeError("zoomX window must map to a finite non-zero span");
}
const center = (start + end) / 2;
start = center - span / 2;
end = center + span / 2;
if (start < 0) {
end -= start;
start = 0;
}
if (end > fullSpan) {
start -= end - fullSpan;
end = fullSpan;
}
start = Math.max(0, start);
end = Math.min(fullSpan, end);
span = end - start;
if (span <= 0) {
throw new TypeError("zoomX window must overlap its extent");
}
return normalizeInteractionRange(axis, {
start: axis.invert(extentStart + direction * start),
end: axis.invert(extentStart + direction * end)
});
}
function resolveScaleExtent(input) {
const minimum = input?.[0] ?? 1;
const maximum = input?.[1] ?? Number.POSITIVE_INFINITY;
if (minimum !== 1) {
throw new TypeError("zoomX scaleExtent must start at 1");
}
if (!(Number.isFinite(maximum) || maximum === Number.POSITIVE_INFINITY) || maximum < minimum) {
throw new TypeError(
"zoomX scaleExtent maximum must be at least 1 or Infinity"
);
}
return [minimum, maximum];
}
function assertWindowKinds(window, extent) {
const values = [window.start, window.end, extent[0], extent[1]];
const kind = valueKind(values[0]);
for (const value of values) {
if (!validValue(value) || valueKind(value) !== kind) {
throw new TypeError(
"zoomX window and extent must use one finite numeric or temporal value type"
);
}
}
}
function validValue(value) {
return value instanceof Date ? Number.isFinite(value.getTime()) : typeof value === "number" && Number.isFinite(value);
}
function valueKind(value) {
return value instanceof Date ? "date" : typeof value;
}
function sameInteractionValue(axis, left, right) {
return axis.layoutKey(left) === axis.layoutKey(right);
}
function sameWindow(axis, left, right) {
return sameInteractionRange(axis, left, right);
}
function sameMappedWindow(axis, left, right) {
return Math.abs(axis.position(left.start) - axis.position(right.start)) < 1e-6 && Math.abs(axis.position(left.end) - axis.position(right.end)) < 1e-6;
}
function cloneWindow(window) {
return cloneInteractionRange(window);
}
function cloneChange(change) {
return {
...change,
value: cloneWindow(change.value),
origin: cloneWindow(change.origin)
};
}
function defaultDescription(control) {
const pointerInstructions = `${control.format(control.window.start)} to ${control.format(control.window.end)}. Focus before wheel zoom. Drag or use a horizontal wheel to pan.`;
return control.keyboard ? `${pointerInstructions} Use plus, minus, arrow keys, or Home.` : pointerInstructions;
}
function defaultFormat(value) {
return value instanceof Date ? value.toLocaleDateString() : String(value);
}
function normalizedWheelDelta(event, axis) {
const value = axis === "x" ? event.deltaX : event.deltaY;
if (event.deltaMode === 1) return value * 16;
if (event.deltaMode === 2) return value * 240;
return value;
}
function isTouchEvent(event) {
return Boolean(event && typeof event === "object" && "touches" in event);
}
function touchCount(event) {
if (!event || typeof event !== "object" || !("touches" in event)) return 0;
return Number(event.touches?.length ?? 0);
}
function eventView(source) {
if (!source || typeof source !== "object" || !("view" in source)) return;
const view = source.view;
return view && typeof view === "object" && "document" in view ? view : void 0;
}
function containsPoint(bounds, x, y) {
return x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height;
}
export {
zoomX
};