import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Check, X, RotateCcw, RefreshCw } from "lucide-react"; import { scaleLinear } from "d3"; import type { VFPoint, CurveState } from "../../types.js"; import { CurveTooltip } from "./CurveTooltip.js"; import { CurveToolbar } from "./CurveToolbar.js"; import { ConfirmDialog } from "../common/ConfirmDialog.js"; import { voltExtent, refBaseMhz, detectClampedPoints, } from "../../utils/curveHelpers.js"; import { useCurveStore } from "../../store/curveStore.js"; interface Props { curve: CurveState; activeDomain: "gpu" | "memory"; onDomainChange: (d: "gpu" | "memory") => void; currentVoltageMv: number | null; currentClockMhz: number | null; onRefresh: () => void; } const MARGIN = { top: 16, right: 24, bottom: 48, left: 64 }; /** Drag-tooltip info (shown while actively dragging a point) */ interface DragInfo { pointIndex: number; /** Current pending delta kHz while dragging */ currentDeltaKhz: number; /** SVG inner coords of the point, for tooltip positioning */ cx: number; cy: number; } /** Box-select rubber-band rect (SVG inner coords) */ interface BoxRect { x0: number; y0: number; x1: number; y1: number; } export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltageMv, currentClockMhz, onRefresh, }: Props) { const readOnly = activeDomain === "memory"; const [hoveredPoint, setHoveredPoint] = useState(null); const [dragInfo, setDragInfo] = useState(null); const [boxRect, setBoxRect] = useState(null); const [inlineInput, setInlineInput] = useState<{ pointIndex: number; value: string; } | null>(null); const svgRef = useRef(null); const containerRef = useRef(null); const [svgCursor, setSvgCursor] = useState("default"); // ─── Dynamic SVG dimensions ────────────────────────────────────────────── const [svgW, setSvgW] = useState(680); const svgWRef = useRef(680); useEffect(() => { svgWRef.current = svgW; }, [svgW]); const INNER_W = svgW - MARGIN.left - MARGIN.right; const [svgH, setSvgH] = useState(460); const svgHRef = useRef(460); useEffect(() => { svgHRef.current = svgH; }, [svgH]); const INNER_H = svgH - MARGIN.top - MARGIN.bottom; useEffect(() => { const el = containerRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; const w = Math.round(width); const h = Math.min( Math.round(height), 500, Math.round(window.innerHeight * 0.58), ); setSvgW((prev) => (prev === w ? prev : Math.max(300, w))); setSvgH((prev) => (prev === h ? prev : Math.max(200, h))); }); ro.observe(el); return () => ro.disconnect(); }, []); // Store const { pendingDeltas, selectedPoints, anchorPoint, stageEdit, stageMultiEdit, selectPoint, togglePoint, selectRange, clearSelection, effectiveMhz, applyEdits, discardEdits, resetAllDeltas, hasNegativeFreqWarning, } = useCurveStore(); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); const [dialog, setDialog] = useState<"apply" | "reset" | null>(null); async function handleApplyConfirm() { setDialog(null); setBusy(true); setActionError(null); try { await applyEdits(onRefresh); } catch (e) { setActionError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } } async function handleResetConfirm() { setDialog(null); setBusy(true); setActionError(null); try { await resetAllDeltas(onRefresh); } catch (e) { setActionError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } } const pts = useMemo( () => curve.points.filter((p) => p.domain === activeDomain), [curve.points, activeDomain], ); const clampedPoints = useMemo(() => detectClampedPoints(pts), [pts]); // Clear selection and pending when switching domains useEffect(() => { clearSelection(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeDomain]); // ─── X-axis zoom / viewport ────────────────────────────────────────────── // Use a slightly larger padding to space things out better const [xViewport, setXViewport] = useState<[number, number]>(() => voltExtent(pts, 40), ); // Reset viewport when the set of points changes (curve refresh) — // adjust state during render instead of an effect (React-recommended pattern). const ptsKey = pts.map((p) => p.index).join(","); const [lastPtsKey, setLastPtsKey] = useState(ptsKey); if (lastPtsKey !== ptsKey) { setLastPtsKey(ptsKey); setXViewport(voltExtent(pts, 40)); } // Compute Y domain accounting for pending changes const allEffective = pts.map((p) => effectiveMhz(p)); const allBase = pts.map((p) => refBaseMhz(p)); const freqMin = Math.min(...allBase, ...allEffective) - 50; const freqMax = Math.max(...allBase, ...allEffective) + 50; const xScale = scaleLinear().domain(xViewport).range([0, INNER_W]); const yScale = scaleLinear().domain([freqMin, freqMax]).range([INNER_H, 0]); // Keep scales in a ref so event handler closures always see the latest const scalesRef = useRef({ xScale, yScale, xViewport, innerW: INNER_W }); useEffect(() => { scalesRef.current = { xScale, yScale, xViewport, innerW: INNER_W }; }); const xTicks = xScale.ticks(8); const yTicks = yScale.ticks(6); // ─── Zoom factor (for toolbar slider) ──────────────────────────────────── const [fullExtentMin, fullExtentMax] = voltExtent(pts); const fullWidth = fullExtentMax - fullExtentMin + 80; const currentWidth = xViewport[1] - xViewport[0]; const zoomFactor = Math.max(1, Math.min(10, fullWidth / currentWidth)); function handleZoomChange(factor: number) { const pad = 40; const newWidth = Math.max(20, fullWidth / factor); const center = (xViewport[0] + xViewport[1]) / 2; let newMin = center - newWidth / 2; let newMax = center + newWidth / 2; if (newMin < fullExtentMin - pad) { newMin = fullExtentMin - pad; newMax = newMin + newWidth; } if (newMax > fullExtentMax + pad) { newMax = fullExtentMax + pad; newMin = newMax - newWidth; } setXViewport([newMin, newMax]); } // Polylines const visiblePts = pts.filter((p) => { const v = p.volt_mv; return v >= xViewport[0] && v <= xViewport[1]; }); const baseLine = visiblePts .map( (p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(refBaseMhz(p)).toFixed(1)}`, ) .join(" "); const effectiveLine = visiblePts .map( (p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(p.freq_mhz).toFixed(1)}`, ) .join(" "); const hasPending = pendingDeltas.size > 0; const pendingLine = hasPending ? visiblePts .map( (p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(effectiveMhz(p)).toFixed(1)}`, ) .join(" ") : null; // ─── Coordinate helpers ────────────────────────────────────────────────── function svgToContainer( svgX: number, svgY: number, ): { x: number; y: number } | null { const svg = svgRef.current; const container = containerRef.current; if (!svg || !container) return null; const svgRect = svg.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const scaleX = svgRect.width / svgWRef.current; const scaleY = svgRect.height / svgHRef.current; return { x: svgRect.left - containerRect.left + (svgX + MARGIN.left) * scaleX, y: svgRect.top - containerRect.top + (svgY + MARGIN.top) * scaleY, }; } function clientToInner( clientX: number, clientY: number, ): { x: number; y: number } | null { const svg = svgRef.current; if (!svg) return null; const rect = svg.getBoundingClientRect(); return { x: (clientX - rect.left) * (svgWRef.current / rect.width) - MARGIN.left, y: (clientY - rect.top) * (svgHRef.current / rect.height) - MARGIN.top, }; } // ─── SVG cursor ────────────────────────────────────────────────────────── function handleSvgMouseMove(e: React.MouseEvent) { if (isPanning.current) return; const next = e.shiftKey ? "crosshair" : "default"; setSvgCursor((c) => (c === next ? c : next)); } function handleSvgMouseLeave() { if (!isPanning.current) setSvgCursor("default"); } // ─── Point hover ───────────────────────────────────────────────────────── const handleMouseEnter = useCallback((p: VFPoint) => { setHoveredPoint(p); }, []); const handleMouseLeave = useCallback(() => { setHoveredPoint(null); }, []); // ─── Drag state (refs — survive renders without triggering effects) ─────── const dragState = useRef<{ active: boolean; pointIndex: number; startY: number; startDeltaKhz: number; pointInitialDeltas: Map; } | null>(null); /** True if the mouse moved meaningfully during the current mousedown */ const dragMoved = useRef(false); // ─── Box select refs ───────────────────────────────────────────────────── const boxStartRef = useRef<{ x: number; y: number } | null>(null); const isBoxSelecting = useRef(false); // ─── Pan refs ───────────────────────────────────────────────────────────── const panState = useRef<{ startX: number; startY: number; startViewport: [number, number]; } | null>(null); const isPanning = useRef(false); // ─── Point mousedown → start drag ──────────────────────────────────────── function handlePointMouseDown(e: React.MouseEvent, p: VFPoint) { if (readOnly) return; containerRef.current?.focus(); e.stopPropagation(); e.preventDefault(); const inner = clientToInner(e.clientX, e.clientY); if (!inner) return; const currentDelta = pendingDeltas.has(p.index) ? pendingDeltas.get(p.index)! : p.delta_khz; const initialDeltas = new Map(); if (selectedPoints.has(p.index) && selectedPoints.size > 1) { // Feature: Dragging a point in a multi-selection moves the whole selection for (const index of selectedPoints) { const pt = pts.find((x) => x.index === index); if (pt) { initialDeltas.set( index, pendingDeltas.has(index) ? pendingDeltas.get(index)! : pt.delta_khz, ); } } } else { // Default: Just drag this point initialDeltas.set(p.index, currentDelta); } dragState.current = { active: true, pointIndex: p.index, startY: inner.y, startDeltaKhz: currentDelta, pointInitialDeltas: initialDeltas, }; dragMoved.current = false; } // ─── SVG background mousedown → pan OR shift+drag = box select ─────────── function handleSvgMouseDown(e: React.MouseEvent) { containerRef.current?.focus(); if (e.button !== 0) return; const inner = clientToInner(e.clientX, e.clientY); if (!inner) return; if (e.shiftKey) { // Shift+drag = box select isBoxSelecting.current = true; boxStartRef.current = inner; setBoxRect({ x0: inner.x, y0: inner.y, x1: inner.x, y1: inner.y }); } else { // Plain drag = pan isPanning.current = true; setSvgCursor("grabbing"); panState.current = { startX: inner.x, startY: inner.y, startViewport: [...scalesRef.current.xViewport] as [number, number], }; } } // ─── Scroll wheel → zoom x-axis around cursor (Alt + scroll) ──────────── function handleWheel(e: React.WheelEvent) { if (!e.altKey) return; // plain scroll → let page scroll normally e.preventDefault(); const inner = clientToInner(e.clientX, e.clientY); if (!inner) return; const { xScale: sx, xViewport: vp } = scalesRef.current; const zoomFactor = e.deltaY < 0 ? 1.15 : 1 / 1.15; // scroll up = zoom in const mouseVolt = sx.invert(inner.x); const [vpMin, vpMax] = vp; const currentWidth = vpMax - vpMin; const newWidth = Math.max( 20, Math.min( voltExtent(pts)[1] - voltExtent(pts)[0] + 40, currentWidth / zoomFactor, ), ); const anchorFrac = (mouseVolt - vpMin) / currentWidth; let newMin = mouseVolt - anchorFrac * newWidth; let newMax = mouseVolt + (1 - anchorFrac) * newWidth; // Clamp to full extent const [fullMin, fullMax] = voltExtent(pts); const pad = 20; if (newMin < fullMin - pad) { newMax += fullMin - pad - newMin; newMin = fullMin - pad; } if (newMax > fullMax + pad) { newMin -= newMax - fullMax - pad; newMax = fullMax + pad; } setXViewport([newMin, newMax]); } // ─── Global mousemove + mouseup ────────────────────────────────────────── useEffect(() => { function onMove(e: MouseEvent) { // --- Point drag --- if (dragState.current?.active) { const inner = clientToInner(e.clientX, e.clientY); if (!inner) return; const ds = dragState.current; const dyPx = inner.y - ds.startY; // Consider moved if more than 3px if (Math.abs(dyPx) > 3) dragMoved.current = true; const { yScale: ys } = scalesRef.current; const [yRangeBottom, yRangeTop] = ys.range() as [number, number]; const [yDomainBottom, yDomainTop] = ys.domain() as [number, number]; const pxPerMhz = (yRangeBottom - yRangeTop) / (yDomainTop - yDomainBottom); const deltaMhz = -dyPx / pxPerMhz; // Calculate the absolute delta for the primary dragged point const primaryNewDeltaKhz = Math.round( ds.startDeltaKhz + deltaMhz * 1000, ); const primaryClamped = Math.max( -1000_000, Math.min(1000_000, primaryNewDeltaKhz), ); // Difference to apply to all other points in the selection const validDeltaDiff = primaryClamped - ds.startDeltaKhz; if (ds.pointInitialDeltas.size > 1) { const edits = new Map(); ds.pointInitialDeltas.forEach((initialDelta, index) => { const newDelta = initialDelta + validDeltaDiff; const clampedNewDelta = Math.max( -1000_000, Math.min(1000_000, newDelta), ); edits.set(index, clampedNewDelta); }); stageMultiEdit(edits); } else { stageEdit(ds.pointIndex, primaryClamped); } // Update drag tooltip info const point = pts.find((p) => p.index === ds.pointIndex); if (point) { const cx = scalesRef.current.xScale(point.volt_mv); const deltaChange = primaryClamped - point.delta_khz; const cy = scalesRef.current.yScale( point.freq_mhz + deltaChange / 1000, ); setDragInfo({ pointIndex: ds.pointIndex, currentDeltaKhz: primaryClamped, cx, cy, }); } return; } // --- Pan --- if (isPanning.current && panState.current) { const inner = clientToInner(e.clientX, e.clientY); if (!inner) return; const ps = panState.current; // How many volts does 1 SVG-inner-px correspond to? const voltPerPx = (ps.startViewport[1] - ps.startViewport[0]) / scalesRef.current.innerW; const dxVolt = (inner.x - ps.startX) * voltPerPx; const [fullMin, fullMax] = voltExtent(pts); const pad = 20; const vpWidth = ps.startViewport[1] - ps.startViewport[0]; let newMin = ps.startViewport[0] - dxVolt; let newMax = ps.startViewport[1] - dxVolt; // Clamp so we don't pan completely outside if (newMin < fullMin - pad) { newMin = fullMin - pad; newMax = newMin + vpWidth; } if (newMax > fullMax + pad) { newMax = fullMax + pad; newMin = newMax - vpWidth; } setXViewport([newMin, newMax]); return; } // --- Box select --- if (isBoxSelecting.current && boxStartRef.current) { const inner = clientToInner(e.clientX, e.clientY); if (!inner) return; setBoxRect({ x0: boxStartRef.current.x, y0: boxStartRef.current.y, x1: inner.x, y1: inner.y, }); } } function onUp(e: MouseEvent) { // --- End drag --- if (dragState.current?.active) { dragState.current = null; setDragInfo(null); return; } // --- End pan --- if (isPanning.current) { isPanning.current = false; setSvgCursor("default"); if (panState.current) { const inner = clientToInner(e.clientX, e.clientY); if (inner) { const dx = Math.abs(inner.x - panState.current.startX); const dy = Math.abs(inner.y - panState.current.startY); if (dx < 3 && dy < 3) { // It was just a click on the background, not a pan, so clear selection clearSelection(); } } } panState.current = null; return; } // --- End box select --- if (isBoxSelecting.current && boxStartRef.current) { isBoxSelecting.current = false; const inner = clientToInner(e.clientX, e.clientY); if (inner) { const finalRect = { x0: boxStartRef.current.x, y0: boxStartRef.current.y, x1: inner.x, y1: inner.y, }; const minX = Math.min(finalRect.x0, finalRect.x1); const maxX = Math.max(finalRect.x0, finalRect.x1); const minY = Math.min(finalRect.y0, finalRect.y1); const maxY = Math.max(finalRect.y0, finalRect.y1); const sizeTrivial = Math.abs(finalRect.x1 - finalRect.x0) < 4 && Math.abs(finalRect.y1 - finalRect.y0) < 4; if (!sizeTrivial) { const { xScale: sx, yScale: ys } = scalesRef.current; const selected = pts .filter((p) => { const cx = sx(p.volt_mv); const cy = ys(effectiveMhz(p)); return cx >= minX && cx <= maxX && cy >= minY && cy <= maxY; }) .map((p) => p.index); selectRange(selected); } else { clearSelection(); } } boxStartRef.current = null; setBoxRect(null); } } window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; // Only re-register when structural deps change; scales are accessed via scalesRef // eslint-disable-next-line react-hooks/exhaustive-deps }, [ pts, pendingDeltas, stageEdit, selectRange, clearSelection, effectiveMhz, ]); return (
{/* Header: title + domain toggle + action buttons */}
V/F Curve {/* Domain toggle */}
{( [ { label: "GPU", value: "gpu" }, { label: "Memory", value: "memory" }, ] as const ).map((d) => ( ))}
{readOnly && ( read-only )}
{!readOnly && hasPending && ( {pendingDeltas.size} pending )} {!readOnly && ( <> )}
{/* Banners */} {hasNegativeFreqWarning() && (
⚠ One or more pending deltas would produce a negative effective frequency. The driver will clamp to 0 MHz.
)} {actionError && (
⚠ {actionError}
)} {/* Confirm dialogs */} {dialog === "apply" && ( setDialog(null)} /> )} {dialog === "reset" && ( setDialog(null)} /> )}
setXViewport(voltExtent(pts))} isZoomed={ Math.abs( xViewport[1] - xViewport[0] - (voltExtent(pts)[1] - voltExtent(pts)[0]), ) > 5 } readOnly={readOnly} zoomFactor={zoomFactor} onZoomChange={handleZoomChange} />
{/* Need tabIndex=0 to capture keyboard events */}
{ if (inlineInput) { if (e.key === "Escape") setInlineInput(null); return; // Let the input handle it } if (e.key === "a" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); selectRange(pts.map((p) => p.index)); return; } if (e.key === "Escape") { e.preventDefault(); clearSelection(); return; } if (e.key === "Tab") { e.preventDefault(); if (pts.length === 0) return; const currentSelected = Array.from(selectedPoints); if (currentSelected.length === 0) { selectPoint(pts[0].index); } else { const lastSelected = e.shiftKey ? Math.min(...currentSelected) : Math.max(...currentSelected); const idx = pts.findIndex((p) => p.index === lastSelected); if (idx >= 0) { let nextIdx = e.shiftKey ? idx - 1 : idx + 1; if (nextIdx < 0) nextIdx = pts.length - 1; if (nextIdx >= pts.length) nextIdx = 0; selectPoint(pts[nextIdx].index); } } return; } if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); if (selectedPoints.size === 0) return; const changeMhz = e.key === "ArrowUp" ? 1 : -1; const multiplier = e.ctrlKey || e.metaKey ? 10 : 1; const changeKhz = changeMhz * multiplier * 1000; const edits = new Map(); for (const idx of selectedPoints) { const p = pts.find((pt) => pt.index === idx); if (p) { const current = pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz; const clamped = Math.max( -500_000, Math.min(500_000, current + changeKhz), ); edits.set(idx, clamped); } } stageMultiEdit(edits); return; } if (e.key === "Enter") { e.preventDefault(); if (selectedPoints.size === 1) { const idx = Array.from(selectedPoints)[0]; const p = pts.find((pt) => pt.index === idx); if (p) { const initVal = (pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz) / 1000; setInlineInput({ pointIndex: idx, value: initVal.toFixed(1) }); // We'll focus the input in an effect } } return; } }} > {/* Grid */} {xTicks.map((t) => ( ))} {yTicks.map((t) => ( ))} {/* X axis */} {xTicks.map((t) => ( {t.toFixed(0)} ))} {/* Y axis */} {yTicks.map((t) => ( {t.toFixed(0)} ))} {/* Axis labels */} Voltage (mV) Frequency (MHz) {/* Border */} {/* Base curve */} {/* Confirmed effective curve */} {/* Pending effective curve */} {pendingLine && ( )} {/* Current voltage / clock crosshairs — GPU domain only */} {!readOnly && currentVoltageMv != null && xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W && ( )} {!readOnly && currentClockMhz != null && yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && ( )} {!readOnly && currentVoltageMv != null && currentClockMhz != null && xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W && yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && ( )} {pts.map((p) => { if ( p.volt_mv < xViewport[0] - 5 || p.volt_mv > xViewport[1] + 5 ) return null; const cx = xScale(p.volt_mv); /** Confirmed position (hardware state — VFP effective frequency) */ const confirmedCy = yScale(p.freq_mhz); /** Staged/pending position (what will be applied) */ const pendingCy = yScale(effectiveMhz(p)); const hasPendingEdit = pendingDeltas.has(p.index); /** * The "main" interactive circle sits at the pending position when * there's a staged edit — that's the point the user is working with. * Otherwise it sits at the confirmed position. */ const mainCy = hasPendingEdit ? pendingCy : confirmedCy; const ghostCy = hasPendingEdit ? confirmedCy : null; // dim ring showing where it was const isHovered = hoveredPoint?.index === p.index; const isSelected = selectedPoints.has(p.index); const isDragging = dragInfo?.pointIndex === p.index; const isAnchor = isSelected && selectedPoints.size >= 2 && p.index === anchorPoint; let fill = readOnly ? "#6366f1" : "#34d399"; if (!readOnly && hasPendingEdit && !isSelected) fill = "#22d3ee"; if (!readOnly && isSelected) fill = "#22d3ee"; // Tweaked radius for less bloated flat regions const r = isDragging ? 5.5 : isHovered || isSelected || hasPendingEdit ? 4.5 : 2.5; return ( {/* Anchor ring — amber outer halo marking the flatten reference point */} {isAnchor && ( )} {/* Dim ring at confirmed position (only visible when there's a pending edit) */} {!readOnly && ghostCy !== null && Math.abs(ghostCy - mainCy) > 0.5 && ( )} {/* Drop-line from confirmed → pending while dragging */} {!readOnly && isDragging && ghostCy !== null && Math.abs(ghostCy - mainCy) > 1 && ( )} {/* Main circle */} handleMouseEnter(p)} onMouseLeave={handleMouseLeave} onMouseDown={ readOnly ? undefined : (e) => handlePointMouseDown(e, p) } onClick={ readOnly ? undefined : (e) => { if (dragMoved.current) return; if (e.shiftKey) { togglePoint(p.index); } else { selectPoint(p.index); } } } /> ); })} {/* Box-select rubber band */} {boxRect && ( )} {/* Inline Input Overlay */} {/* Overlay position is measured from live DOM geometry + current scales on every render — refs are required here and always fresh. */} {/* eslint-disable-next-line react-hooks/refs */} {inlineInput && (() => { const pt = pts.find((p) => p.index === inlineInput.pointIndex); if (!pt) return null; const cx = xScale(pt.volt_mv); const currentDelta = pendingDeltas.has(pt.index) ? pendingDeltas.get(pt.index)! : pt.delta_khz; const deltaChange = currentDelta - pt.delta_khz; const cy = yScale(pt.freq_mhz + deltaChange / 1000); const pos = svgToContainer(cx, cy); if (!pos) return null; return (
setInlineInput({ ...inlineInput, value: e.target.value }) } onBlur={() => setInlineInput(null)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); const mhz = parseFloat(inlineInput.value); if (!isNaN(mhz) && isFinite(mhz)) { const clamped = Math.max( -500_000, Math.min(500_000, Math.round(mhz * 1000)), ); stageEdit(pt.index, clamped); } setInlineInput(null); // Refocus the container containerRef.current?.focus(); } if (e.key === "Escape") { setInlineInput(null); containerRef.current?.focus(); } }} className="w-20 bg-zinc-800 text-cyan-300 rounded px-1.5 py-0.5 border-2 border-cyan-500 shadow-lg outline-none text-xs font-mono text-center" />
); })()} {/* Hover / Active tooltip */} {(() => { if (dragInfo) return null; let tooltipPt = hoveredPoint; if (!tooltipPt && selectedPoints.size === 1) { tooltipPt = pts.find((p) => p.index === Array.from(selectedPoints)[0]) || null; } if (!tooltipPt) return null; return ( ); })()} {/* Drag tooltip — shown while actively dragging */} {dragInfo && (() => { const point = pts.find((p) => p.index === dragInfo.pointIndex); if (!point) return null; const deltaMhz = dragInfo.currentDeltaKhz / 1000; const deltaChange = dragInfo.currentDeltaKhz - point.delta_khz; const effMhz = point.freq_mhz + deltaChange / 1000; return (
↕ Point {point.index} {point.volt_mv.toFixed(0)} mV
0 ? "text-cyan-400" : deltaMhz < 0 ? "text-orange-400" : "text-zinc-400"}`} > Δ {deltaMhz > 0 ? "+" : ""} {deltaMhz.toFixed(1)} MHz
→ {effMhz.toFixed(0)} MHz
); })()}
); }