From aa5e36d25f59a566b85a6196afd409588e0c8fc8 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Sat, 7 Feb 2026 19:33:46 -0500 Subject: [PATCH 1/3] feat: highlight traces and same-net traces on hover When hovering over a schematic trace, the trace and all other traces connected to the same net change color to indicate interactivity. Net groups are precomputed from circuit-json relationships for efficient pointer event handling. Closes tscircuit/tscircuit#1130 --- lib/components/SchematicViewer.tsx | 6 + lib/hooks/useTraceHoverHighlight.ts | 166 ++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 lib/hooks/useTraceHoverHighlight.ts diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 791abf9..4b1428c 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -5,6 +5,7 @@ import { import { su } from "@tscircuit/soup-util" import { useChangeSchematicComponentLocationsInSvg } from "lib/hooks/useChangeSchematicComponentLocationsInSvg" import { useChangeSchematicTracesForMovedComponents } from "lib/hooks/useChangeSchematicTracesForMovedComponents" +import { useTraceHoverHighlight } from "lib/hooks/useTraceHoverHighlight" import { useSchematicGroupsOverlay } from "lib/hooks/useSchematicGroupsOverlay" import { enableDebug } from "lib/utils/debug" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -338,6 +339,11 @@ export const SchematicViewer = ({ editEvents: editEventsWithUnappliedEditEvents, }) + useTraceHoverHighlight({ + svgDivRef, + circuitJson, + }) + // Add group overlays when enabled useSchematicGroupsOverlay({ svgDivRef, diff --git a/lib/hooks/useTraceHoverHighlight.ts b/lib/hooks/useTraceHoverHighlight.ts new file mode 100644 index 0000000..f9ee851 --- /dev/null +++ b/lib/hooks/useTraceHoverHighlight.ts @@ -0,0 +1,166 @@ +import { useEffect } from "react" +import { su } from "@tscircuit/soup-util" +import type { CircuitJson } from "circuit-json" + +const HIGHLIGHT_COLOR = "#60a5fa" + +/** + * This hook highlights traces on hover and all traces connected to the same net + */ +export const useTraceHoverHighlight = ({ + svgDivRef, + circuitJson, +}: { + svgDivRef: React.RefObject + circuitJson: CircuitJson +}) => { + useEffect(() => { + const svg = svgDivRef.current + if (!svg) return + + const sourceTraces = su(circuitJson).source_trace.list() + const schematicTraces = su(circuitJson).schematic_trace.list() + + if (schematicTraces.length === 0) return + + // Build schematic_trace_id → source_trace_id + const schematicToSource = new Map() + for (const st of schematicTraces) { + if (st.source_trace_id) { + schematicToSource.set(st.schematic_trace_id, st.source_trace_id) + } + } + + // Build source_port_id → Set + const portToSources = new Map>() + for (const st of sourceTraces) { + for (const portId of st.connected_source_port_ids ?? []) { + if (!portToSources.has(portId)) portToSources.set(portId, new Set()) + portToSources.get(portId)!.add(st.source_trace_id) + } + } + + // Build source_trace_id → Set + const sourceToSchematics = new Map>() + for (const st of schematicTraces) { + if (!st.source_trace_id) continue + if (!sourceToSchematics.has(st.source_trace_id)) { + sourceToSchematics.set(st.source_trace_id, new Set()) + } + sourceToSchematics.get(st.source_trace_id)!.add(st.schematic_trace_id) + } + + // Precompute net groups: traces sharing connected ports are on the same net + const traceNetMap = new Map() + const netTraces = new Map>() + let nextNetId = 0 + + for (const st of schematicTraces) { + if (traceNetMap.has(st.schematic_trace_id)) continue + + const visited = new Set() + const queue = [st.schematic_trace_id] + + while (queue.length > 0) { + const currentSchId = queue.pop()! + if (visited.has(currentSchId)) continue + visited.add(currentSchId) + + const srcId = schematicToSource.get(currentSchId) + if (!srcId) continue + + const srcTrace = sourceTraces.find((s) => s.source_trace_id === srcId) + if (!srcTrace) continue + + for (const portId of srcTrace.connected_source_port_ids ?? []) { + const connectedSrcIds = portToSources.get(portId) + if (!connectedSrcIds) continue + for (const connSrcId of connectedSrcIds) { + const schIds = sourceToSchematics.get(connSrcId) + if (!schIds) continue + for (const schId of schIds) { + if (!visited.has(schId)) queue.push(schId) + } + } + } + } + + const netId = nextNetId++ + netTraces.set(netId, visited) + for (const id of visited) { + traceNetMap.set(id, netId) + } + } + + // Hover state + const originalStrokes = new Map() + let currentNetId: number | null = null + + const clearHighlights = () => { + for (const [el, stroke] of originalStrokes) { + el.setAttribute("stroke", stroke) + } + originalStrokes.clear() + currentNetId = null + } + + const applyHighlights = (netId: number) => { + const traceIds = netTraces.get(netId) + if (!traceIds) return + + for (const traceId of traceIds) { + const paths = svg.querySelectorAll( + `[data-schematic-trace-id="${traceId}"] path`, + ) + for (const path of Array.from(paths)) { + if (path.getAttribute("class")?.includes("invisible")) continue + originalStrokes.set(path, path.getAttribute("stroke") || "") + path.setAttribute("stroke", HIGHLIGHT_COLOR) + } + } + currentNetId = netId + } + + const handlePointerMove = (e: PointerEvent) => { + const target = e.target as Element + if (!target?.closest) return + + const traceGroup = target.closest("[data-schematic-trace-id]") + if (!traceGroup) { + if (currentNetId !== null) clearHighlights() + return + } + + const traceId = traceGroup.getAttribute("data-schematic-trace-id") + if (!traceId) { + if (currentNetId !== null) clearHighlights() + return + } + + const netId = traceNetMap.get(traceId) + if (netId === undefined) { + if (currentNetId !== null) clearHighlights() + return + } + + // Already highlighting this net + if (netId === currentNetId) return + + clearHighlights() + applyHighlights(netId) + } + + const handlePointerLeave = () => { + clearHighlights() + } + + svg.addEventListener("pointermove", handlePointerMove) + svg.addEventListener("pointerleave", handlePointerLeave) + + return () => { + clearHighlights() + svg.removeEventListener("pointermove", handlePointerMove) + svg.removeEventListener("pointerleave", handlePointerLeave) + } + }, [svgDivRef, circuitJson]) +} From 121fbc707807a65d7509ee1806bee1611cf739af Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Sat, 7 Feb 2026 19:51:51 -0500 Subject: [PATCH 2/3] fix: use SVG net attributes for correct same-net trace grouping The previous approach using BFS over circuit-json source_trace relationships missed connections. The SVG already encodes correct net grouping via data-subcircuit-connectivity-map-key attributes set by circuit-to-svg. This is simpler and handles all edge cases including traces without net keys. --- lib/hooks/useTraceHoverHighlight.ts | 133 +++++++--------------------- 1 file changed, 33 insertions(+), 100 deletions(-) diff --git a/lib/hooks/useTraceHoverHighlight.ts b/lib/hooks/useTraceHoverHighlight.ts index f9ee851..8ca7974 100644 --- a/lib/hooks/useTraceHoverHighlight.ts +++ b/lib/hooks/useTraceHoverHighlight.ts @@ -1,11 +1,11 @@ import { useEffect } from "react" -import { su } from "@tscircuit/soup-util" import type { CircuitJson } from "circuit-json" const HIGHLIGHT_COLOR = "#60a5fa" /** - * This hook highlights traces on hover and all traces connected to the same net + * This hook highlights traces on hover and all traces connected to the same net. + * Net grouping is derived from the SVG's data-subcircuit-connectivity-map-key attribute. */ export const useTraceHoverHighlight = ({ svgDivRef, @@ -18,136 +18,69 @@ export const useTraceHoverHighlight = ({ const svg = svgDivRef.current if (!svg) return - const sourceTraces = su(circuitJson).source_trace.list() - const schematicTraces = su(circuitJson).schematic_trace.list() - - if (schematicTraces.length === 0) return - - // Build schematic_trace_id → source_trace_id - const schematicToSource = new Map() - for (const st of schematicTraces) { - if (st.source_trace_id) { - schematicToSource.set(st.schematic_trace_id, st.source_trace_id) - } - } - - // Build source_port_id → Set - const portToSources = new Map>() - for (const st of sourceTraces) { - for (const portId of st.connected_source_port_ids ?? []) { - if (!portToSources.has(portId)) portToSources.set(portId, new Set()) - portToSources.get(portId)!.add(st.source_trace_id) - } - } - - // Build source_trace_id → Set - const sourceToSchematics = new Map>() - for (const st of schematicTraces) { - if (!st.source_trace_id) continue - if (!sourceToSchematics.has(st.source_trace_id)) { - sourceToSchematics.set(st.source_trace_id, new Set()) - } - sourceToSchematics.get(st.source_trace_id)!.add(st.schematic_trace_id) - } - - // Precompute net groups: traces sharing connected ports are on the same net - const traceNetMap = new Map() - const netTraces = new Map>() - let nextNetId = 0 - - for (const st of schematicTraces) { - if (traceNetMap.has(st.schematic_trace_id)) continue - - const visited = new Set() - const queue = [st.schematic_trace_id] - - while (queue.length > 0) { - const currentSchId = queue.pop()! - if (visited.has(currentSchId)) continue - visited.add(currentSchId) - - const srcId = schematicToSource.get(currentSchId) - if (!srcId) continue - - const srcTrace = sourceTraces.find((s) => s.source_trace_id === srcId) - if (!srcTrace) continue - - for (const portId of srcTrace.connected_source_port_ids ?? []) { - const connectedSrcIds = portToSources.get(portId) - if (!connectedSrcIds) continue - for (const connSrcId of connectedSrcIds) { - const schIds = sourceToSchematics.get(connSrcId) - if (!schIds) continue - for (const schId of schIds) { - if (!visited.has(schId)) queue.push(schId) - } - } - } - } - - const netId = nextNetId++ - netTraces.set(netId, visited) - for (const id of visited) { - traceNetMap.set(id, netId) - } - } - // Hover state const originalStrokes = new Map() - let currentNetId: number | null = null + let currentNetKey: string | null = null const clearHighlights = () => { for (const [el, stroke] of originalStrokes) { el.setAttribute("stroke", stroke) } originalStrokes.clear() - currentNetId = null + currentNetKey = null } - const applyHighlights = (netId: number) => { - const traceIds = netTraces.get(netId) - if (!traceIds) return - - for (const traceId of traceIds) { - const paths = svg.querySelectorAll( - `[data-schematic-trace-id="${traceId}"] path`, - ) + const applyHighlights = (netKey: string) => { + // Find all trace groups on the same net + const sameNetTraces = svg.querySelectorAll( + `[data-subcircuit-connectivity-map-key="${netKey}"][data-circuit-json-type="schematic_trace"]`, + ) + for (const traceGroup of Array.from(sameNetTraces)) { + const paths = traceGroup.querySelectorAll("path") for (const path of Array.from(paths)) { if (path.getAttribute("class")?.includes("invisible")) continue originalStrokes.set(path, path.getAttribute("stroke") || "") path.setAttribute("stroke", HIGHLIGHT_COLOR) } } - currentNetId = netId + currentNetKey = netKey } const handlePointerMove = (e: PointerEvent) => { const target = e.target as Element if (!target?.closest) return - const traceGroup = target.closest("[data-schematic-trace-id]") + const traceGroup = target.closest( + "[data-circuit-json-type='schematic_trace']", + ) if (!traceGroup) { - if (currentNetId !== null) clearHighlights() + if (currentNetKey !== null) clearHighlights() return } - const traceId = traceGroup.getAttribute("data-schematic-trace-id") - if (!traceId) { - if (currentNetId !== null) clearHighlights() - return - } - - const netId = traceNetMap.get(traceId) - if (netId === undefined) { - if (currentNetId !== null) clearHighlights() + const netKey = traceGroup.getAttribute( + "data-subcircuit-connectivity-map-key", + ) + if (!netKey) { + // No net key — highlight just this single trace + const traceId = traceGroup.getAttribute("data-schematic-trace-id") + if (!traceId || currentNetKey === `single:${traceId}`) return + clearHighlights() + const paths = traceGroup.querySelectorAll("path") + for (const path of Array.from(paths)) { + if (path.getAttribute("class")?.includes("invisible")) continue + originalStrokes.set(path, path.getAttribute("stroke") || "") + path.setAttribute("stroke", HIGHLIGHT_COLOR) + } + currentNetKey = `single:${traceId}` return } // Already highlighting this net - if (netId === currentNetId) return + if (netKey === currentNetKey) return clearHighlights() - applyHighlights(netId) + applyHighlights(netKey) } const handlePointerLeave = () => { From 07040b52ce66992e382b069e861e5a316392f624 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Sun, 8 Feb 2026 08:35:37 -0500 Subject: [PATCH 3/3] feat: add tscircuit_demo fixture for hover highlight demo Loads MrPicklePinosaur/tscircuit_demo circuit from tscircuit registry to demonstrate trace hover highlighting on a complex real-world schematic. Co-Authored-By: Claude Opus 4.6 --- examples/example19-tscircuit-demo.fixture.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 examples/example19-tscircuit-demo.fixture.tsx diff --git a/examples/example19-tscircuit-demo.fixture.tsx b/examples/example19-tscircuit-demo.fixture.tsx new file mode 100644 index 0000000..147d8f4 --- /dev/null +++ b/examples/example19-tscircuit-demo.fixture.tsx @@ -0,0 +1,30 @@ +import type { CircuitJson } from "circuit-json" +import { SchematicViewer } from "lib/index" +import { useEffect, useState } from "react" + +const CIRCUIT_JSON_URL = + "https://registry-api.tscircuit.com/snippets/get?snippet_id=1bb7c947-85fc-4cbb-bbbe-d326676f4042" + +export default () => { + const [circuitJson, setCircuitJson] = useState(null) + + useEffect(() => { + fetch(CIRCUIT_JSON_URL) + .then((r) => r.json()) + .then((data) => setCircuitJson(data.snippet.circuit_json as CircuitJson)) + .catch(console.error) + }, []) + + if (!circuitJson) return
Loading circuit...
+ + return ( +
+ +
+ ) +}