UNPKG

@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.

514 lines (513 loc) 17.8 kB
import { createInteractionAxis } from "./interaction-axis-internal.js"; import { cloneInteractionValue } from "./interaction-range-internal.js"; const defaultId = "handle-x"; const classPrefix = "ts-chart__handle-x"; const handleRadius = 9; const trackThickness = 4; const ruleThickness = 2; function handleX(options) { const id = options.id?.trim() || defaultId; if (options.id !== void 0 && !options.id.trim()) { throw new TypeError("handleX id cannot be empty"); } const hitSize = finitePositive(options.hitSize ?? 44, "hitSize"); return { id, resolve(context) { const axis = createInteractionAxis({ axis: "x", scale: context.scales.x, extent: [context.chart.x, context.chart.x + context.chart.width], sample: options.value.value, values: options.values }); const valueIndex = axis.indexOf(options.value.value); if (valueIndex < 0) { throw new TypeError( "handleX controlled value must be one of its explicit values" ); } const value = axis.at(valueIndex); const cross = resolveCross(options.cross, context); const color = context.theme.palette[0] ?? context.theme.foreground; const trackStyle = { fill: context.theme.foreground, fillOpacity: 0.48, ...options.trackStyle }; const ruleStyle = options.ruleStyle === false ? false : { fill: color, ...options.ruleStyle }; const handleStyle = { fill: color, stroke: context.theme.background, strokeWidth: 2, ...options.handleStyle }; const fallbackKey = `behavior:${id}:fallback`; const control = { kind: "handle-x", key: id, id, extension: handleXControlExtension, fallbackNodeKey: fallbackKey, bounds: context.chart, width: context.width, height: context.height, axis, value, cross: cross.position, ruleStart: cross.ruleStart, trackStart: axis.position(axis.at(0)), trackEnd: axis.position(axis.at(options.values.length - 1)), trackStyle, ruleStyle, handleStyle, hitSize, ariaLabel: options.ariaLabel ?? "Horizontal value", format: options.format ?? defaultFormat, keyboard: options.keyboard !== false, change(next, reason) { options.value.onChange(cloneInteractionValue(next), { reason: cloneChange(reason) }); } }; return { nodes: [renderFallback(control, fallbackKey)], controls: [control] }; } }; } function resolveCross(cross, context) { if (!cross || typeof cross !== "object") { throw new TypeError("handleX cross is required"); } if ("value" in cross) { const value = cross.value; if (value === void 0) { throw new TypeError("handleX semantic cross value is required"); } const axis = createInteractionAxis({ axis: "y", scale: context.scales.y, extent: [context.chart.y, context.chart.y + context.chart.height], sample: value, values: [value] }); return { position: axis.position(axis.at(0)), ruleStart: context.chart.y }; } if (cross.edge !== "top" && cross.edge !== "bottom") { throw new TypeError('handleX cross edge must be "top" or "bottom"'); } const offset = finite(cross.offset ?? 0, "cross offset"); return cross.edge === "top" ? { position: context.chart.y - offset, ruleStart: context.chart.y + context.chart.height } : { position: context.chart.y + context.chart.height + offset, ruleStart: context.chart.y }; } function renderFallback(control, key) { const x = control.axis.position(control.value); const children = []; if (control.ruleStyle !== false) { const ruleTop = Math.min(control.ruleStart, control.cross); children.push({ kind: "rect", key: `${key}:rule`, className: `${classPrefix}-rule`, x: x - ruleThickness / 2, y: ruleTop, width: ruleThickness, height: Math.abs(control.cross - control.ruleStart), style: control.ruleStyle }); } const trackLeft = Math.min(control.trackStart, control.trackEnd); children.push( { kind: "rect", key: `${key}:track`, className: `${classPrefix}-track`, x: trackLeft, y: control.cross - trackThickness / 2, width: Math.abs(control.trackEnd - control.trackStart), height: trackThickness, style: control.trackStyle }, { kind: "dot", key: `${key}:handle`, className: `${classPrefix}-handle`, x, y: control.cross, radius: handleRadius, style: control.handleStyle } ); return { kind: "group", key, className: `${classPrefix}-fallback`, ariaHidden: true, children }; } const handleXControlExtension = { id: "handle-x", create: createHandleXControl }; function createHandleXControl({ container, surface }) { const namespace = "http://www.w3.org/2000/svg"; const document = container.ownerDocument; const root = document.createElementNS(namespace, "svg"); const rule = document.createElementNS(namespace, "rect"); const track = document.createElementNS(namespace, "rect"); const handle = document.createElementNS(namespace, "circle"); const focusRing = document.createElementNS(namespace, "circle"); const hitTarget = document.createElementNS(namespace, "rect"); root.append(rule, track, handle, focusRing, hitTarget); Object.assign(root.style, { position: "absolute", inset: "0", zIndex: "1", width: "100%", height: "100%", overflow: "visible", pointerEvents: "none" }); for (const element of [rule, track, handle, focusRing]) { element.setAttribute("pointer-events", "none"); } focusRing.setAttribute("fill", "none"); focusRing.setAttribute("stroke", "currentColor"); focusRing.setAttribute("stroke-width", "2"); focusRing.setAttribute("r", String(handleRadius + 4)); focusRing.style.display = "none"; hitTarget.setAttribute("fill", "transparent"); hitTarget.setAttribute("pointer-events", "all"); hitTarget.setAttribute("role", "slider"); hitTarget.setAttribute("aria-orientation", "horizontal"); hitTarget.style.cursor = "ew-resize"; hitTarget.style.outline = "none"; hitTarget.style.touchAction = "none"; let control; let scene; let activePointer; let gestureOrigin; let gestureValue; let gestureSource = "pointer"; hitTarget.addEventListener("pointerdown", handlePointerDown); hitTarget.addEventListener("pointermove", handlePointerMove); hitTarget.addEventListener("pointerup", handlePointerUp); hitTarget.addEventListener("pointercancel", handlePointerCancel); hitTarget.addEventListener("keydown", handleKeyDown); hitTarget.addEventListener("focus", handleFocus); hitTarget.addEventListener("blur", handleBlur); return { update(nextControl, nextScene) { const next = asHandleXControl(nextControl); const preserveGesture = Boolean( activePointer !== void 0 && gestureValue !== void 0 && sameValue(next.axis, next.value, gestureValue) ); control = next; scene = nextScene; if (activePointer !== void 0 && !preserveGesture) finishGesture(true); root.dataset.chartHandleRoot = next.id; root.setAttribute("viewBox", `0 0 ${next.width} ${next.height}`); root.setAttribute("width", "100%"); root.setAttribute("height", "100%"); track.dataset.chartHandleTrack = next.id; track.dataset.chartHandleRole = "track"; handle.dataset.chartHandle = next.id; handle.dataset.chartHandleRole = "handle"; hitTarget.dataset.chartHandleSurface = next.id; hitTarget.setAttribute("aria-label", next.ariaLabel); hitTarget.setAttribute("tabindex", next.keyboard ? "0" : "-1"); if (next.keyboard) { hitTarget.setAttribute( "aria-keyshortcuts", "ArrowLeft ArrowRight ArrowUp ArrowDown Home End Escape" ); } else { hitTarget.removeAttribute("aria-keyshortcuts"); } syncGeometry(next); if (!root.isConnected || root.parentElement !== container) { container.append(root); } paint(preserveGesture ? gestureValue : next.value); }, contains(target) { return Boolean(target && root.contains(target)); }, destroy() { finishGesture(true); hitTarget.removeEventListener("pointerdown", handlePointerDown); hitTarget.removeEventListener("pointermove", handlePointerMove); hitTarget.removeEventListener("pointerup", handlePointerUp); hitTarget.removeEventListener("pointercancel", handlePointerCancel); hitTarget.removeEventListener("keydown", handleKeyDown); hitTarget.removeEventListener("focus", handleFocus); hitTarget.removeEventListener("blur", handleBlur); root.remove(); control = void 0; scene = void 0; } }; function syncGeometry(next) { const trackLeft = Math.min(next.trackStart, next.trackEnd); setAttribute(track, "x", trackLeft); setAttribute(track, "y", next.cross - trackThickness / 2); setAttribute(track, "width", Math.abs(next.trackEnd - next.trackStart)); setAttribute(track, "height", trackThickness); applyStyle(track, next.trackStyle); if (next.ruleStyle === false) { rule.remove(); rule.removeAttribute("data-chart-handle-rule"); rule.removeAttribute("data-chart-handle-role"); } else { root.insertBefore(rule, track); rule.dataset.chartHandleRule = next.id; rule.dataset.chartHandleRole = "rule"; setAttribute(rule, "y", Math.min(next.ruleStart, next.cross)); setAttribute(rule, "width", ruleThickness); setAttribute(rule, "height", Math.abs(next.cross - next.ruleStart)); applyStyle(rule, next.ruleStyle); } applyStyle(handle, next.handleStyle); setAttribute(handle, "r", handleRadius); const minimum = Math.min(next.trackStart, next.trackEnd); const maximum = Math.max(next.trackStart, next.trackEnd); setAttribute(hitTarget, "x", minimum - next.hitSize / 2); setAttribute(hitTarget, "y", next.cross - next.hitSize / 2); setAttribute(hitTarget, "width", maximum - minimum + next.hitSize); setAttribute(hitTarget, "height", next.hitSize); } function paint(value) { if (!control) return; const x = control.axis.position(value); setAttribute(handle, "cx", x); setAttribute(handle, "cy", control.cross); setAttribute(focusRing, "cx", x); setAttribute(focusRing, "cy", control.cross); if (control.ruleStyle !== false) { setAttribute(rule, "x", x - ruleThickness / 2); } const index = control.axis.indexOf(value); hitTarget.setAttribute("aria-valuemin", "0"); hitTarget.setAttribute( "aria-valuemax", String((control.axis.values?.length ?? 1) - 1) ); hitTarget.setAttribute("aria-valuenow", String(index)); hitTarget.setAttribute("aria-valuetext", control.format(value)); } function handlePointerDown(event) { if (!control || activePointer !== void 0 || event.isPrimary === false || event.pointerType !== "touch" && event.button !== 0) { return; } const next = valueFromClient(event.clientX, event.clientY); if (next === void 0) return; event.preventDefault(); event.stopPropagation(); hitTarget.focus({ preventScroll: true }); activePointer = event.pointerId; gestureOrigin = cloneInteractionValue(control.value); gestureValue = cloneInteractionValue(control.value); gestureSource = pointerSource(event); capturePointer(event.pointerId); preview(next); } function handlePointerMove(event) { if (event.pointerId !== activePointer) return; const next = valueFromClient(event.clientX, event.clientY); if (next === void 0) return; event.preventDefault(); preview(next); } function handlePointerUp(event) { if (event.pointerId !== activePointer) return; event.preventDefault(); event.stopPropagation(); const next = valueFromClient(event.clientX, event.clientY); if (next !== void 0) preview(next); if (event.pointerId !== activePointer || !control || gestureOrigin === void 0 || gestureValue === void 0) { return; } const origin = cloneInteractionValue(gestureOrigin); const value = cloneInteractionValue(gestureValue); const source = gestureSource; const changedControl = control; finishGesture(true); changedControl.change(value, { type: "commit", value, origin, source }); paintControlledValue(); } function handlePointerCancel(event) { if (event.pointerId !== activePointer) return; event.preventDefault(); cancel(gestureSource); } function handleKeyDown(event) { if (event.key === "Escape" && activePointer !== void 0) { event.preventDefault(); event.stopPropagation(); cancel("keyboard"); return; } if (!control?.keyboard || activePointer !== void 0) return; const currentIndex = control.axis.indexOf(control.value); let nextIndex; if (event.key === "Home") nextIndex = 0; else if (event.key === "End") { nextIndex = (control.axis.values?.length ?? 1) - 1; } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") { nextIndex = currentIndex - 1; } else if (event.key === "ArrowRight" || event.key === "ArrowUp") { nextIndex = currentIndex + 1; } if (nextIndex === void 0) return; event.preventDefault(); event.stopPropagation(); const next = control.axis.at(nextIndex); if (sameValue(control.axis, next, control.value)) return; const origin = cloneInteractionValue(control.value); const changedControl = control; changedControl.change(next, { type: "commit", value: next, origin, source: "keyboard" }); paintControlledValue(); } function handleFocus() { focusRing.style.display = ""; } function handleBlur() { if (activePointer !== void 0) cancel(gestureSource); focusRing.style.display = "none"; } function preview(next) { if (!control || gestureOrigin === void 0 || gestureValue !== void 0 && sameValue(control.axis, next, gestureValue)) { return; } gestureValue = cloneInteractionValue(next); paint(gestureValue); const origin = cloneInteractionValue(gestureOrigin); const value = cloneInteractionValue(gestureValue); const changedControl = control; changedControl.change(value, { type: "preview", value, origin, source: gestureSource }); if (control !== changedControl) paint(control.value); } function cancel(source) { if (!control || gestureOrigin === void 0) return; const origin = cloneInteractionValue(gestureOrigin); const changedControl = control; finishGesture(true); changedControl.change(origin, { type: "cancel", value: origin, origin, source }); paintControlledValue(); } function paintControlledValue() { if (control) paint(control.value); } function valueFromClient(clientX, clientY) { if (!control || !scene) return void 0; const position = surface.clientToScene?.(scene, clientX, clientY); return position ? control.axis.valueAt(position.x) : void 0; } function capturePointer(pointerId) { if (typeof hitTarget.setPointerCapture !== "function") return; hitTarget.setPointerCapture(pointerId); } function finishGesture(release) { const pointer = activePointer; activePointer = void 0; gestureOrigin = void 0; gestureValue = void 0; gestureSource = "pointer"; if (release && pointer !== void 0 && typeof hitTarget.releasePointerCapture === "function") { if (typeof hitTarget.hasPointerCapture === "function" && !hitTarget.hasPointerCapture(pointer)) { return; } hitTarget.releasePointerCapture(pointer); } } } function asHandleXControl(control) { if (!("kind" in control) || control.kind !== "handle-x") { throw new TypeError("Expected a horizontal handle control"); } return control; } function pointerSource(event) { return event.pointerType === "touch" ? "touch" : "pointer"; } function sameValue(axis, left, right) { return axis.layoutKey(left) === axis.layoutKey(right); } function applyStyle(element, style) { setAttribute(element, "fill", style.fill); setAttribute(element, "fill-opacity", style.fillOpacity); setAttribute(element, "stroke", style.stroke); setAttribute(element, "stroke-opacity", style.strokeOpacity); setAttribute(element, "stroke-width", style.strokeWidth); setAttribute(element, "opacity", style.opacity); setAttribute(element, "stroke-linecap", style.lineCap); setAttribute(element, "stroke-linejoin", style.lineJoin); setAttribute(element, "stroke-dasharray", style.strokeDasharray); } function setAttribute(element, name, value) { if (value === void 0) element.removeAttribute(name); else element.setAttribute(name, String(value)); } function cloneChange(change) { return { ...change, value: cloneInteractionValue(change.value), origin: cloneInteractionValue(change.origin) }; } function defaultFormat(value) { return value instanceof Date ? value.toLocaleDateString() : String(value); } function finitePositive(value, name) { if (!Number.isFinite(value) || value <= 0) { throw new TypeError(`handleX ${name} must be a positive finite number`); } return value; } function finite(value, name) { if (!Number.isFinite(value)) { throw new TypeError(`handleX ${name} must be finite`); } return value; } export { handleX };