Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions src/components/MultiLineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -336,7 +337,7 @@ export default function MultiLineChart({
.text((d) => d),
);

size(tooltipTextElem, tooltipBoxElem);
size(tooltipTextElem, tooltipBoxElem, x, y);
}

function pointerentered() {
Expand Down Expand Up @@ -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<SVGTextElement, unknown, null, undefined>,
path: Selection<SVGPathElement, unknown, null, undefined>,
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(() => {
Expand Down
81 changes: 81 additions & 0 deletions src/utils/chartTooltipLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
131 changes: 131 additions & 0 deletions src/utils/chartTooltipLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* 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).
*
* `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 {
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,
};
}
Loading