Backend (nvcurve/): - hal/fans.py, hal/limits.py, hal/gpu.py: replace conditional pynvml imports with the established 'pynvml: Any = _pynvml_import' pattern (fixes ~50 'possibly unbound' errors); type the result dicts; guard query_interface() results; explicit uuid/pci-bus parsing (int, hex convention documented); modernize Optional[T] -> T | None - cli.py: fix 'curve_state' possibly-unbound and snap_path None handling in cmd_setup; wrap unchecked int()/open()/makedirs() calls in try/except with clean CLI errors; add module logger for silent except-pass blocks; raise ... from exc; fix unused loop vars and set-comprehension - hal/snapshot.py: filepath: str | None; wrap all file ops; sorted imports; remove unused CT_POINTS import - daemon.py: extract 0o666 to _SOCKET_MODE constant (intentional for /run sockets) with nosemgrep - server.py: nosemgrep for Python 3.7-compat false positive (project requires >= 3.12); log previously-swallowed exception - profiles/native.py, profiles/apply.py: wrap file ops and int(k) profile-key parsing; sorted imports; modernize typing Frontend (frontend/src): - Add .js extensions to all relative imports (standard TS-ESM; Vite resolves .js -> .ts) - React.FormEvent (deprecated in React 19 types) -> React.SubmitEvent - catch (e: any) -> catch (e: unknown) + instanceof Error narrowing - React-hooks: move ref writes from render into effects; convert viewport reset to render-phase state adjustment; split selectPoint(index, multi) into selectPoint + togglePoint (no flag argument); remove non-null assertion - Static inline styles -> Tailwind classes (dynamic positioning/cursor styles kept) - Remove non-standard 'container' option from scrollIntoView (browsers ignore unknown options) which had orphaned a @ts-expect-error - Object.fromEntries for Map -> Record conversion Tooling: - .gitignore: ignore .codegraph/ local tool data Verified: tsc --noEmit, vite production build, python imports, and full LSP scan (0 errors/warnings in both projects).
176 lines
5.7 KiB
TypeScript
176 lines
5.7 KiB
TypeScript
import { useState, useMemo } from "react";
|
||
import { ZoomIn, RotateCcw, Minus } from "lucide-react";
|
||
import { useCurveStore } from "../../store/curveStore.js";
|
||
import type { VFPoint } from "../../types.js";
|
||
|
||
interface Props {
|
||
/** All curve points — used by global offset slider */
|
||
activePts: VFPoint[];
|
||
/** Called to reset the x-axis zoom to default */
|
||
onResetZoom: () => void;
|
||
/** True if the chart is currently zoomed in */
|
||
isZoomed: boolean;
|
||
/** True when viewing a read-only domain (memory) */
|
||
readOnly?: boolean;
|
||
/** Current zoom factor (1 = no zoom) */
|
||
zoomFactor: number;
|
||
/** Called when user changes zoom via slider */
|
||
onZoomChange: (factor: number) => void;
|
||
}
|
||
|
||
export function CurveToolbar({
|
||
activePts,
|
||
onResetZoom,
|
||
isZoomed,
|
||
readOnly,
|
||
zoomFactor,
|
||
onZoomChange,
|
||
}: Props) {
|
||
const {
|
||
pendingDeltas,
|
||
selectedPoints,
|
||
anchorPoint,
|
||
curve,
|
||
stageRangeEdit,
|
||
flattenToAnchor,
|
||
} = useCurveStore();
|
||
|
||
const [offsetMhz, setOffsetMhz] = useState(0);
|
||
|
||
const uniformDeltaMhz = useMemo(() => {
|
||
if (activePts.length === 0) return 0;
|
||
const firstD =
|
||
pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
|
||
const uniform = activePts.every(
|
||
(p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD,
|
||
);
|
||
return uniform ? firstD / 1000 : null;
|
||
}, [activePts, pendingDeltas]);
|
||
|
||
// Sync the slider to the uniform delta when it changes (adjust state during
|
||
// render instead of an effect; undefined sentinel so the first render syncs).
|
||
const [lastUniformDelta, setLastUniformDelta] = useState<
|
||
number | null | undefined
|
||
>();
|
||
if (uniformDeltaMhz !== null && lastUniformDelta !== uniformDeltaMhz) {
|
||
setLastUniformDelta(uniformDeltaMhz);
|
||
setOffsetMhz(uniformDeltaMhz);
|
||
}
|
||
|
||
function handleOffsetChange(mhz: number) {
|
||
setOffsetMhz(mhz);
|
||
stageRangeEdit(activePts, Math.round(mhz * 1000));
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2 px-1 pb-2">
|
||
{/* Zoom control */}
|
||
<div
|
||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40"
|
||
title="Zoom x-axis (Alt+scroll also works)"
|
||
>
|
||
<ZoomIn size={11} className="text-zinc-500 shrink-0" />
|
||
<input
|
||
type="range"
|
||
min={1}
|
||
max={10}
|
||
step={0.1}
|
||
value={zoomFactor}
|
||
onChange={(e) => onZoomChange(Number(e.target.value))}
|
||
className="w-20 h-1 cursor-pointer accent-cyan-400"
|
||
/>
|
||
<span
|
||
className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? "text-cyan-400" : "text-zinc-600"}`}
|
||
>
|
||
{zoomFactor.toFixed(1)}×
|
||
</span>
|
||
{isZoomed && (
|
||
<button
|
||
onClick={onResetZoom}
|
||
title="Reset zoom"
|
||
className="text-zinc-500 hover:text-zinc-300 transition-colors ml-0.5"
|
||
>
|
||
<RotateCcw size={11} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<span className="w-px h-6 bg-zinc-800 mx-1" />
|
||
|
||
{/* Global offset slider — GPU only */}
|
||
{!readOnly && uniformDeltaMhz !== null && (
|
||
<div className="flex items-center gap-1.5 min-w-[260px]">
|
||
<span className="text-zinc-500 text-xs whitespace-nowrap">
|
||
Global Offset
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min={-1000}
|
||
max={1000}
|
||
step={5}
|
||
value={offsetMhz}
|
||
onChange={(e) => handleOffsetChange(Number(e.target.value))}
|
||
className="w-32 accent-cyan-400"
|
||
title={`${offsetMhz > 0 ? "+" : ""}${offsetMhz} MHz`}
|
||
/>
|
||
<span
|
||
className={[
|
||
"text-xs font-mono w-16",
|
||
offsetMhz > 0
|
||
? "text-cyan-400"
|
||
: offsetMhz < 0
|
||
? "text-orange-400"
|
||
: "text-zinc-500",
|
||
].join(" ")}
|
||
>
|
||
{offsetMhz > 0 ? "+" : ""}
|
||
{offsetMhz} MHz
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Flatten — visible when 2+ points are selected */}
|
||
{!readOnly &&
|
||
selectedPoints.size >= 2 &&
|
||
(() => {
|
||
const anchor =
|
||
anchorPoint !== null && selectedPoints.has(anchorPoint)
|
||
? anchorPoint
|
||
: Math.min(...selectedPoints);
|
||
const anchorDelta =
|
||
pendingDeltas.get(anchor) ??
|
||
curve?.points.find((p) => p.index === anchor)?.delta_khz ??
|
||
0;
|
||
const label = `·${anchor} ${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz`;
|
||
return (
|
||
<button
|
||
onClick={flattenToAnchor}
|
||
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
|
||
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz)`}
|
||
>
|
||
<Minus size={11} />
|
||
Flatten to {label}
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* Legend — right-aligned */}
|
||
<div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto">
|
||
<span className="flex items-center gap-1">
|
||
<span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" />{" "}
|
||
effective
|
||
</span>
|
||
<span className="flex items-center gap-1">
|
||
<span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" />{" "}
|
||
pending
|
||
</span>
|
||
<span className="flex items-center gap-1">
|
||
<span className="inline-block w-2 h-2 rounded-full bg-yellow-400" />{" "}
|
||
current
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|