From f1c8e4d22140824eaab80ca8563961a999338466 Mon Sep 17 00:00:00 2001 From: LiamConner10 Date: Thu, 25 Dec 2025 13:47:31 -0600 Subject: [PATCH 1/2] feat: add trace hover highlighting for same-net traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When hovering over a trace, all traces in the same electrical net are now highlighted together. This uses connected_source_net_ids to properly group traces by electrical connectivity. Fixes #1130 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/components/SchematicViewer.tsx | 8 + lib/hooks/useTraceHoverHighlighting.ts | 196 +++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 lib/hooks/useTraceHoverHighlighting.ts diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 51e782a..2416886 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -6,6 +6,7 @@ import { su } from "@tscircuit/soup-util" import { useChangeSchematicComponentLocationsInSvg } from "lib/hooks/useChangeSchematicComponentLocationsInSvg" import { useChangeSchematicTracesForMovedComponents } from "lib/hooks/useChangeSchematicTracesForMovedComponents" import { useSchematicGroupsOverlay } from "lib/hooks/useSchematicGroupsOverlay" +import { useTraceHoverHighlighting } from "lib/hooks/useTraceHoverHighlighting" import { enableDebug } from "lib/utils/debug" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { @@ -294,6 +295,13 @@ export const SchematicViewer = ({ showGroups: showSchematicGroups && !disableGroups, }) + // Add trace hover highlighting (disabled during edit mode and SPICE overlay) + useTraceHoverHighlighting({ + svgDivRef, + circuitJson, + enabled: !editModeEnabled && !showSpiceOverlay, + }) + // keep the latest touch handler without re-rendering the svg div const handleComponentTouchStartRef = useRef(handleComponentTouchStart) useEffect(() => { diff --git a/lib/hooks/useTraceHoverHighlighting.ts b/lib/hooks/useTraceHoverHighlighting.ts new file mode 100644 index 0000000..7cd79fa --- /dev/null +++ b/lib/hooks/useTraceHoverHighlighting.ts @@ -0,0 +1,196 @@ +import { useEffect, useRef, useCallback } from "react" +import { su } from "@tscircuit/soup-util" +import type { CircuitJson } from "circuit-json" + +/** + * This hook highlights all traces in the same electrical net when any trace is hovered. + * An electrical "net" is a group of traces that share the same source_trace_id. + */ +export const useTraceHoverHighlighting = ({ + svgDivRef, + circuitJson, + enabled = true, +}: { + svgDivRef: React.RefObject + circuitJson: CircuitJson + enabled?: boolean +}) => { + const hoveredTraceIdRef = useRef(null) + const timeoutRef = useRef | null>(null) + + const highlightTraces = useCallback( + (schematicTraceIds: string[], highlight: boolean) => { + const svg = svgDivRef.current + if (!svg) return + + for (const traceId of schematicTraceIds) { + const traceElements = svg.querySelectorAll( + `[data-schematic-trace-id="${traceId}"] path` + ) + for (const el of Array.from(traceElements)) { + if (el.getAttribute("class")?.includes("invisible")) continue + + if (highlight) { + ;(el as HTMLElement).style.stroke = "#ff6b00" + ;(el as HTMLElement).style.strokeWidth = "8" + ;(el as HTMLElement).style.filter = + "drop-shadow(0 0 4px rgba(255, 107, 0, 0.6))" + } else { + ;(el as HTMLElement).style.stroke = "" + ;(el as HTMLElement).style.strokeWidth = "" + ;(el as HTMLElement).style.filter = "" + } + } + } + }, + [svgDivRef] + ) + + const getRelatedTraceIds = useCallback( + (schematicTraceId: string): string[] => { + try { + // Find the schematic trace + const schematicTrace = su(circuitJson).schematic_trace.get(schematicTraceId) + if (!schematicTrace) return [schematicTraceId] + + const sourceTraceId = schematicTrace.source_trace_id + if (!sourceTraceId) return [schematicTraceId] + + // Get the source trace to find its connected net IDs + const sourceTrace = su(circuitJson).source_trace.get(sourceTraceId) + if (!sourceTrace) { + // Fallback: just find traces with same source_trace_id + const relatedTraces = su(circuitJson) + .schematic_trace.list() + .filter((st) => st.source_trace_id === sourceTraceId) + return relatedTraces.map((t) => t.schematic_trace_id) + } + + // Get net IDs this trace is connected to + const connectedNetIds = new Set(sourceTrace.connected_source_net_ids || []) + + if (connectedNetIds.size === 0) { + // No nets, just use source_trace_id grouping + const relatedTraces = su(circuitJson) + .schematic_trace.list() + .filter((st) => st.source_trace_id === sourceTraceId) + return relatedTraces.map((t) => t.schematic_trace_id) + } + + // Find ALL source traces that share any of these net IDs + const allSourceTraces = su(circuitJson).source_trace.list() + const relatedSourceTraceIds = new Set() + + for (const st of allSourceTraces) { + const stNetIds = st.connected_source_net_ids || [] + // Check if this trace shares any net with the hovered trace + if (stNetIds.some((netId: string) => connectedNetIds.has(netId))) { + relatedSourceTraceIds.add(st.source_trace_id) + } + } + + // Find all schematic traces for these source traces + const relatedSchematicTraces = su(circuitJson) + .schematic_trace.list() + .filter((st) => st.source_trace_id && relatedSourceTraceIds.has(st.source_trace_id)) + + return relatedSchematicTraces.map((t) => t.schematic_trace_id) + } catch { + return [schematicTraceId] + } + }, + [circuitJson] + ) + + useEffect(() => { + if (!enabled) return + + const svg = svgDivRef.current + if (!svg) return + + const handleMouseEnter = (e: Event) => { + // Clear any pending timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + + const target = e.target as HTMLElement + const traceGroup = target.closest("[data-schematic-trace-id]") + if (!traceGroup) return + + const schematicTraceId = traceGroup.getAttribute("data-schematic-trace-id") + if (!schematicTraceId) return + + // If already hovering this trace, do nothing + if (hoveredTraceIdRef.current === schematicTraceId) return + + // Clear previous highlight + if (hoveredTraceIdRef.current) { + const prevRelatedIds = getRelatedTraceIds(hoveredTraceIdRef.current) + highlightTraces(prevRelatedIds, false) + } + + // Set new hover state and highlight + hoveredTraceIdRef.current = schematicTraceId + const relatedTraceIds = getRelatedTraceIds(schematicTraceId) + highlightTraces(relatedTraceIds, true) + } + + const handleMouseLeave = (e: Event) => { + const target = e.target as HTMLElement + const traceGroup = target.closest("[data-schematic-trace-id]") + if (!traceGroup) return + + const schematicTraceId = traceGroup.getAttribute("data-schematic-trace-id") + if (!schematicTraceId) return + + // Add small delay to prevent flickering when moving between connected traces + timeoutRef.current = setTimeout(() => { + if (hoveredTraceIdRef.current) { + const relatedTraceIds = getRelatedTraceIds(hoveredTraceIdRef.current) + highlightTraces(relatedTraceIds, false) + hoveredTraceIdRef.current = null + } + }, 50) + } + + const attachListeners = () => { + const tracePaths = svg.querySelectorAll( + '[data-circuit-json-type="schematic_trace"] path' + ) + for (const path of Array.from(tracePaths)) { + path.addEventListener("mouseenter", handleMouseEnter) + path.addEventListener("mouseleave", handleMouseLeave) + } + } + + const detachListeners = () => { + const tracePaths = svg.querySelectorAll( + '[data-circuit-json-type="schematic_trace"] path' + ) + for (const path of Array.from(tracePaths)) { + path.removeEventListener("mouseenter", handleMouseEnter) + path.removeEventListener("mouseleave", handleMouseLeave) + } + } + + // Attach listeners initially + attachListeners() + + // Re-attach on DOM changes (SVG re-render) + const observer = new MutationObserver(() => { + detachListeners() + attachListeners() + }) + observer.observe(svg, { childList: true, subtree: false }) + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + detachListeners() + observer.disconnect() + } + }, [svgDivRef, circuitJson, enabled, highlightTraces, getRelatedTraceIds]) +} From 1df3b9de8fc2b1d3275176198ae2e15ee80549e3 Mon Sep 17 00:00:00 2001 From: LiamConner10 Date: Thu, 25 Dec 2025 13:53:54 -0600 Subject: [PATCH 2/2] style: apply biome formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/hooks/useTraceHoverHighlighting.ts | 31 +++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/lib/hooks/useTraceHoverHighlighting.ts b/lib/hooks/useTraceHoverHighlighting.ts index 7cd79fa..c7b6486 100644 --- a/lib/hooks/useTraceHoverHighlighting.ts +++ b/lib/hooks/useTraceHoverHighlighting.ts @@ -25,7 +25,7 @@ export const useTraceHoverHighlighting = ({ for (const traceId of schematicTraceIds) { const traceElements = svg.querySelectorAll( - `[data-schematic-trace-id="${traceId}"] path` + `[data-schematic-trace-id="${traceId}"] path`, ) for (const el of Array.from(traceElements)) { if (el.getAttribute("class")?.includes("invisible")) continue @@ -43,14 +43,15 @@ export const useTraceHoverHighlighting = ({ } } }, - [svgDivRef] + [svgDivRef], ) const getRelatedTraceIds = useCallback( (schematicTraceId: string): string[] => { try { // Find the schematic trace - const schematicTrace = su(circuitJson).schematic_trace.get(schematicTraceId) + const schematicTrace = + su(circuitJson).schematic_trace.get(schematicTraceId) if (!schematicTrace) return [schematicTraceId] const sourceTraceId = schematicTrace.source_trace_id @@ -67,7 +68,9 @@ export const useTraceHoverHighlighting = ({ } // Get net IDs this trace is connected to - const connectedNetIds = new Set(sourceTrace.connected_source_net_ids || []) + const connectedNetIds = new Set( + sourceTrace.connected_source_net_ids || [], + ) if (connectedNetIds.size === 0) { // No nets, just use source_trace_id grouping @@ -92,14 +95,18 @@ export const useTraceHoverHighlighting = ({ // Find all schematic traces for these source traces const relatedSchematicTraces = su(circuitJson) .schematic_trace.list() - .filter((st) => st.source_trace_id && relatedSourceTraceIds.has(st.source_trace_id)) + .filter( + (st) => + st.source_trace_id && + relatedSourceTraceIds.has(st.source_trace_id), + ) return relatedSchematicTraces.map((t) => t.schematic_trace_id) } catch { return [schematicTraceId] } }, - [circuitJson] + [circuitJson], ) useEffect(() => { @@ -119,7 +126,9 @@ export const useTraceHoverHighlighting = ({ const traceGroup = target.closest("[data-schematic-trace-id]") if (!traceGroup) return - const schematicTraceId = traceGroup.getAttribute("data-schematic-trace-id") + const schematicTraceId = traceGroup.getAttribute( + "data-schematic-trace-id", + ) if (!schematicTraceId) return // If already hovering this trace, do nothing @@ -142,7 +151,9 @@ export const useTraceHoverHighlighting = ({ const traceGroup = target.closest("[data-schematic-trace-id]") if (!traceGroup) return - const schematicTraceId = traceGroup.getAttribute("data-schematic-trace-id") + const schematicTraceId = traceGroup.getAttribute( + "data-schematic-trace-id", + ) if (!schematicTraceId) return // Add small delay to prevent flickering when moving between connected traces @@ -157,7 +168,7 @@ export const useTraceHoverHighlighting = ({ const attachListeners = () => { const tracePaths = svg.querySelectorAll( - '[data-circuit-json-type="schematic_trace"] path' + '[data-circuit-json-type="schematic_trace"] path', ) for (const path of Array.from(tracePaths)) { path.addEventListener("mouseenter", handleMouseEnter) @@ -167,7 +178,7 @@ export const useTraceHoverHighlighting = ({ const detachListeners = () => { const tracePaths = svg.querySelectorAll( - '[data-circuit-json-type="schematic_trace"] path' + '[data-circuit-json-type="schematic_trace"] path', ) for (const path of Array.from(tracePaths)) { path.removeEventListener("mouseenter", handleMouseEnter)