// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ Copyright (c) 2017, the Perspective Authors. ┃ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ // ┃ This file is part of the Perspective library, distributed under the terms ┃ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { Canvas2D, Context2D } from "../charts/canvas-types"; import type { PlotLayout } from "../layout/plot-layout"; import type { Theme } from "../theme/theme"; /** * Minimal positioning input — PlotLayout satisfies this. */ export interface CssBounds { cssWidth: number; cssHeight: number; } export interface TooltipCallbacks { /** * RAF-throttled mouse position in CSS pixels, relative to the GL * canvas (host already subtracted `getBoundingClientRect`). */ onHover(mx: number, my: number): void; /** * Fires on mouseleave; skipped while a pinned tooltip is active. */ onLeave(): void; /** * Fires on click with mouse position. Return true to consume the click * (skipping the default pin/dismiss flow — used for legend clicks). */ onClickPre?(mx: number, my: number): boolean; /** * Fires when a click should pin the current hover target. */ onPin?(mx: number, my: number): void; /** * Fires on dblclick (treemap drill-up gesture). Optional — charts * that don't bind a handler simply ignore the event. */ onDblClick?(mx: number, my: number): void; /** * Fires when an active pin is dismissed by a click on the * already-pinned target (the "click again to unpin" gesture in * `dispatchClick`). Chart impls hook this to emit a * `perspective-global-filter` with `selected: false`. Does *not* * fire on the implicit dismiss inside `pin()` that replaces an * existing pin — that path is followed by a fresh `onPin` which * emits its own `selected: true`. */ onUnpin?(): void; } export interface RenderTooltipOptions { /** * Draw a dashed crosshair at `pos`. Used by scatter/line. */ crosshair?: boolean; /** * Draw a ring of `radius` CSS pixels at `pos`. Used to highlight a * hovered point. Omit for bars (where the bar itself highlights). */ highlightRadius?: number; } /** * Side-channel from the chart back to the host's DOM. The chart calls * into a sink rather than touching the DOM itself; the host * materializes the actual visual (`
` for pinned tooltip, cursor * mutation on the GL canvas). * * - `MessageHostSink` (in worker/) — forwards calls over a * `postMessage`-shaped channel back to * the host. * - `DomHostSink` (in host transport) — receives the matching * envelopes and applies them to the DOM. * * The controller's `_pinned` flag is the source of truth for whether * hover updates are gated; the sink only owns the visual artifact. */ export interface HostSink { pin( lines: string[], pos: { px: number; py: number }, bounds: CssBounds, ): void; dismiss(): void; setCursor(cursor: string): void; /** * Forward a `perspective-click` to the host. Optional — only the * worker-bound `MessageHostSink` implements it; `DomHostSink` (the * host-side consumer of pin/dismiss) never sees user-event calls, * so omits the implementation. */ emitUserClick?(detail: UserClickPayload): void; /** * Forward a `perspective-global-filter` to the host with the * `selected: true` / `selected: false` semantics. The host owns the * `removeConfigs` history (mirrors datagrid's * `model._last_insert_configs`); the sink only ships the new state. */ emitUserSelect?(payload: UserSelectPayload): void; } /** * Plain-object payload for `HostSink.emitUserClick`. Matches * `PerspectiveClickDetail` byte-for-byte; defined locally to avoid a * cycle through `event-detail.ts`. */ export interface UserClickPayload { row: Record; column_names: string[]; config: { filter?: unknown[] }; } /** * Plain-object payload for `HostSink.emitUserSelect`. The host * transport reconstructs a `PerspectiveSelectDetail` class instance * from this plus its cached `_lastInsertConfig`. */ export interface UserSelectPayload { selected: boolean; row: Record; column_names: string[]; insertConfig: { filter?: unknown[] }; } /** * Owns the hover/click/dblclick state machine and the pinned-tooltip * lifecycle. The renderer drives this purely through * `dispatchHover` / `dispatchLeave` / `dispatchClick` / * `dispatchDblClick` — the host's `RawEventForwarder` captures DOM * events on the GL canvas and posts them as `InteractionEvent`s. * * Pinning + cursor changes go through a {@link HostSink} so the actual * DOM mutations happen host-side regardless of where the chart runs. */ export class TooltipController { private _callbacks: TooltipCallbacks | null = null; private _hoverRAFId = 0; private _hoverTimeoutId: ReturnType | null = null; private _host: HostSink | null = null; private _pinned = false; get isPinned(): boolean { return this._pinned; } /** * Replace the active host sink. Dismisses any existing pin via the * prior sink so we never leak a pinned artifact across resets — * though in practice each chart instance uses one sink for its * lifetime. */ setHost(sink: HostSink): void { if (this._pinned) { this._host?.dismiss(); this._pinned = false; } this._host = sink; } /** * Forward a cursor change to the host. No-op when no host sink is * installed (chart constructed without a transport). */ setCursor(cursor: string): void { this._host?.setCursor(cursor); } /** * Install the chart's tooltip callbacks. The renderer drives the * controller via `dispatchHover` / `dispatchLeave` / * `dispatchClick` / `dispatchDblClick`; this controller never * touches the DOM directly. */ attach(callbacks: TooltipCallbacks): void { this.detach(); this._callbacks = callbacks; } detach(): void { if (this._hoverRAFId) { cancelAnimationFrame(this._hoverRAFId); this._hoverRAFId = 0; } if (this._hoverTimeoutId !== null) { clearTimeout(this._hoverTimeoutId); this._hoverTimeoutId = null; } this._callbacks = null; } /** * Schedule an `onHover` callback for the given canvas-relative * coords. Coalesces multiple calls within one animation frame so * pointer streams don't backlog the chart's hit-test path. * * Workers ship with `requestAnimationFrame` (DedicatedWorkerGlobalScope * exposes it for OffscreenCanvas painting), so the same coalescer * works in both modes. We fall back to setTimeout if RAF is missing * (e.g. node tests without a polyfill). */ dispatchHover(mx: number, my: number): void { if (this._pinned || !this._callbacks) { return; } if (this._hoverRAFId || this._hoverTimeoutId !== null) { return; } const fire = () => { this._hoverRAFId = 0; this._hoverTimeoutId = null; this._callbacks?.onHover(mx, my); }; if (typeof requestAnimationFrame === "function") { this._hoverRAFId = requestAnimationFrame(fire); } else { this._hoverTimeoutId = setTimeout(fire, 16); } } dispatchLeave(): void { if (this._pinned || !this._callbacks) { return; } this._callbacks.onLeave(); } dispatchClick(mx: number, my: number): void { if (!this._callbacks) { return; } if (this._callbacks.onClickPre?.(mx, my)) { return; } if (this._pinned) { const cb = this._callbacks; this.dismiss(); cb.onUnpin?.(); return; } this._callbacks.onPin?.(mx, my); } dispatchDblClick(mx: number, my: number): void { this._callbacks?.onDblClick?.(mx, my); } /** * Pin a tooltip (or replace an active one). Forwards through the * configured sink and flips the controller's pinned flag so hover * dispatch is suppressed until dismissal. */ pin( lines: string[], pos: { px: number; py: number }, bounds: CssBounds, ): void { if (lines.length === 0) { return; } this._host?.pin(lines, pos, bounds); this._pinned = true; } dismiss(): void { this._host?.dismiss(); this._pinned = false; } } /** * Paint a canvas tooltip (crosshair, highlight ring, box + text) onto * `canvas`. The helper normalizes the 2D context to a DPR-scaled * identity transform on entry and restores prior state on exit, so it * composes cleanly with other chrome painters that may have already * called `initCanvas` on the same canvas — re-applying `scale(dpr,dpr)` * blind would double-scale in that case, misplacing the tooltip * proportionally to its distance from the origin. */ export function renderCanvasTooltip( canvas: Canvas2D | null, pos: { px: number; py: number }, lines: string[], layout: PlotLayout, theme: Theme, dpr: number, options: RenderTooltipOptions = {}, ): void { if (!canvas) { return; } const ctx = canvas.getContext("2d") as Context2D | null; if (!ctx) { return; } ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); ctx.font = `11px ${theme.fontFamily}`; const lineHeight = 16; const padding = 8; let maxWidth = 0; for (const line of lines) { const w = ctx.measureText(line).width; if (w > maxWidth) { maxWidth = w; } } const boxW = maxWidth + padding * 2; const boxH = lines.length * lineHeight + padding * 2 - 4; let tx = pos.px + 12; let ty = pos.py - boxH - 8; if (tx + boxW > layout.cssWidth) { tx = pos.px - boxW - 12; } if (ty < 0) { ty = pos.py + 12; } if (ty + boxH > layout.cssHeight) { ty = layout.cssHeight - boxH - 4; } const hasLines = lines.length > 0; // Crosshair if (options.crosshair) { ctx.strokeStyle = theme.tickColor; ctx.globalAlpha = 0.3; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(pos.px, layout.plotRect.y); ctx.lineTo(pos.px, layout.plotRect.y + layout.plotRect.height); ctx.moveTo(layout.plotRect.x, pos.py); ctx.lineTo(layout.plotRect.x + layout.plotRect.width, pos.py); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1.0; } // Highlight ring if (options.highlightRadius && options.highlightRadius > 0) { ctx.strokeStyle = theme.tickColor; ctx.globalAlpha = 0.8; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(pos.px, pos.py, options.highlightRadius, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1.0; } // Box + text are only drawn when we have content. Callers pass an // empty `lines` array while a lazy row fetch is still in flight — // the crosshair / highlight ring above paint immediately so the // hover remains visible, but the tooltip chrome waits for data. if (hasLines) { ctx.fillStyle = theme.tooltipBg; ctx.strokeStyle = theme.tooltipBorder; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(tx, ty, boxW, boxH, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = theme.tooltipText; ctx.textAlign = "left"; ctx.textBaseline = "top"; for (let i = 0; i < lines.length; i++) { ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight); } } ctx.restore(); }