From 1ead1c7c24eaa97d293c35b42f4977d0c62b9edf Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 20 May 2026 13:03:19 +0700 Subject: [PATCH 1/3] fix: highlight all same-net traces on hover When hovering any trace, all schematic_trace elements belonging to the same electrical net are now highlighted together. Uses Union-Find (DisjointSet) to group source_trace elements by shared connected_source_port_ids, connected_source_net_ids, and subcircuit_connectivity_map_key. Maps schematic_trace elements to their net group via source_trace_id. Disabled during edit mode to avoid conflicting with drag/edit styling. Fixes tscircuit/tscircuit#1130 --- lib/components/SchematicViewer.tsx | 8 + lib/hooks/useTraceHoverHighlighting.ts | 77 ++++++++++ .../get-schematic-trace-net-keys.test.ts | 145 ++++++++++++++++++ lib/utils/get-schematic-trace-net-keys.ts | 114 ++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 lib/hooks/useTraceHoverHighlighting.ts create mode 100644 lib/utils/get-schematic-trace-net-keys.test.ts create mode 100644 lib/utils/get-schematic-trace-net-keys.ts diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index ab4fd20..1ab638b 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 { useTraceHoverHighlighting } from "lib/hooks/useTraceHoverHighlighting" interface Props { circuitJson: CircuitJson @@ -345,6 +346,13 @@ export const SchematicViewer = ({ editEvents: editEventsWithUnappliedEditEvents, }) + useTraceHoverHighlighting({ + svgDivRef, + circuitJson, + circuitJsonKey, + enabled: !editModeEnabled, + }) + // Add group overlays when enabled useSchematicGroupsOverlay({ svgDivRef, diff --git a/lib/hooks/useTraceHoverHighlighting.ts b/lib/hooks/useTraceHoverHighlighting.ts new file mode 100644 index 0000000..f7bcff6 --- /dev/null +++ b/lib/hooks/useTraceHoverHighlighting.ts @@ -0,0 +1,77 @@ +import { useEffect, useMemo } from "react" +import type { CircuitJson } from "circuit-json" +import { getSchematicTraceNetKeyMap } from "lib/utils/get-schematic-trace-net-keys" + +const HOVER_FILTER = "brightness(1.3) drop-shadow(0 0 3px rgba(255, 107, 53, 0.5))" + +export const useTraceHoverHighlighting = ({ + svgDivRef, + circuitJson, + circuitJsonKey, + enabled, +}: { + svgDivRef: React.RefObject + circuitJson: CircuitJson + circuitJsonKey: string + enabled: boolean +}) => { + const schematicTraceNetKeyMap = useMemo( + () => getSchematicTraceNetKeyMap(circuitJson), + [circuitJsonKey, circuitJson], + ) + + useEffect(() => { + const svgRoot = svgDivRef.current + if (!svgRoot) return + + const traceGroups = Array.from( + svgRoot.querySelectorAll( + '[data-circuit-json-type="schematic_trace"][data-schematic-trace-id]', + ), + ) + + const netKeyToElements = new Map() + + for (const el of traceGroups) { + const id = el.dataset.schematicTraceId + if (!id) continue + const netKey = schematicTraceNetKeyMap.get(id) + const key = netKey ?? `single:${id}` + if (!netKeyToElements.has(key)) { + netKeyToElements.set(key, []) + } + netKeyToElements.get(key)!.push(el) + } + + const cleanupCallbacks: Array<() => void> = [] + + for (const sameNetElements of netKeyToElements.values()) { + const applyHover = () => { + if (!enabled) return + for (const el of sameNetElements) { + el.style.filter = HOVER_FILTER + } + } + + const removeHover = () => { + for (const el of sameNetElements) { + el.style.filter = "" + } + } + + for (const el of sameNetElements) { + el.addEventListener("mouseenter", applyHover) + el.addEventListener("mouseleave", removeHover) + cleanupCallbacks.push(() => { + el.removeEventListener("mouseenter", applyHover) + el.removeEventListener("mouseleave", removeHover) + el.style.filter = "" + }) + } + } + + return () => { + for (const cleanup of cleanupCallbacks) cleanup() + } + }, [circuitJsonKey, svgDivRef, schematicTraceNetKeyMap, enabled]) +} diff --git a/lib/utils/get-schematic-trace-net-keys.test.ts b/lib/utils/get-schematic-trace-net-keys.test.ts new file mode 100644 index 0000000..4076ca4 --- /dev/null +++ b/lib/utils/get-schematic-trace-net-keys.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "bun:test" +import { + getSchematicTraceNetKeyMap, + getSourceTraceNetKeyMap, + getSameNetSchematicTraceIdsMap, +} from "./get-schematic-trace-net-keys" + +test("groups source traces by shared source ports transitively", () => { + const sourceTraceNetKeyMap = getSourceTraceNetKeyMap([ + { + type: "source_trace", + source_trace_id: "source_trace_1", + connected_source_port_ids: ["source_port_1", "source_port_2"], + connected_source_net_ids: [], + }, + { + type: "source_trace", + source_trace_id: "source_trace_2", + connected_source_port_ids: ["source_port_2", "source_port_3"], + connected_source_net_ids: [], + }, + { + type: "source_trace", + source_trace_id: "source_trace_3", + connected_source_port_ids: ["source_port_4", "source_port_5"], + connected_source_net_ids: [], + }, + ] as any) + + expect(sourceTraceNetKeyMap.get("source_trace_1")).toBe( + sourceTraceNetKeyMap.get("source_trace_2"), + ) + expect(sourceTraceNetKeyMap.get("source_trace_1")).not.toBe( + sourceTraceNetKeyMap.get("source_trace_3"), + ) +}) + +test("maps schematic trace ids to their source trace net groups", () => { + const schematicTraceNetKeyMap = getSchematicTraceNetKeyMap([ + { + type: "source_trace", + source_trace_id: "source_trace_1", + connected_source_port_ids: ["source_port_1", "source_port_2"], + connected_source_net_ids: [], + }, + { + type: "source_trace", + source_trace_id: "source_trace_2", + connected_source_port_ids: ["source_port_2", "source_port_3"], + connected_source_net_ids: [], + }, + { + type: "schematic_trace", + schematic_trace_id: "schematic_trace_1", + source_trace_id: "source_trace_1", + }, + { + type: "schematic_trace", + schematic_trace_id: "schematic_trace_2", + source_trace_id: "source_trace_2", + }, + ] as any) + + expect(schematicTraceNetKeyMap.get("schematic_trace_1")).toBe( + schematicTraceNetKeyMap.get("schematic_trace_2"), + ) +}) + +test("groups source traces by shared nets", () => { + const sourceTraceNetKeyMap = getSourceTraceNetKeyMap([ + { + type: "source_trace", + source_trace_id: "source_trace_1", + connected_source_port_ids: [], + connected_source_net_ids: ["net_1"], + }, + { + type: "source_trace", + source_trace_id: "source_trace_2", + connected_source_port_ids: [], + connected_source_net_ids: ["net_1"], + }, + ] as any) + + expect(sourceTraceNetKeyMap.get("source_trace_1")).toBe( + sourceTraceNetKeyMap.get("source_trace_2"), + ) +}) + +test("groups source traces by subcircuit_connectivity_map_key", () => { + const sourceTraceNetKeyMap = getSourceTraceNetKeyMap([ + { + type: "source_trace", + source_trace_id: "source_trace_1", + connected_source_port_ids: [], + connected_source_net_ids: [], + subcircuit_connectivity_map_key: "sub_1", + }, + { + type: "source_trace", + source_trace_id: "source_trace_2", + connected_source_port_ids: [], + connected_source_net_ids: [], + subcircuit_connectivity_map_key: "sub_1", + }, + ] as any) + + expect(sourceTraceNetKeyMap.get("source_trace_1")).toBe( + sourceTraceNetKeyMap.get("source_trace_2"), + ) +}) + +test("getSameNetSchematicTraceIdsMap returns same Set for same-net traces", () => { + const sameNetMap = getSameNetSchematicTraceIdsMap([ + { + type: "source_trace", + source_trace_id: "source_trace_1", + connected_source_port_ids: ["source_port_1"], + connected_source_net_ids: [], + }, + { + type: "source_trace", + source_trace_id: "source_trace_2", + connected_source_port_ids: ["source_port_1"], + connected_source_net_ids: [], + }, + { + type: "schematic_trace", + schematic_trace_id: "schematic_trace_1", + source_trace_id: "source_trace_1", + }, + { + type: "schematic_trace", + schematic_trace_id: "schematic_trace_2", + source_trace_id: "source_trace_2", + }, + ] as any) + + const set1 = sameNetMap.get("schematic_trace_1") + const set2 = sameNetMap.get("schematic_trace_2") + expect(set1).toBe(set2) + expect(set1!.has("schematic_trace_1")).toBe(true) + expect(set1!.has("schematic_trace_2")).toBe(true) + expect(set1!.size).toBe(2) +}) diff --git a/lib/utils/get-schematic-trace-net-keys.ts b/lib/utils/get-schematic-trace-net-keys.ts new file mode 100644 index 0000000..c5f0684 --- /dev/null +++ b/lib/utils/get-schematic-trace-net-keys.ts @@ -0,0 +1,114 @@ +import type { CircuitJson } from "circuit-json" + +class DisjointSet { + private parent = new Map() + + add(id: string) { + if (!this.parent.has(id)) this.parent.set(id, id) + } + + find(id: string): string { + this.add(id) + const parent = this.parent.get(id)! + if (parent === id) return id + const root = this.find(parent) + this.parent.set(id, root) + return root + } + + union(a: string, b: string) { + const rootA = this.find(a) + const rootB = this.find(b) + if (rootA !== rootB) this.parent.set(rootB, rootA) + } +} + +const getStringArray = (value: unknown): string[] => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : [] + +export const getSourceTraceNetKeyMap = (circuitJson: CircuitJson) => { + const disjointSet = new DisjointSet() + const sourceTraceIds: string[] = [] + + for (const element of circuitJson as Array>) { + if (element.type !== "source_trace") continue + + const sourceTraceId = element.source_trace_id + if (typeof sourceTraceId !== "string") continue + + sourceTraceIds.push(sourceTraceId) + disjointSet.add(sourceTraceId) + + const connectedIds = [ + ...getStringArray(element.connected_source_port_ids), + ...getStringArray(element.connected_source_net_ids), + ] + + const subcircuitConnectivityMapKey = + typeof element.subcircuit_connectivity_map_key === "string" + ? element.subcircuit_connectivity_map_key + : null + + if (subcircuitConnectivityMapKey) { + connectedIds.push(`subcircuit:${subcircuitConnectivityMapKey}`) + } + + for (const connectedId of connectedIds) { + disjointSet.union(sourceTraceId, connectedId) + } + } + + return new Map( + sourceTraceIds.map((sourceTraceId) => [ + sourceTraceId, + disjointSet.find(sourceTraceId), + ]), + ) +} + +export const getSchematicTraceNetKeyMap = (circuitJson: CircuitJson) => { + const sourceTraceNetKeyMap = getSourceTraceNetKeyMap(circuitJson) + const schematicTraceNetKeyMap = new Map() + + for (const element of circuitJson as Array>) { + if (element.type !== "schematic_trace") continue + + const schematicTraceId = element.schematic_trace_id + const sourceTraceId = element.source_trace_id + if ( + typeof schematicTraceId !== "string" || + typeof sourceTraceId !== "string" + ) { + continue + } + + const netKey = sourceTraceNetKeyMap.get(sourceTraceId) + if (netKey) schematicTraceNetKeyMap.set(schematicTraceId, netKey) + } + + return schematicTraceNetKeyMap +} + +export const getSameNetSchematicTraceIdsMap = (circuitJson: CircuitJson) => { + const schematicTraceNetKeyMap = getSchematicTraceNetKeyMap(circuitJson) + const netKeyToTraceIds = new Map>() + + for (const [traceId, netKey] of schematicTraceNetKeyMap) { + let traceIds = netKeyToTraceIds.get(netKey) + if (!traceIds) { + traceIds = new Set() + netKeyToTraceIds.set(netKey, traceIds) + } + traceIds.add(traceId) + } + + const sameNetTraceIdsByTraceId = new Map>() + for (const [traceId, netKey] of schematicTraceNetKeyMap) { + const sameNet = netKeyToTraceIds.get(netKey)! + sameNetTraceIdsByTraceId.set(traceId, sameNet) + } + + return sameNetTraceIdsByTraceId +} From 0d599abdc497ae2c390968fd7664350a3a950d9b Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 20 May 2026 13:10:33 +0700 Subject: [PATCH 2/3] fix: format - wrap long line, normalize line endings --- .gitignore | 1 + lib/hooks/useTraceHoverHighlighting.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4b61c44..c88fcf9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ cosmos-export .env lib/workers/spice-simulation.worker.blob.js .cursor +package-lock.json diff --git a/lib/hooks/useTraceHoverHighlighting.ts b/lib/hooks/useTraceHoverHighlighting.ts index f7bcff6..552ed0a 100644 --- a/lib/hooks/useTraceHoverHighlighting.ts +++ b/lib/hooks/useTraceHoverHighlighting.ts @@ -2,7 +2,8 @@ import { useEffect, useMemo } from "react" import type { CircuitJson } from "circuit-json" import { getSchematicTraceNetKeyMap } from "lib/utils/get-schematic-trace-net-keys" -const HOVER_FILTER = "brightness(1.3) drop-shadow(0 0 3px rgba(255, 107, 53, 0.5))" +const HOVER_FILTER = + "brightness(1.3) drop-shadow(0 0 3px rgba(255, 107, 53, 0.5))" export const useTraceHoverHighlighting = ({ svgDivRef, From b5e8319eb466f03f51790af191e7f27e4e2472f7 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 20 May 2026 13:14:44 +0700 Subject: [PATCH 3/3] fix: add non-null assertions in test file for strict typecheck --- .../get-schematic-trace-net-keys.test.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/utils/get-schematic-trace-net-keys.test.ts b/lib/utils/get-schematic-trace-net-keys.test.ts index 4076ca4..5c86d24 100644 --- a/lib/utils/get-schematic-trace-net-keys.test.ts +++ b/lib/utils/get-schematic-trace-net-keys.test.ts @@ -27,11 +27,11 @@ test("groups source traces by shared source ports transitively", () => { }, ] as any) - expect(sourceTraceNetKeyMap.get("source_trace_1")).toBe( - sourceTraceNetKeyMap.get("source_trace_2"), + expect(sourceTraceNetKeyMap.get("source_trace_1")!).toBe( + sourceTraceNetKeyMap.get("source_trace_2")!, ) - expect(sourceTraceNetKeyMap.get("source_trace_1")).not.toBe( - sourceTraceNetKeyMap.get("source_trace_3"), + expect(sourceTraceNetKeyMap.get("source_trace_1")!).not.toBe( + sourceTraceNetKeyMap.get("source_trace_3")!, ) }) @@ -61,8 +61,8 @@ test("maps schematic trace ids to their source trace net groups", () => { }, ] as any) - expect(schematicTraceNetKeyMap.get("schematic_trace_1")).toBe( - schematicTraceNetKeyMap.get("schematic_trace_2"), + expect(schematicTraceNetKeyMap.get("schematic_trace_1")!).toBe( + schematicTraceNetKeyMap.get("schematic_trace_2")!, ) }) @@ -82,8 +82,8 @@ test("groups source traces by shared nets", () => { }, ] as any) - expect(sourceTraceNetKeyMap.get("source_trace_1")).toBe( - sourceTraceNetKeyMap.get("source_trace_2"), + expect(sourceTraceNetKeyMap.get("source_trace_1")!).toBe( + sourceTraceNetKeyMap.get("source_trace_2")!, ) }) @@ -105,8 +105,8 @@ test("groups source traces by subcircuit_connectivity_map_key", () => { }, ] as any) - expect(sourceTraceNetKeyMap.get("source_trace_1")).toBe( - sourceTraceNetKeyMap.get("source_trace_2"), + expect(sourceTraceNetKeyMap.get("source_trace_1")!).toBe( + sourceTraceNetKeyMap.get("source_trace_2")!, ) }) @@ -136,10 +136,10 @@ test("getSameNetSchematicTraceIdsMap returns same Set for same-net traces", () = }, ] as any) - const set1 = sameNetMap.get("schematic_trace_1") - const set2 = sameNetMap.get("schematic_trace_2") + const set1 = sameNetMap.get("schematic_trace_1")! + const set2 = sameNetMap.get("schematic_trace_2")! expect(set1).toBe(set2) - expect(set1!.has("schematic_trace_1")).toBe(true) - expect(set1!.has("schematic_trace_2")).toBe(true) - expect(set1!.size).toBe(2) + expect(set1.has("schematic_trace_1")).toBe(true) + expect(set1.has("schematic_trace_2")).toBe(true) + expect(set1.size).toBe(2) })