diff --git a/examples/example21-connected-trace-hover.fixture.tsx b/examples/example21-connected-trace-hover.fixture.tsx
new file mode 100644
index 0000000..185c58e
--- /dev/null
+++ b/examples/example21-connected-trace-hover.fixture.tsx
@@ -0,0 +1,44 @@
+import { SchematicViewer } from "lib/components/SchematicViewer"
+import { renderToCircuitJson } from "lib/dev/render-to-circuit-json"
+
+const circuitJson = renderToCircuitJson(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+)
+
+export default () => (
+
+)
diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx
index ab4fd20..01d25be 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 { useHighlightConnectedTracesOnHover } from "lib/hooks/useHighlightConnectedTracesOnHover"
import { enableDebug } from "lib/utils/debug"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
@@ -353,6 +354,12 @@ export const SchematicViewer = ({
showGroups: showSchematicGroups && !disableGroups,
})
+ useHighlightConnectedTracesOnHover({
+ svgDivRef,
+ circuitJson,
+ circuitJsonKey,
+ })
+
// keep the latest touch handler without re-rendering the svg div
const handleComponentTouchStartRef = useRef(handleComponentTouchStart)
useEffect(() => {
diff --git a/lib/hooks/useHighlightConnectedTracesOnHover.ts b/lib/hooks/useHighlightConnectedTracesOnHover.ts
new file mode 100644
index 0000000..116244b
--- /dev/null
+++ b/lib/hooks/useHighlightConnectedTracesOnHover.ts
@@ -0,0 +1,325 @@
+import { su } from "@tscircuit/soup-util"
+import type { CircuitJson } from "circuit-json"
+import { useEffect } from "react"
+
+const HOVER_STYLE_ID = "schematic-trace-net-hover-style"
+const HOVER_ATTR = "data-schematic-trace-net-hover"
+
+const addToSetMap = (
+ map: Map>,
+ key: string,
+ value: string,
+) => {
+ const values = map.get(key) ?? new Set()
+ values.add(value)
+ map.set(key, values)
+}
+
+const getSolverSourcePortSelectors = (sourceTraceId: string) => {
+ if (!sourceTraceId.startsWith("solver_")) return []
+
+ return sourceTraceId
+ .replace(/^solver_/, "")
+ .split("-")
+ .filter(Boolean)
+}
+
+const getConnectedSourceTraceIds = (
+ hoveredSourceTraceId: string,
+ sourceTraceIdToNetIds: Map>,
+ netIdToSourceTraceIds: Map>,
+ sourceTraceIdToPortIds: Map>,
+ portIdToSourceTraceIds: Map>,
+) => {
+ const connectedSourceTraceIds = new Set()
+ const queue = [hoveredSourceTraceId]
+
+ while (queue.length > 0) {
+ const sourceTraceId = queue.pop()
+ if (!sourceTraceId || connectedSourceTraceIds.has(sourceTraceId)) continue
+
+ connectedSourceTraceIds.add(sourceTraceId)
+
+ for (const netId of sourceTraceIdToNetIds.get(sourceTraceId) ?? []) {
+ for (const nextTraceId of netIdToSourceTraceIds.get(netId) ?? []) {
+ if (!connectedSourceTraceIds.has(nextTraceId)) {
+ queue.push(nextTraceId)
+ }
+ }
+ }
+
+ for (const portId of sourceTraceIdToPortIds.get(sourceTraceId) ?? []) {
+ for (const nextTraceId of portIdToSourceTraceIds.get(portId) ?? []) {
+ if (!connectedSourceTraceIds.has(nextTraceId)) {
+ queue.push(nextTraceId)
+ }
+ }
+ }
+ }
+
+ return connectedSourceTraceIds
+}
+
+export const getConnectedSchematicTraceIdsByHoveredTrace = (
+ circuitJson: CircuitJson,
+) => {
+ const schematicTraceIdToSourceTraceId = new Map()
+ const sourceTraceIdToSchematicTraceIds = new Map>()
+ const sourceTraceIdToNetIds = new Map>()
+ const netIdToSourceTraceIds = new Map>()
+ const sourceTraceIdToPortIds = new Map>()
+ const portIdToSourceTraceIds = new Map>()
+ const sourcePortSelectorToPortId = new Map()
+ const sourcePortIdToConnectivityKey = new Map()
+
+ for (const sourceComponent of su(
+ circuitJson,
+ ).source_component.list() as any[]) {
+ const sourceComponentId = sourceComponent.source_component_id as
+ | string
+ | undefined
+ const componentName = sourceComponent.name as string | undefined
+ if (!sourceComponentId || !componentName) continue
+
+ for (const sourcePort of su(circuitJson).source_port.list({
+ source_component_id: sourceComponentId,
+ }) as any[]) {
+ const sourcePortId = sourcePort.source_port_id as string | undefined
+ if (!sourcePortId) continue
+
+ const portSelectors = [
+ sourcePort.name,
+ sourcePort.pin_number,
+ ...(sourcePort.port_hints ?? []),
+ ]
+ .filter(Boolean)
+ .map(String)
+
+ for (const portSelector of portSelectors) {
+ sourcePortSelectorToPortId.set(
+ `${componentName}.${portSelector}`,
+ sourcePortId,
+ )
+ }
+
+ const connectivityKey = sourcePort.subcircuit_connectivity_map_key as
+ | string
+ | undefined
+ if (connectivityKey) {
+ sourcePortIdToConnectivityKey.set(sourcePortId, connectivityKey)
+ }
+ }
+ }
+
+ for (const sourceTrace of su(circuitJson).source_trace.list() as any[]) {
+ const sourceTraceId = sourceTrace.source_trace_id as string | undefined
+ if (!sourceTraceId) continue
+
+ const netIds = new Set(
+ (sourceTrace.connected_source_net_ids ?? []).filter(Boolean),
+ )
+ if (sourceTrace.subcircuit_connectivity_map_key) {
+ netIds.add(`connectivity:${sourceTrace.subcircuit_connectivity_map_key}`)
+ }
+ sourceTraceIdToNetIds.set(sourceTraceId, netIds)
+
+ for (const netId of netIds) {
+ addToSetMap(netIdToSourceTraceIds, netId, sourceTraceId)
+ }
+
+ const portIds = new Set(
+ (sourceTrace.connected_source_port_ids ?? []).filter(Boolean),
+ )
+ sourceTraceIdToPortIds.set(sourceTraceId, portIds)
+
+ for (const portId of portIds) {
+ addToSetMap(portIdToSourceTraceIds, portId, sourceTraceId)
+ }
+ }
+
+ for (const schematicTrace of su(
+ circuitJson,
+ ).schematic_trace.list() as any[]) {
+ const schematicTraceId = schematicTrace.schematic_trace_id as
+ | string
+ | undefined
+ const sourceTraceId = schematicTrace.source_trace_id as string | undefined
+
+ if (!schematicTraceId || !sourceTraceId) continue
+
+ schematicTraceIdToSourceTraceId.set(schematicTraceId, sourceTraceId)
+ addToSetMap(
+ sourceTraceIdToSchematicTraceIds,
+ sourceTraceId,
+ schematicTraceId,
+ )
+
+ const solverPortIds = getSolverSourcePortSelectors(sourceTraceId)
+ .map((portSelector) => sourcePortSelectorToPortId.get(portSelector))
+ .filter(Boolean) as string[]
+
+ if (solverPortIds.length > 0) {
+ const netIds = sourceTraceIdToNetIds.get(sourceTraceId) ?? new Set()
+ const portIds = sourceTraceIdToPortIds.get(sourceTraceId) ?? new Set()
+
+ for (const sourcePortId of solverPortIds) {
+ portIds.add(sourcePortId)
+
+ const connectivityKey = sourcePortIdToConnectivityKey.get(sourcePortId)
+ if (connectivityKey) {
+ netIds.add(`connectivity:${connectivityKey}`)
+ }
+ }
+
+ sourceTraceIdToPortIds.set(sourceTraceId, portIds)
+ sourceTraceIdToNetIds.set(sourceTraceId, netIds)
+
+ for (const sourcePortId of portIds) {
+ addToSetMap(portIdToSourceTraceIds, sourcePortId, sourceTraceId)
+ }
+
+ for (const netId of netIds) {
+ addToSetMap(netIdToSourceTraceIds, netId, sourceTraceId)
+ }
+ }
+ }
+
+ const connectedSchematicTraceIdsByTraceId = new Map>()
+
+ for (const [
+ schematicTraceId,
+ sourceTraceId,
+ ] of schematicTraceIdToSourceTraceId) {
+ const connectedSchematicTraceIds = new Set()
+ const connectedSourceTraceIds = getConnectedSourceTraceIds(
+ sourceTraceId,
+ sourceTraceIdToNetIds,
+ netIdToSourceTraceIds,
+ sourceTraceIdToPortIds,
+ portIdToSourceTraceIds,
+ )
+
+ for (const connectedSourceTraceId of connectedSourceTraceIds) {
+ for (const connectedSchematicTraceId of sourceTraceIdToSchematicTraceIds.get(
+ connectedSourceTraceId,
+ ) ?? []) {
+ connectedSchematicTraceIds.add(connectedSchematicTraceId)
+ }
+ }
+
+ connectedSchematicTraceIdsByTraceId.set(
+ schematicTraceId,
+ connectedSchematicTraceIds,
+ )
+ }
+
+ return connectedSchematicTraceIdsByTraceId
+}
+
+export const useHighlightConnectedTracesOnHover = ({
+ svgDivRef,
+ circuitJson,
+ circuitJsonKey,
+}: {
+ svgDivRef: React.RefObject
+ circuitJson: CircuitJson
+ circuitJsonKey: string
+}) => {
+ useEffect(() => {
+ const svg = svgDivRef.current
+ if (!svg) return
+
+ const connectedSchematicTraceIdsByTraceId =
+ getConnectedSchematicTraceIdsByHoveredTrace(circuitJson)
+
+ const ensureHoverStyle = () => {
+ if (!svg.querySelector(`style#${HOVER_STYLE_ID}`)) {
+ const style = document.createElement("style")
+ style.id = HOVER_STYLE_ID
+ style.textContent = `
+ .boundary {
+ pointer-events: none;
+ }
+
+ [data-circuit-json-type="schematic_trace"][${HOVER_ATTR}="true"] {
+ filter: invert(1);
+ }
+
+ [data-circuit-json-type="schematic_trace"][${HOVER_ATTR}="true"] .trace-crossing-outline {
+ opacity: 0;
+ }
+ `
+ svg.appendChild(style)
+ }
+ }
+
+ const clearHighlightedTraces = () => {
+ for (const trace of Array.from(svg.querySelectorAll(`[${HOVER_ATTR}]`))) {
+ trace.removeAttribute(HOVER_ATTR)
+ }
+ }
+
+ const highlightConnectedTraces = (schematicTraceId: string) => {
+ clearHighlightedTraces()
+
+ for (const connectedSchematicTraceId of connectedSchematicTraceIdsByTraceId.get(
+ schematicTraceId,
+ ) ?? []) {
+ for (const traceElement of Array.from(
+ svg.querySelectorAll(
+ `[data-circuit-json-type="schematic_trace"][data-schematic-trace-id="${connectedSchematicTraceId}"]`,
+ ),
+ )) {
+ traceElement.setAttribute(HOVER_ATTR, "true")
+ }
+ }
+ }
+
+ const cleanupListeners: Array<() => void> = []
+
+ const attachTraceHoverListeners = () => {
+ ensureHoverStyle()
+ cleanupListeners.splice(0).forEach((cleanup) => cleanup())
+
+ const traceElements = svg.querySelectorAll(
+ '[data-circuit-json-type="schematic_trace"][data-schematic-trace-id]',
+ )
+
+ for (const traceElement of Array.from(traceElements)) {
+ const schematicTraceId = traceElement.getAttribute(
+ "data-schematic-trace-id",
+ )
+ if (!schematicTraceId) continue
+
+ const handlePointerEnter = () => {
+ highlightConnectedTraces(schematicTraceId)
+ }
+
+ traceElement.addEventListener("pointerenter", handlePointerEnter)
+ traceElement.addEventListener("pointerleave", clearHighlightedTraces)
+
+ cleanupListeners.push(() => {
+ traceElement.removeEventListener("pointerenter", handlePointerEnter)
+ traceElement.removeEventListener(
+ "pointerleave",
+ clearHighlightedTraces,
+ )
+ })
+ }
+ }
+
+ attachTraceHoverListeners()
+
+ const observer = new MutationObserver(attachTraceHoverListeners)
+ observer.observe(svg, {
+ childList: true,
+ subtree: false,
+ })
+
+ return () => {
+ observer.disconnect()
+ cleanupListeners.splice(0).forEach((cleanup) => cleanup())
+ clearHighlightedTraces()
+ }
+ }, [svgDivRef, circuitJson, circuitJsonKey])
+}
diff --git a/tests/use-highlight-connected-traces-on-hover.test.ts b/tests/use-highlight-connected-traces-on-hover.test.ts
new file mode 100644
index 0000000..50f3667
--- /dev/null
+++ b/tests/use-highlight-connected-traces-on-hover.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, test } from "bun:test"
+import { getConnectedSchematicTraceIdsByHoveredTrace } from "lib/hooks/useHighlightConnectedTracesOnHover"
+
+const getConnectedTraceIds = (
+ circuitJson: any[],
+ hoveredSchematicTraceId: string,
+) =>
+ [
+ ...(getConnectedSchematicTraceIdsByHoveredTrace(circuitJson as any).get(
+ hoveredSchematicTraceId,
+ ) ?? []),
+ ].sort()
+
+describe("getConnectedSchematicTraceIdsByHoveredTrace", () => {
+ test("groups traces through connected source net ids", () => {
+ const circuitJson = [
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_gnd_a",
+ connected_source_net_ids: ["source_net_gnd"],
+ },
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_gnd_b",
+ connected_source_net_ids: ["source_net_gnd"],
+ },
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_sig",
+ connected_source_net_ids: ["source_net_sig"],
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_gnd_a",
+ source_trace_id: "source_trace_gnd_a",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_gnd_b",
+ source_trace_id: "source_trace_gnd_b",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_sig",
+ source_trace_id: "source_trace_sig",
+ },
+ ]
+
+ expect(getConnectedTraceIds(circuitJson, "schematic_trace_gnd_a")).toEqual([
+ "schematic_trace_gnd_a",
+ "schematic_trace_gnd_b",
+ ])
+ })
+
+ test("falls back to connected source ports when net ids are unavailable", () => {
+ const circuitJson = [
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_a",
+ connected_source_port_ids: ["source_port_shared"],
+ },
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_b",
+ connected_source_port_ids: ["source_port_shared"],
+ },
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_c",
+ connected_source_port_ids: ["source_port_other"],
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_a",
+ source_trace_id: "source_trace_a",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_b",
+ source_trace_id: "source_trace_b",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_c",
+ source_trace_id: "source_trace_c",
+ },
+ ]
+
+ expect(getConnectedTraceIds(circuitJson, "schematic_trace_a")).toEqual([
+ "schematic_trace_a",
+ "schematic_trace_b",
+ ])
+ })
+
+ test("resolves solver trace endpoints through source port connectivity keys", () => {
+ const circuitJson = [
+ {
+ type: "source_component",
+ source_component_id: "source_component_r1",
+ name: "R1",
+ },
+ {
+ type: "source_component",
+ source_component_id: "source_component_u1",
+ name: "U1",
+ },
+ {
+ type: "source_component",
+ source_component_id: "source_component_c1",
+ name: "C1",
+ },
+ {
+ type: "source_port",
+ source_port_id: "source_port_r1_1",
+ source_component_id: "source_component_r1",
+ pin_number: "1",
+ subcircuit_connectivity_map_key: "net_gnd",
+ },
+ {
+ type: "source_port",
+ source_port_id: "source_port_r1_2",
+ source_component_id: "source_component_r1",
+ pin_number: "2",
+ subcircuit_connectivity_map_key: "net_sig",
+ },
+ {
+ type: "source_port",
+ source_port_id: "source_port_u1_4",
+ source_component_id: "source_component_u1",
+ pin_number: "4",
+ subcircuit_connectivity_map_key: "net_gnd",
+ },
+ {
+ type: "source_port",
+ source_port_id: "source_port_c1_1",
+ source_component_id: "source_component_c1",
+ pin_number: "1",
+ subcircuit_connectivity_map_key: "net_sig",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_gnd_a",
+ source_trace_id: "solver_R1.1-U1.4",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_gnd_b",
+ source_trace_id: "solver_U1.4-R1.1",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_sig",
+ source_trace_id: "solver_R1.2-C1.1",
+ },
+ ]
+
+ expect(getConnectedTraceIds(circuitJson, "schematic_trace_gnd_a")).toEqual([
+ "schematic_trace_gnd_a",
+ "schematic_trace_gnd_b",
+ ])
+ })
+})