From 73b509fe0c987ed33592b027afd1f8318fce47ca Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Wed, 19 Aug 2026 13:58:51 +0200 Subject: [PATCH 1/3] fix multilinechart tooltip positioning --- src/components/MultiLineChart.tsx | 53 +++++++++-- src/utils/chartTooltipLayout.test.ts | 81 +++++++++++++++++ src/utils/chartTooltipLayout.ts | 129 +++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 src/utils/chartTooltipLayout.test.ts create mode 100644 src/utils/chartTooltipLayout.ts diff --git a/src/components/MultiLineChart.tsx b/src/components/MultiLineChart.tsx index 84b81ec1..003b9f31 100644 --- a/src/components/MultiLineChart.tsx +++ b/src/components/MultiLineChart.tsx @@ -6,6 +6,7 @@ import { ascending, extent, groups, leastIndex, range } from "d3-array"; import { axisBottom, axisLeft } from "d3-axis"; import { useRef, useEffect, useMemo, useState } from "react"; import { capitalizeWords } from "../utils/capitalizeWords"; +import { computeTooltipBoxLayout } from "../utils/chartTooltipLayout"; interface DataPoint { sector: string; @@ -336,7 +337,7 @@ export default function MultiLineChart({ .text((d) => d), ); - size(tooltipTextElem, tooltipBoxElem); + size(tooltipTextElem, tooltipBoxElem, x, y); } function pointerentered() { @@ -369,20 +370,62 @@ export default function MultiLineChart({ setSelectRef(clicked_tech as string); } + // Positions the tooltip box around the hovered point, avoiding clipping + // past the plot's edges. Horizontally the box is centered on the point + // by default, sliding back on-chart if that would clip; vertically it + // grows downward from the point by default (today's look), flipping + // to grow upward if there's no room below but there is above. Either + // way the tail stretches to keep pointing at the exact hovered point. + // The actual geometry lives in chartTooltipLayout.ts as a pure + // function, so it can be unit tested without a real SVG layout engine. function size( text: Selection, path: Selection, + xPixel: number, + yPixel: number, ) { const bbox = text.node()?.getBBox(); if (!bbox) return; - const { y, width: w, height: h } = bbox; - text.attr("transform", `translate(${-w / 2},${15 - y})`); + + const { + boxLeft, + boxRight, + nearY, + farY, + offsetX, + tailHalf, + textOffsetX, + textOffsetY, + } = computeTooltipBoxLayout( + { x: xPixel, y: yPixel }, + { width: bbox.width, height: bbox.height, bboxY: bbox.y }, + { + left: marginLeft, + right: width - marginRight, + top: marginTop, + bottom: height - marginBottom, + }, + ); + + text.attr("transform", `translate(${textOffsetX},${textOffsetY})`); path.attr( "d", - `M${-w / 2 - 10},5H-5l5,-5l5,5H${w / 2 + 10}v${h + 20}h-${w + 20}z`, + `M${boxLeft},${nearY}L${offsetX - tailHalf},${nearY}L0,0L${offsetX + tailHalf},${nearY}L${boxRight},${nearY}L${boxRight},${farY}L${boxLeft},${farY}Z`, ); } - }, [d3data, selectRef, chartSetup, sector, metric, marginTop, width]); + }, [ + d3data, + selectRef, + chartSetup, + sector, + metric, + marginTop, + marginRight, + marginBottom, + marginLeft, + width, + height, + ]); // Apply cross-chart highlighting when another pathway's chart is hovered useEffect(() => { diff --git a/src/utils/chartTooltipLayout.test.ts b/src/utils/chartTooltipLayout.test.ts new file mode 100644 index 00000000..03608ef1 --- /dev/null +++ b/src/utils/chartTooltipLayout.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { computeTooltipBoxLayout } from "./chartTooltipLayout"; + +// Bounds/text metrics chosen to match MultiLineChart's defaults: pad=10, +// tipHeight=5, so boxWidth = text.width + 20 and boxHeight = text.height + 20. +const bounds = { left: 50, right: 520, top: 20, bottom: 345 }; +const text = { width: 80, height: 20, bboxY: -14 }; + +describe("computeTooltipBoxLayout", () => { + it("centers on the point and grows downward when there's room on every side", () => { + const layout = computeTooltipBoxLayout({ x: 300, y: 150 }, text, bounds); + + expect(layout.offsetX).toBe(0); + expect(layout.nearY).toBe(5); // tipHeight + expect(layout.farY).toBe(45); // tipHeight + boxHeight (20 + 20) + expect(layout.textOffsetX).toBe(-40); // -width / 2 + expect(layout.textOffsetY).toBe(29); // min(near,far) + pad - bboxY + }); + + it("slides the box right when the point is near the left edge", () => { + const layout = computeTooltipBoxLayout({ x: 60, y: 150 }, text, bounds); + + // Box's absolute left edge should sit exactly on the plot's left bound. + expect(60 + layout.boxLeft).toBeCloseTo(bounds.left); + // Vertical placement is unaffected. + expect(layout.nearY).toBe(5); + }); + + it("slides the box left when the point is near the right edge", () => { + const layout = computeTooltipBoxLayout({ x: 510, y: 150 }, text, bounds); + + expect(510 + layout.boxRight).toBeCloseTo(bounds.right); + expect(layout.nearY).toBe(5); + }); + + it("flips the box above the point when there's no room below", () => { + const layout = computeTooltipBoxLayout({ x: 300, y: 330 }, text, bounds); + + // Both edges end up above the point (negative local y). + expect(layout.nearY).toBeLessThan(0); + expect(layout.farY).toBeLessThan(0); + expect(Math.abs(layout.nearY)).toBeLessThan(Math.abs(layout.farY)); + // Horizontal placement is unaffected. + expect(layout.offsetX).toBe(0); + }); + + it("applies horizontal and vertical repositioning independently near a corner", () => { + const layout = computeTooltipBoxLayout({ x: 55, y: 330 }, text, bounds); + + expect(55 + layout.boxLeft).toBeCloseTo(bounds.left); // shifted right + expect(layout.nearY).toBeLessThan(0); // flipped above + }); + + it("clamps the far edge to the available space when neither side fully fits", () => { + const tightBounds = { left: 50, right: 520, top: 100, bottom: 110 }; + const layout = computeTooltipBoxLayout( + { x: 300, y: 105 }, + text, + tightBounds, + ); + + const belowSpace = tightBounds.bottom - 105; + const aboveSpace = 105 - tightBounds.top; + const chosenSpace = layout.farY > 0 ? belowSpace : aboveSpace; + expect(Math.abs(layout.farY)).toBeLessThanOrEqual(chosenSpace + 1e-9); + }); + + it("falls back to the averaged center when the box is wider than the plot area", () => { + const narrowBounds = { left: 50, right: 120, top: 20, bottom: 345 }; + const layout = computeTooltipBoxLayout( + { x: 300, y: 150 }, + text, + narrowBounds, + ); + + const halfWidth = (text.width + 20) / 2; // pad default = 10 + const minCenter = narrowBounds.left + halfWidth; + const maxCenter = narrowBounds.right - halfWidth; + expect(300 + layout.offsetX).toBeCloseTo((minCenter + maxCenter) / 2); + }); +}); diff --git a/src/utils/chartTooltipLayout.ts b/src/utils/chartTooltipLayout.ts new file mode 100644 index 00000000..845aa00a --- /dev/null +++ b/src/utils/chartTooltipLayout.ts @@ -0,0 +1,129 @@ +/** + * Pure geometry for positioning a point-anchored chart tooltip (e.g. + * MultiLineChart's per-datapoint tooltip) so it never clips past the + * plot area's edges. Kept free of d3/DOM so it can be unit tested without + * a real SVG layout engine (jsdom's getBBox() always returns zeros). + * + * All coordinates are local to the anchor point, i.e. the hovered point + * sits at (0, 0); `offsetX`/`nearY`/`farY` describe the tooltip box's + * position relative to it. + */ + +export interface TooltipAnchorPoint { + x: number; + y: number; +} + +/** The plot area's edges, in the same pixel space as the anchor point. */ +export interface TooltipPlotBounds { + left: number; + right: number; + top: number; + bottom: number; +} + +/** The tooltip text's measured size, as reported by SVGTextElement.getBBox(). */ +export interface TooltipTextMetrics { + width: number; + height: number; + /** bbox.y — the ascent offset, needed to correct the text's baseline. */ + bboxY: number; +} + +export interface TooltipLayoutOptions { + /** Internal padding around the text, each side. */ + pad?: number; + /** Length of the tail between the box and the anchor point. */ + tipHeight?: number; +} + +export interface TooltipBoxLayout { + boxLeft: number; + boxRight: number; + /** Box edge closest to the anchor point (tail attaches here). */ + nearY: number; + /** Box edge farthest from the anchor point. */ + farY: number; + /** Horizontal shift of the box's center away from the anchor point. */ + offsetX: number; + /** Half-width of the tail where it meets the box. */ + tailHalf: number; + /** Transform offset for the tooltip text. */ + textOffsetX: number; + textOffsetY: number; +} + +const DEFAULT_PAD = 10; +const DEFAULT_TIP_HEIGHT = 5; + +/** + * Computes where to draw a tooltip box anchored to a single point, so it + * stays within the given plot bounds. + * + * Horizontally, the box is centered on the point by default, sliding back + * on-chart just far enough to fit within `bounds.left`/`bounds.right`. + * + * Vertically, the box grows downward from the point by default; it flips + * to grow upward if there's no room below but there is above. If neither + * direction fully fits, it uses whichever side has more room and slides + * back on-chart to clip as little as possible. + * + * In every case the tail (drawn separately by the caller, from + * `(offsetX - tailHalf, nearY)` through `(0, 0)` to + * `(offsetX + tailHalf, nearY)`) keeps pointing at the exact anchor point. + */ +export function computeTooltipBoxLayout( + point: TooltipAnchorPoint, + text: TooltipTextMetrics, + bounds: TooltipPlotBounds, + options: TooltipLayoutOptions = {}, +): TooltipBoxLayout { + const pad = options.pad ?? DEFAULT_PAD; + const tipHeight = options.tipHeight ?? DEFAULT_TIP_HEIGHT; + const tailHalf = pad / 2; + const boxWidth = text.width + pad * 2; + const boxHeight = text.height + pad * 2; + const halfWidth = boxWidth / 2; + + // Horizontal: slide the centered box back on-chart just far enough to + // fit within the plot's left/right edges. If the box is wider than the + // plot area, fall back to the average so it clips as evenly as possible. + const minCenter = bounds.left + halfWidth; + const maxCenter = bounds.right - halfWidth; + const centerX = + minCenter <= maxCenter + ? Math.min(Math.max(point.x, minCenter), maxCenter) + : (minCenter + maxCenter) / 2; + const offsetX = centerX - point.x; + const boxLeft = offsetX - halfWidth; + const boxRight = offsetX + halfWidth; + + // Vertical: prefer below the point; flip above if there's no room below + // but there is above. If neither fully fits, use whichever side has + // more room and slide the box back on-chart to fit. + const belowSpace = bounds.bottom - point.y; + const aboveSpace = point.y - bounds.top; + const needed = tipHeight + boxHeight; + const sign = + needed <= belowSpace + ? 1 + : needed <= aboveSpace + ? -1 + : belowSpace >= aboveSpace + ? 1 + : -1; + const overflowY = Math.max(0, needed - (sign > 0 ? belowSpace : aboveSpace)); + const nearY = sign * (tipHeight - overflowY); + const farY = sign * (tipHeight + boxHeight - overflowY); + + return { + boxLeft, + boxRight, + nearY, + farY, + offsetX, + tailHalf, + textOffsetX: offsetX - text.width / 2, + textOffsetY: Math.min(nearY, farY) + pad - text.bboxY, + }; +} From 3dc8b64daa2f793a0fcc16b820735b0db5ad7118 Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Wed, 19 Aug 2026 14:00:00 +0200 Subject: [PATCH 2/3] fix: no more clipping of tooltip From 6c35219f3af02f8e1e12d45fb3083b9aff8158db Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Wed, 19 Aug 2026 14:33:08 +0200 Subject: [PATCH 3/3] Clarify coordinate spaces in chartTooltipLayout doc comment point/bounds are absolute plot-pixel coordinates; only the returned layout is local to the anchor point. The previous header comment claimed all coordinates were anchor-local, which contradicted TooltipPlotBounds's own doc and could mislead callers. Addresses Copilot review comment on PR #913. Co-Authored-By: Claude Sonnet 5 --- src/utils/chartTooltipLayout.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/utils/chartTooltipLayout.ts b/src/utils/chartTooltipLayout.ts index 845aa00a..358bad6b 100644 --- a/src/utils/chartTooltipLayout.ts +++ b/src/utils/chartTooltipLayout.ts @@ -4,9 +4,11 @@ * plot area's edges. Kept free of d3/DOM so it can be unit tested without * a real SVG layout engine (jsdom's getBBox() always returns zeros). * - * All coordinates are local to the anchor point, i.e. the hovered point - * sits at (0, 0); `offsetX`/`nearY`/`farY` describe the tooltip box's - * position relative to it. + * `point` and `bounds` are in absolute plot-pixel space (the same space + * as each other). The returned layout is local to the anchor point + * instead, i.e. as if the hovered point sat at (0, 0) — `offsetX`/`nearY`/ + * `farY`/etc. describe the tooltip box's position relative to it, ready + * to use inside an SVG group already translated to the point's position. */ export interface TooltipAnchorPoint {