fix(memory-graph): make popover quadrant fallback pick the side with most space

pickBestQuadrant flattened each side's available space to -1 when the
popover did not fully fit, then sorted those values for the fallback.
Once every side was -1 the sort was meaningless and the fallback always
returned right, even for a node hugging the right edge of the
container where the left side has ~45x more room. The clamped popover
then slides over the node it is annotating.

Keep the raw space measurements alongside the required size so the
preference pass still checks fit, while the fallback can genuinely
rank sides by available space. Also export the helper and cover the
preference order, the exact-fit boundary, and the fallback ranking
with unit tests.
This commit is contained in:
abhay-codes07 2026-07-12 23:26:12 +05:30
parent 2cebe81512
commit 5bbcc07e93
No known key found for this signature in database
2 changed files with 103 additions and 15 deletions

View file

@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest"
import { pickBestQuadrant } from "../components/node-hover-popover"
/**
* pickBestQuadrant(screenX, screenY, nodeRadius, containerWidth,
* containerHeight, popoverWidth, popoverHeight)
*
* gap = 24 in the implementation, so available space per side is:
* right: containerWidth - (screenX + nodeRadius + 24)
* left: screenX - nodeRadius - 24
* above: screenY - nodeRadius - 24
* below: containerHeight - (screenY + nodeRadius + 24)
*/
describe("pickBestQuadrant", () => {
const RADIUS = 10
const POPOVER_W = 476 // CARD_W(280) + 12 + SHORTCUTS_W(160) + GAP(24)
const POPOVER_H = 230
it("prefers right when there is room on the right", () => {
expect(
pickBestQuadrant(100, 300, RADIUS, 1200, 600, POPOVER_W, POPOVER_H),
).toBe("right")
})
it("falls back to left when only the left side fits", () => {
// right space: 1200 - (1100 + 10 + 24) = 66 -> no fit
// left space: 1100 - 10 - 24 = 1066 -> fits
expect(
pickBestQuadrant(1100, 40, RADIUS, 1200, 260, POPOVER_W, POPOVER_H),
).toBe("left")
})
it("falls back to below when neither horizontal side fits", () => {
// container is narrow (500 wide) so neither left nor right fits 476,
// but there is 600 - (100 + 10 + 24) = 466 below -> fits 230
expect(
pickBestQuadrant(250, 100, RADIUS, 500, 600, POPOVER_W, POPOVER_H),
).toBe("below")
})
it("uses above when it is the only side that fits", () => {
// node near the bottom of a narrow container
// above space: 500 - 10 - 24 = 466 -> fits 230
// below space: 600 - (500 + 10 + 24) = 66 -> no fit
expect(
pickBestQuadrant(250, 500, RADIUS, 500, 600, POPOVER_W, POPOVER_H),
).toBe("above")
})
it("treats exactly-enough space as a fit", () => {
// right space: 800 - (280 + 10 + 24) = 486 -> not 476? use exact math
// choose screenX so right space === POPOVER_W: screenX = 800 - 24 - 10 - 476 = 290
expect(
pickBestQuadrant(290, 300, RADIUS, 800, 600, POPOVER_W, POPOVER_H),
).toBe("right")
})
describe("when no quadrant fully fits", () => {
it("picks the side with the most available space instead of defaulting to right", () => {
// container 800x300, node hugging the right edge at (750, 150)
// right: 800 - (750 + 10 + 24) = 16
// left: 750 - 10 - 24 = 716 <- most space, but < 800 popover width
// above: 150 - 10 - 24 = 116
// below: 300 - (150 + 10 + 24) = 116
expect(pickBestQuadrant(750, 150, RADIUS, 800, 300, 800, 400)).toBe(
"left",
)
})
it("picks below when the node hugs the top of a short container", () => {
// container 400x500, node at (200, 60)
// right: 400 - (200 + 10 + 24) = 166
// left: 200 - 10 - 24 = 166
// above: 60 - 10 - 24 = 26
// below: 500 - (60 + 10 + 24) = 406 <- most space
expect(pickBestQuadrant(200, 60, RADIUS, 400, 500, 600, 600)).toBe(
"below",
)
})
it("picks above when the node hugs the bottom of a short container", () => {
// container 400x500, node at (200, 440)
// above: 440 - 10 - 24 = 406 <- most space
// below: 500 - (440 + 10 + 24) = 26
expect(pickBestQuadrant(200, 440, RADIUS, 400, 500, 600, 600)).toBe(
"above",
)
})
})
})

View file

@ -190,9 +190,9 @@ function CopyableId({
)
}
type Quadrant = "right" | "left" | "above" | "below"
export type Quadrant = "right" | "left" | "above" | "below"
function pickBestQuadrant(
export function pickBestQuadrant(
screenX: number,
screenY: number,
nodeRadius: number,
@ -202,25 +202,23 @@ function pickBestQuadrant(
popoverHeight: number,
): Quadrant {
const gap = 24
const spaceRight = containerWidth - (screenX + nodeRadius + gap)
const spaceLeft = screenX - nodeRadius - gap
const spaceAbove = screenY - nodeRadius - gap
const spaceBelow = containerHeight - (screenY + nodeRadius + gap)
const fits: [Quadrant, number][] = [
["right", spaceRight >= popoverWidth ? spaceRight : -1],
["left", spaceLeft >= popoverWidth ? spaceLeft : -1],
["above", spaceAbove >= popoverHeight ? spaceAbove : -1],
["below", spaceBelow >= popoverHeight ? spaceBelow : -1],
// [quadrant, available space, space required for the popover to fit]
const candidates: [Quadrant, number, number][] = [
["right", containerWidth - (screenX + nodeRadius + gap), popoverWidth],
["left", screenX - nodeRadius - gap, popoverWidth],
["above", screenY - nodeRadius - gap, popoverHeight],
["below", containerHeight - (screenY + nodeRadius + gap), popoverHeight],
]
const preferred: Quadrant[] = ["right", "left", "below", "above"]
for (const q of preferred) {
const entry = fits.find(([dir]) => dir === q)
if (entry && entry[1] > 0) return q
const entry = candidates.find(([dir]) => dir === q)
if (entry && entry[1] >= entry[2]) return q
}
return fits.sort((a, b) => b[1] - a[1])[0]?.[0] ?? "right"
// Nothing fits fully — fall back to the side with the most room so the
// clamped popover overlaps the node as little as possible.
return candidates.sort((a, b) => b[1] - a[1])[0]?.[0] ?? "right"
}
function truncate(s: string, max: number) {