"use client"
import { memo, useMemo, useCallback, useState } from "react"
import type { GraphNode, DocumentNodeData, MemoryNodeData } from "./types"
import type { ChainEntry } from "./canvas/version-chain"
export interface NodeHoverPopoverProps {
node: GraphNode
screenX: number
screenY: number
nodeRadius: number
containerBounds?: DOMRect
versionChain?: ChainEntry[] | null
onNavigateNext?: () => void
onNavigatePrev?: () => void
onNavigateUp?: () => void
onNavigateDown?: () => void
onSelectNode?: (nodeId: string) => void
}
function KeyBadge({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
function NavButton({
icon,
label,
onClick,
}: {
icon: string
label: string
onClick?: () => void
}) {
return (
)
}
function CopyableId({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false)
const copy = useCallback(() => {
navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}, [value])
const short =
value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value
return (
)
}
type Quadrant = "right" | "left" | "above" | "below"
function pickBestQuadrant(
screenX: number,
screenY: number,
nodeRadius: number,
containerWidth: number,
containerHeight: number,
popoverWidth: number,
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],
]
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
}
return fits.sort((a, b) => b[1] - a[1])[0]![0]
}
function truncate(s: string, max: number) {
return s.length > max ? `${s.substring(0, max)}...` : s
}
function VersionTimeline({
chain,
currentId,
onSelect,
}: {
chain: ChainEntry[]
currentId: string
onSelect?: (id: string) => void
}) {
return (
{chain.map((entry) => {
const isCurrent = entry.id === currentId
return (
)
})}
)
}
export const NodeHoverPopover = memo(
function NodeHoverPopover({
node,
screenX,
screenY,
nodeRadius,
containerBounds,
versionChain,
onNavigateNext,
onNavigatePrev,
onNavigateUp,
onNavigateDown,
onSelectNode,
}) {
const CARD_W = 280
const SHORTCUTS_W = 100
const GAP = 24
const TOTAL_W = CARD_W + 12 + SHORTCUTS_W
const isMemory = node.type === "memory"
const data = node.data
const memoryMeta = useMemo(() => {
if (!isMemory) return null
const md = data as MemoryNodeData
return {
version: md.version ?? 1,
isLatest: md.isLatest ?? false,
isForgotten: md.isForgotten ?? false,
forgetReason: md.forgetReason ?? null,
forgetAfter: md.forgetAfter ?? null,
}
}, [isMemory, data])
const hasChain = versionChain && versionChain.length > 1
const hasForgetInfo =
memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter)
const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135
const TOTAL_H = CARD_H
const { popoverX, popoverY, connectorPath } = useMemo(() => {
const cw = containerBounds?.width ?? 800
const ch = containerBounds?.height ?? 600
const quadrant = pickBestQuadrant(
screenX,
screenY,
nodeRadius,
cw,
ch,
TOTAL_W + GAP,
TOTAL_H,
)
let px: number
let py: number
let connStart: { x: number; y: number }
switch (quadrant) {
case "right":
px = screenX + nodeRadius + GAP
py = screenY - TOTAL_H / 2
connStart = { x: screenX + nodeRadius, y: screenY }
break
case "left":
px = screenX - nodeRadius - GAP - TOTAL_W
py = screenY - TOTAL_H / 2
connStart = { x: screenX - nodeRadius, y: screenY }
break
case "below":
px = screenX - TOTAL_W / 2
py = screenY + nodeRadius + GAP
connStart = { x: screenX, y: screenY + nodeRadius }
break
case "above":
px = screenX - TOTAL_W / 2
py = screenY - nodeRadius - GAP - TOTAL_H
connStart = { x: screenX, y: screenY - nodeRadius }
break
}
px = Math.max(8, Math.min(cw - TOTAL_W - 8, px))
py = Math.max(8, Math.min(ch - TOTAL_H - 8, py))
const cardCenterX = px + CARD_W / 2
const cardCenterY = py + TOTAL_H / 2
const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}`
return { popoverX: px, popoverY: py, connectorPath: path }
}, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H])
const content = useMemo(() => {
if (isMemory) {
const md = data as MemoryNodeData
return md.memory || md.content || ""
}
const dd = data as DocumentNodeData
return dd.summary || dd.title || ""
}, [isMemory, data])
const docData = !isMemory ? (data as DocumentNodeData) : null
return (
{/* Card */}
{/* Content — show timeline if chain exists, otherwise plain text */}
{hasChain ? (
) : (
{truncate(content, 100) || "No content"}
)}
{/* Forget info (memory-only) */}
{memoryMeta && hasForgetInfo && (
{memoryMeta.forgetAfter && (
Expires:{" "}
{new Date(memoryMeta.forgetAfter).toLocaleDateString()}
)}
{memoryMeta.forgetReason && (
Reason: {memoryMeta.forgetReason}
)}
{memoryMeta.isForgotten && !memoryMeta.forgetReason && (
Forgotten
)}
)}
{/* Bottom bar */}
{memoryMeta ? (
<>
v{memoryMeta.version}{" "}
{memoryMeta.isForgotten
? "Forgotten"
: memoryMeta.isLatest
? "Latest"
: "Superseded"}
>
) : (
<>
{docData?.type || "document"}
{docData?.memories?.length ?? 0} memories
>
)}
{/* ID row */}
{isMemory ? (
) : (
)}
{/* Navigation */}
{isMemory && (
)}
{(isMemory ? hasChain : true) && (
)}
)
},
)