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.

480 lines (479 loc) 15.5 kB
import { createGuideNodes } from "./guide-nodes-internal.js"; import { createInteractionAxis } from "./interaction-axis-internal.js"; const defaultId = "continuous-cursor"; const classPrefix = "ts-chart__continuous-cursor"; function continuousCursor(options) { const id = options.id?.trim() || defaultId; if (options.id !== void 0 && !options.id.trim()) { throw new TypeError("continuousCursor id cannot be empty"); } return { id, resolve(context) { const input = options.position.value; const xAxis = cursorAxis( "x", context.scales.x, [context.chart.x, context.chart.x + context.chart.width], input?.x ); const yAxis = cursorAxis( "y", context.scales.y, [context.chart.y, context.chart.y + context.chart.height], input?.y ); const position = input ? normalizePosition(input, xAxis, yAxis) : null; const guide = resolveGuide(options, context); const fallbackKey = `behavior:${id}:fallback`; const control = { kind: "continuous-cursor", key: id, id, extension: continuousCursorControlExtension, fallbackNodeKey: fallbackKey, bounds: context.chart, width: context.width, height: context.height, xAxis, yAxis, position, guide, change(value, reason) { options.position.onChange(clonePosition(value), { reason: cloneChange(reason) }); } }; return { nodes: [renderFallback(control, fallbackKey)], controls: [control] }; } }; } function cursorAxis(axis, scale, extent, current) { return createInteractionAxis({ axis, scale, extent, sample: current ?? scaleSample(scale, axis) }); } function scaleSample(scale, axis) { const sample = scale?.domain.find(isContinuousCursorValue); if (sample === void 0) { throw new TypeError( `continuousCursor requires a finite numeric or temporal ${axis} scale domain` ); } return cloneValue(sample); } function resolveGuide(options, context) { const rule = (input) => input === false ? false : { style: { stroke: input?.stroke ?? context.theme.foreground, strokeOpacity: finiteNonNegative(input?.strokeOpacity, 0.48), strokeWidth: finiteNonNegative(input?.strokeWidth, 1), strokeDasharray: input?.strokeDasharray ?? "4 4", lineCap: input?.lineCap } }; const marker = options.marker === false ? false : { radius: finiteNonNegative(options.marker?.radius, 5), style: { fill: options.marker?.fill ?? context.theme.background, fillOpacity: finiteNonNegative(options.marker?.fillOpacity, 1), stroke: options.marker?.stroke ?? context.theme.foreground, strokeOpacity: finiteNonNegative(options.marker?.strokeOpacity, 1), strokeWidth: finiteNonNegative(options.marker?.strokeWidth, 1.5) } }; return { xRule: rule(options.xRule), yRule: rule(options.yRule), marker, xLabel: resolveLabel(options.xLabel, context), yLabel: resolveLabel(options.yLabel, context) }; } function resolveLabel(input, context) { if (input === false || input === void 0) return false; return { format: input.format ?? defaultFormat, side: input.side, offset: input.offset, paddingX: input.paddingX, paddingY: input.paddingY, radius: input.radius, fontSize: input.fontSize, fontWeight: input.fontWeight, style: { fill: input.color ?? context.theme.background }, boxStyle: { fill: input.background ?? context.theme.foreground, stroke: input.stroke ?? context.theme.background, strokeWidth: finiteNonNegative(input.strokeWidth, 1) } }; } function renderFallback(control, key) { return { kind: "group", key, className: `${classPrefix}-fallback`, ariaHidden: true, children: control.position ? guideNodes(control, control.position) : [] }; } function guideNodes(control, position) { const x = control.xAxis.position(position.x); const y = control.yAxis.position(position.y); return createGuideNodes({ id: control.id, classPrefix, chart: control.bounds, x, y, xRule: control.guide.xRule, yRule: control.guide.yRule, marker: control.guide.marker, xLabel: control.guide.xLabel === false ? false : { ...control.guide.xLabel, text: control.guide.xLabel.format(position.x) }, yLabel: control.guide.yLabel === false ? false : { ...control.guide.yLabel, text: control.guide.yLabel.format(position.y) } }).nodes; } const continuousCursorControlExtension = { id: "continuous-cursor", create: createContinuousCursorControl }; function createContinuousCursorControl({ container, surface }) { const namespace = "http://www.w3.org/2000/svg"; const root = container.ownerDocument.createElementNS(namespace, "svg"); const hitTarget = container.ownerDocument.createElementNS(namespace, "rect"); const layer = container.ownerDocument.createElementNS(namespace, "g"); root.append(hitTarget, layer); root.setAttribute("aria-hidden", "true"); Object.assign(root.style, { position: "absolute", inset: "0", zIndex: "1", width: "100%", height: "100%", overflow: "visible", pointerEvents: "auto", touchAction: "none" }); hitTarget.setAttribute("fill", "transparent"); hitTarget.setAttribute("pointer-events", "all"); layer.style.pointerEvents = "none"; let control; let scene; let transient = null; let pinned = false; let awaitingControl = false; let lastSource = "pointer"; root.addEventListener("pointermove", handlePointerMove); root.addEventListener("pointerdown", handlePointerDown); root.addEventListener("pointerleave", handlePointerLeave); root.addEventListener("pointercancel", handlePointerCancel); root.addEventListener("click", handleClick); container.addEventListener("keydown", handleKeyDown, true); return { update(nextControl, nextScene) { const previous = control; const next = asContinuousCursorControl(nextControl); control = next; scene = nextScene; root.dataset.chartCursor = next.id; root.setAttribute("viewBox", `0 0 ${next.width} ${next.height}`); root.setAttribute("width", "100%"); root.setAttribute("height", "100%"); hitTarget.setAttribute("x", "0"); hitTarget.setAttribute("y", "0"); hitTarget.setAttribute("width", String(next.width)); hitTarget.setAttribute("height", String(next.height)); if (!root.isConnected || root.parentElement !== container) { container.append(root); } if (awaitingControl) { awaitingControl = false; transient = clonePosition(next.position); pinned = next.position !== null; } else if (next.position) { transient = clonePosition(next.position); pinned = true; } else if (previous?.position) { transient = null; pinned = false; } else if (transient) { transient = normalizePosition(transient, next.xAxis, next.yAxis); } paint(transient); }, contains(target) { return Boolean(target && root.contains(target)); }, destroy() { root.removeEventListener("pointermove", handlePointerMove); root.removeEventListener("pointerdown", handlePointerDown); root.removeEventListener("pointerleave", handlePointerLeave); root.removeEventListener("pointercancel", handlePointerCancel); root.removeEventListener("click", handleClick); container.removeEventListener("keydown", handleKeyDown, true); root.remove(); control = void 0; scene = void 0; transient = null; pinned = false; awaitingControl = false; } }; function handlePointerMove(event) { preview(event, "move"); } function handlePointerDown(event) { lastSource = pointerSource(event); if (isPinned()) return; preview(event, "move"); } function preview(event, cause) { lastSource = pointerSource(event); if (!control || isPinned()) return; awaitingControl = false; const position = positionFromClient(event.clientX, event.clientY); transient = position; paint(position); emit(position, { type: "preview", value: clonePosition(position), origin: clonePosition(control.position), source: lastSource, cause }); } function handlePointerLeave(event) { clearPreview(pointerSource(event), "leave"); } function handlePointerCancel(event) { clearPreview(pointerSource(event), "cancel"); } function clearPreview(source, cause) { if (!control || isPinned() || transient === null) return; transient = null; paint(null); emit(null, { type: "preview", value: null, origin: clonePosition(control.position), source, cause }); } function handleClick(event) { if (!control) return; const origin = pinnedPosition(); if (origin) { propose(null, { type: "clear", value: null, origin: clonePosition(origin), source: lastSource, cause: "toggle" }); return; } const next = transient ?? positionFromClient(event.clientX, event.clientY); if (!next) return; propose(next, { type: "commit", value: clonePosition(next), origin: null, source: lastSource, cause: "pin" }); } function handleKeyDown(event) { if (event.key !== "Escape" || !control || transient === null) return; event.preventDefault(); event.stopPropagation(); propose(null, { type: "clear", value: null, origin: clonePosition(pinnedPosition()), source: "keyboard", cause: "escape" }); } function propose(next, reason) { if (!control) return; const changed = control; awaitingControl = true; transient = clonePosition(next); pinned = next !== null; paint(transient); changed.change(next, reason); awaitingControl = false; const accepted = clonePosition(control?.position ?? null); transient = accepted; pinned = accepted !== null; paint(accepted); } function emit(next, reason) { control?.change(next, reason); } function isPinned() { return pinned; } function pinnedPosition() { return isPinned() ? transient : null; } function positionFromClient(clientX, clientY) { if (!control || !scene) return null; const point = surface.clientToScene?.(scene, clientX, clientY); if (!point || !containsPoint(control.bounds, point.x, point.y)) return null; return { x: control.xAxis.valueAt(point.x), y: control.yAxis.valueAt(point.y) }; } function paint(position) { if (!control || !position) { root.dataset.visible = "false"; root.dataset.pinned = "false"; layer.replaceChildren(); return; } root.dataset.visible = "true"; root.dataset.pinned = String(isPinned()); syncGuideElements(layer, guideNodes(control, position)); } } function asContinuousCursorControl(control) { if (!("kind" in control) || control.kind !== "continuous-cursor") { throw new TypeError("Expected a continuous cursor control"); } return control; } function syncGuideElements(layer, nodes) { const retained = /* @__PURE__ */ new Set(); const current = new Map( [...layer.children].flatMap((element) => { const key = element.dataset.guideKey; return key ? [[key, element]] : []; }) ); for (const node of nodes) { const tag = nodeTag(node); if (!tag) continue; let element = current.get(node.key); if (!element || element.localName !== tag) { element = layer.ownerDocument.createElementNS( "http://www.w3.org/2000/svg", tag ); element.dataset.guideKey = node.key; } updateGuideElement(element, node); layer.append(element); retained.add(element); } for (const element of [...layer.children]) { if (!retained.has(element)) element.remove(); } } function nodeTag(node) { if (node.kind === "rule") return "line"; if (node.kind === "dot") return "circle"; if (node.kind === "rect") return "rect"; if (node.kind === "label") return "text"; return null; } function updateGuideElement(element, node) { element.setAttribute("class", node.className ?? ""); syncStyle(element, node.style); if (node.kind === "rule") { setAttribute(element, "x1", node.x1); setAttribute(element, "y1", node.y1); setAttribute(element, "x2", node.x2); setAttribute(element, "y2", node.y2); } else if (node.kind === "dot") { setAttribute(element, "cx", node.x); setAttribute(element, "cy", node.y); setAttribute(element, "r", node.radius); } else if (node.kind === "rect") { setAttribute(element, "x", node.x); setAttribute(element, "y", node.y); setAttribute(element, "width", node.width); setAttribute(element, "height", node.height); setAttribute(element, "rx", node.radius); } else if (node.kind === "label") { setAttribute(element, "x", node.x); setAttribute(element, "y", node.y); setAttribute(element, "text-anchor", node.anchor); setAttribute(element, "dominant-baseline", node.baseline); setAttribute(element, "font-size", node.fontSize); setAttribute(element, "font-weight", node.fontWeight); element.setAttribute("font-family", "inherit"); element.textContent = node.text; } } function syncStyle(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 normalizePosition(position, xAxis, yAxis) { return { x: xAxis.valueAt(xAxis.position(position.x)), y: yAxis.valueAt(yAxis.position(position.y)) }; } function containsPoint(bounds, x, y) { return x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height; } function pointerSource(event) { return event.pointerType === "touch" ? "touch" : "pointer"; } function isContinuousCursorValue(value) { return value instanceof Date ? Number.isFinite(value.getTime()) : typeof value === "number" && Number.isFinite(value); } function clonePosition(position) { return position ? { x: cloneValue(position.x), y: cloneValue(position.y) } : null; } function cloneChange(change) { return { ...change, value: clonePosition(change.value), origin: clonePosition(change.origin) }; } function cloneValue(value) { return value instanceof Date ? new Date(value.getTime()) : value; } function defaultFormat(value) { return value instanceof Date ? value.toLocaleString() : String(value); } function finiteNonNegative(value, fallback) { return value !== void 0 && Number.isFinite(value) && value >= 0 ? value : fallback; } export { continuousCursor };