From 6f64d29be9e1c6432794d484ac0fc93a18df41c9 Mon Sep 17 00:00:00 2001 From: royliz3090-jpg Date: Sat, 6 Jun 2026 16:31:56 +0000 Subject: [PATCH] [agent] feat: add net-based trace hover highlighting --- lib/components/SchematicTraceMouseTarget.tsx | 155 +++++++++++++++++++ lib/components/SchematicViewer.tsx | 82 ++++++++++ 2 files changed, 237 insertions(+) create mode 100644 lib/components/SchematicTraceMouseTarget.tsx diff --git a/lib/components/SchematicTraceMouseTarget.tsx b/lib/components/SchematicTraceMouseTarget.tsx new file mode 100644 index 0000000..30d12d9 --- /dev/null +++ b/lib/components/SchematicTraceMouseTarget.tsx @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { useMouseEventsOverBoundingBox } from "../hooks/useMouseEventsOverBoundingBox" +import type { BoundingBoxBounds } from "./MouseTracker" +import { zIndexMap } from "../utils/z-index-map" + +interface RelativeRect { + left: number + top: number + width: number + height: number +} + +interface Measurement { + bounds: BoundingBoxBounds + rect: RelativeRect +} + +const areMeasurementsEqual = (a: Measurement | null, b: Measurement | null) => { + if (!a && !b) return true + if (!a || !b) return false + return ( + Math.abs(a.bounds.minX - b.bounds.minX) < 0.5 && + Math.abs(a.bounds.maxX - b.bounds.maxX) < 0.5 && + Math.abs(a.bounds.minY - b.bounds.minY) < 0.5 && + Math.abs(a.bounds.maxY - b.bounds.maxY) < 0.5 && + Math.abs(a.rect.left - b.rect.left) < 0.5 && + Math.abs(a.rect.top - b.rect.top) < 0.5 && + Math.abs(a.rect.width - b.rect.width) < 0.5 && + Math.abs(a.rect.height - b.rect.height) < 0.5 + ) +} + +interface Props { + traceId: string + svgDivRef: React.RefObject + containerRef: React.RefObject + onHoverChange?: (traceId: string, isHovering: boolean) => void + circuitJsonKey: string +} + +export const SchematicTraceMouseTarget = ({ + traceId, + svgDivRef, + containerRef, + onHoverChange, + circuitJsonKey, +}: Props) => { + const [measurement, setMeasurement] = useState(null) + const frameRef = useRef(null) + + const measure = useCallback(() => { + frameRef.current = null + const svgDiv = svgDivRef.current + const container = containerRef.current + if (!svgDiv || !container) { + setMeasurement((prev) => (prev ? null : prev)) + return + } + const element = svgDiv.querySelector( + `[data-schematic-trace-id="${traceId}"]`, + ) + if (!element) { + setMeasurement((prev) => (prev ? null : prev)) + return + } + + const elementRect = element.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + const nextMeasurement: Measurement = { + bounds: { + minX: elementRect.left, + maxX: elementRect.right, + minY: elementRect.top, + maxY: elementRect.bottom, + }, + rect: { + left: elementRect.left - containerRect.left, + top: elementRect.top - containerRect.top, + width: elementRect.width, + height: elementRect.height, + }, + } + + setMeasurement((prev) => + areMeasurementsEqual(prev, nextMeasurement) ? prev : nextMeasurement, + ) + }, [traceId, containerRef, svgDivRef]) + + const scheduleMeasure = useCallback(() => { + if (frameRef.current !== null) return + frameRef.current = window.requestAnimationFrame(measure) + }, [measure]) + + useEffect(() => { + scheduleMeasure() + }, [scheduleMeasure, circuitJsonKey]) + + useEffect(() => { + scheduleMeasure() + const svgDiv = svgDivRef.current + const container = containerRef.current + if (!svgDiv || !container) return + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => { + scheduleMeasure() + }) + : null + resizeObserver?.observe(container) + resizeObserver?.observe(svgDiv) + + const mutationObserver = + typeof MutationObserver !== "undefined" + ? new MutationObserver(() => { + scheduleMeasure() + }) + : null + mutationObserver?.observe(svgDiv, { + attributes: true, + attributeFilter: ["style", "transform"], + subtree: true, + childList: true, + }) + + window.addEventListener("scroll", scheduleMeasure, true) + window.addEventListener("resize", scheduleMeasure) + + return () => { + resizeObserver?.disconnect() + mutationObserver?.disconnect() + window.removeEventListener("scroll", scheduleMeasure, true) + window.removeEventListener("resize", scheduleMeasure) + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + } + }, [scheduleMeasure, svgDivRef, containerRef]) + + const bounds = measurement?.bounds ?? null + + const { hovering } = useMouseEventsOverBoundingBox({ + bounds, + }) + + useEffect(() => { + if (onHoverChange) { + onHoverChange(traceId, hovering) + } + }, [hovering, traceId, onHoverChange]) + + return null +} diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index ab4fd20..76a3176 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -31,6 +31,7 @@ import { getStoredBoolean, setStoredBoolean } from "lib/hooks/useLocalStorage" import { MouseTracker } from "./MouseTracker" import { SchematicComponentMouseTarget } from "./SchematicComponentMouseTarget" import { SchematicPortMouseTarget } from "./SchematicPortMouseTarget" +import { SchematicTraceMouseTarget } from "./SchematicTraceMouseTarget" interface Props { circuitJson: CircuitJson @@ -164,9 +165,49 @@ export const SchematicViewer = ({ [], ) + const [hoveredNetId, setHoveredNetId] = useState(null) + const hoveringTracesRef = useRef>(new Map()) + + const handleTraceHoverChange = useCallback( + (traceId: string, isHovering: boolean) => { + const trace = su(circuitJson).schematic_trace.get(traceId) + const sourceTraceId = trace?.source_trace_id + const sourceTrace = sourceTraceId + ? su(circuitJson).source_trace.get(sourceTraceId) + : null + const netId = sourceTrace?.connected_source_net_ids?.[0] + + if (isHovering && netId) { + hoveringTracesRef.current.set(traceId, netId) + } else { + hoveringTracesRef.current.delete(traceId) + } + + // Update hoveredNetId based on the most recent hover + const activeNetIds = Array.from(hoveringTracesRef.current.values()) + setHoveredNetId( + activeNetIds.length > 0 ? activeNetIds[activeNetIds.length - 1] : null, + ) + }, + [circuitJson], + ) + const svgDivRef = useRef(null) const touchStartRef = useRef<{ x: number; y: number } | null>(null) + const schematicTraceIds = useMemo(() => { + try { + return ( + su(circuitJson) + .schematic_trace?.list() + ?.map((trace) => trace.schematic_trace_id as string) ?? [] + ) + } catch (err) { + console.error("Failed to derive schematic trace ids", err) + return [] + } + }, [circuitJsonKey, circuitJson]) + const schematicComponentIds = useMemo(() => { try { return ( @@ -406,6 +447,37 @@ export const SchematicViewer = ({ {`[data-schematic-port-id]:hover { cursor: pointer !important; }`} )} +
))} + {schematicTraceIds.map((traceId) => ( + + ))} {svgDiv} {showSchematicPorts && schematicPortsInfo.map(({ portId, label }) => (