{onSchematicComponentClicked && (
)}
{onSchematicPortClicked && (
)}
+
{
+ if (!(target instanceof Element)) return null
+ return target.closest(TRACE_SELECTOR)
+}
+
+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)
+
+ let currentHoveredTraceId: string | null = null
+
+ const clearHighlightedTraces = () => {
+ currentHoveredTraceId = null
+ for (const traceElement of Array.from(
+ svg.querySelectorAll(`[${HOVER_ATTR}]`),
+ )) {
+ traceElement.removeAttribute(HOVER_ATTR)
+ }
+ }
+
+ const highlightConnectedTraces = (schematicTraceId: string) => {
+ if (schematicTraceId === currentHoveredTraceId) return
+
+ currentHoveredTraceId = schematicTraceId
+ const connectedSchematicTraceIds =
+ connectedSchematicTraceIdsByTraceId.get(schematicTraceId) ??
+ new Set([schematicTraceId])
+
+ for (const traceElement of Array.from(
+ svg.querySelectorAll(TRACE_SELECTOR),
+ )) {
+ const traceId = traceElement.getAttribute("data-schematic-trace-id")
+ if (traceId && connectedSchematicTraceIds.has(traceId)) {
+ traceElement.setAttribute(HOVER_ATTR, "true")
+ } else {
+ traceElement.removeAttribute(HOVER_ATTR)
+ }
+ }
+ }
+
+ const handlePointerOver = (event: PointerEvent) => {
+ const traceElement = getTraceElement(event.target)
+ const schematicTraceId = traceElement?.getAttribute(
+ "data-schematic-trace-id",
+ )
+ if (!schematicTraceId) return
+
+ highlightConnectedTraces(schematicTraceId)
+ }
+
+ const handlePointerOut = (event: PointerEvent) => {
+ const nextTraceElement = getTraceElement(event.relatedTarget)
+ if (!nextTraceElement) {
+ clearHighlightedTraces()
+ }
+ }
+
+ svg.addEventListener("pointerover", handlePointerOver)
+ svg.addEventListener("pointerout", handlePointerOut)
+ svg.addEventListener("pointerleave", clearHighlightedTraces)
+
+ return () => {
+ svg.removeEventListener("pointerover", handlePointerOver)
+ svg.removeEventListener("pointerout", handlePointerOut)
+ svg.removeEventListener("pointerleave", clearHighlightedTraces)
+ clearHighlightedTraces()
+ }
+ }, [svgDivRef, circuitJson, circuitJsonKey])
+}
diff --git a/lib/utils/getConnectedSchematicTraceIdsByHoveredTrace.ts b/lib/utils/getConnectedSchematicTraceIdsByHoveredTrace.ts
new file mode 100644
index 0000000..9e5a74b
--- /dev/null
+++ b/lib/utils/getConnectedSchematicTraceIdsByHoveredTrace.ts
@@ -0,0 +1,316 @@
+import type { CircuitJson } from "circuit-json"
+
+type CircuitJsonElement = Record
+
+const addToSetMap = (
+ map: Map>,
+ key: string | undefined,
+ value: string | undefined,
+) => {
+ if (!key || !value) return
+ const values = map.get(key) ?? new Set()
+ values.add(value)
+ map.set(key, values)
+}
+
+const getElementsByType = (circuitJson: CircuitJson, type: string) =>
+ (circuitJson as CircuitJsonElement[]).filter((elm) => elm.type === type)
+
+const getStringArray = (value: any) =>
+ Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string")
+ : []
+
+const normalizeSelector = (selector: string) =>
+ selector
+ .trim()
+ .replace(/^\.*/, "")
+ .replace(/\s*>\s*/g, ".")
+ .replace(/\s+/g, ".")
+ .replace(/^\.+|\.+$/g, "")
+
+const getSourcePortSelectorsFromSolverTraceId = (sourceTraceId: string) => {
+ if (!sourceTraceId.startsWith("solver_")) return []
+ return sourceTraceId
+ .replace(/^solver_/, "")
+ .split("-")
+ .map(normalizeSelector)
+ .filter(Boolean)
+}
+
+const addTraceConnectivityKey = (
+ traceIdToKeys: Map>,
+ keyToTraceIds: Map>,
+ traceId: string | undefined,
+ key: string | undefined,
+) => {
+ if (!traceId || !key) return
+ addToSetMap(traceIdToKeys, traceId, key)
+ addToSetMap(keyToTraceIds, key, traceId)
+}
+
+const addSourcePortKey = (
+ sourcePortSelectorToPortId: Map,
+ componentName: string | undefined,
+ selector: unknown,
+ sourcePortId: string,
+) => {
+ if (!componentName || selector === undefined || selector === null) return
+ const normalizedSelector = normalizeSelector(String(selector))
+ if (!normalizedSelector) return
+
+ sourcePortSelectorToPortId.set(
+ `${componentName}.${normalizedSelector}`,
+ sourcePortId,
+ )
+
+ if (normalizedSelector.startsWith("pin")) {
+ sourcePortSelectorToPortId.set(
+ `${componentName}.${normalizedSelector.replace(/^pin/, "")}`,
+ sourcePortId,
+ )
+ } else if (/^\d+$/.test(normalizedSelector)) {
+ sourcePortSelectorToPortId.set(
+ `${componentName}.pin${normalizedSelector}`,
+ sourcePortId,
+ )
+ }
+}
+
+const getConnectedSourceTraceIds = (
+ hoveredSourceTraceId: string,
+ traceIdToKeys: Map>,
+ keyToTraceIds: 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 key of traceIdToKeys.get(sourceTraceId) ?? []) {
+ for (const nextTraceId of keyToTraceIds.get(key) ?? []) {
+ if (!connectedSourceTraceIds.has(nextTraceId)) {
+ queue.push(nextTraceId)
+ }
+ }
+ }
+ }
+
+ return connectedSourceTraceIds
+}
+
+export const getConnectedSchematicTraceIdsByHoveredTrace = (
+ circuitJson: CircuitJson,
+) => {
+ const sourceTraceIdToSchematicTraceIds = new Map>()
+ const schematicTraceIdToSourceTraceId = new Map()
+ const traceIdToKeys = new Map>()
+ const keyToTraceIds = new Map>()
+ const sourcePortSelectorToPortId = new Map()
+ const sourcePortIdToConnectivityKey = new Map()
+
+ const sourceComponentNameById = new Map()
+
+ for (const sourceComponent of getElementsByType(
+ circuitJson,
+ "source_component",
+ )) {
+ if (sourceComponent.source_component_id && sourceComponent.name) {
+ sourceComponentNameById.set(
+ sourceComponent.source_component_id,
+ sourceComponent.name,
+ )
+ }
+ }
+
+ for (const sourcePort of getElementsByType(circuitJson, "source_port")) {
+ const sourcePortId = sourcePort.source_port_id as string | undefined
+ if (!sourcePortId) continue
+
+ const sourceComponentName = sourceComponentNameById.get(
+ sourcePort.source_component_id,
+ )
+
+ for (const selector of [
+ sourcePort.name,
+ sourcePort.pin_number,
+ ...(sourcePort.port_hints ?? []),
+ ]) {
+ addSourcePortKey(
+ sourcePortSelectorToPortId,
+ sourceComponentName,
+ selector,
+ sourcePortId,
+ )
+ }
+
+ if (sourcePort.subcircuit_connectivity_map_key) {
+ sourcePortIdToConnectivityKey.set(
+ sourcePortId,
+ sourcePort.subcircuit_connectivity_map_key,
+ )
+ }
+ }
+
+ for (const sourceTrace of getElementsByType(circuitJson, "source_trace")) {
+ const sourceTraceId = sourceTrace.source_trace_id as string | undefined
+ if (!sourceTraceId) continue
+
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `source-trace:${sourceTraceId}`,
+ )
+
+ for (const sourceNetId of getStringArray(
+ sourceTrace.connected_source_net_ids,
+ )) {
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `source-net:${sourceNetId}`,
+ )
+ }
+
+ if (sourceTrace.subcircuit_connectivity_map_key) {
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `connectivity:${sourceTrace.subcircuit_connectivity_map_key}`,
+ )
+ }
+
+ for (const sourcePortId of getStringArray(
+ sourceTrace.connected_source_port_ids,
+ )) {
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `source-port:${sourcePortId}`,
+ )
+ const connectivityKey = sourcePortIdToConnectivityKey.get(sourcePortId)
+ if (connectivityKey) {
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `connectivity:${connectivityKey}`,
+ )
+ }
+ }
+ }
+
+ for (const schematicTrace of getElementsByType(
+ circuitJson,
+ "schematic_trace",
+ )) {
+ 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,
+ )
+
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `source-trace:${sourceTraceId}`,
+ )
+
+ if (schematicTrace.subcircuit_connectivity_map_key) {
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `connectivity:${schematicTrace.subcircuit_connectivity_map_key}`,
+ )
+ }
+
+ for (const sourceNetId of getStringArray(
+ schematicTrace.connected_source_net_ids,
+ )) {
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `source-net:${sourceNetId}`,
+ )
+ }
+
+ for (const sourcePortSelector of getSourcePortSelectorsFromSolverTraceId(
+ sourceTraceId,
+ )) {
+ const sourcePortId =
+ sourcePortSelectorToPortId.get(sourcePortSelector) ??
+ (sourcePortIdToConnectivityKey.has(sourcePortSelector)
+ ? sourcePortSelector
+ : undefined)
+ if (!sourcePortId) continue
+
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `source-port:${sourcePortId}`,
+ )
+
+ const connectivityKey = sourcePortIdToConnectivityKey.get(sourcePortId)
+ if (connectivityKey) {
+ addTraceConnectivityKey(
+ traceIdToKeys,
+ keyToTraceIds,
+ sourceTraceId,
+ `connectivity:${connectivityKey}`,
+ )
+ }
+ }
+ }
+
+ const connectedSchematicTraceIdsByTraceId = new Map>()
+
+ for (const [
+ schematicTraceId,
+ sourceTraceId,
+ ] of schematicTraceIdToSourceTraceId) {
+ const connectedSchematicTraceIds = new Set()
+ const connectedSourceTraceIds = getConnectedSourceTraceIds(
+ sourceTraceId,
+ traceIdToKeys,
+ keyToTraceIds,
+ )
+
+ for (const connectedSourceTraceId of connectedSourceTraceIds) {
+ for (const connectedSchematicTraceId of sourceTraceIdToSchematicTraceIds.get(
+ connectedSourceTraceId,
+ ) ?? []) {
+ connectedSchematicTraceIds.add(connectedSchematicTraceId)
+ }
+ }
+
+ if (connectedSchematicTraceIds.size === 0) {
+ connectedSchematicTraceIds.add(schematicTraceId)
+ }
+
+ connectedSchematicTraceIdsByTraceId.set(
+ schematicTraceId,
+ connectedSchematicTraceIds,
+ )
+ }
+
+ return connectedSchematicTraceIdsByTraceId
+}
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..f8a5573
--- /dev/null
+++ b/tests/use-highlight-connected-traces-on-hover.test.ts
@@ -0,0 +1,147 @@
+import { expect, test } from "bun:test"
+import { getConnectedSchematicTraceIdsByHoveredTrace } from "../lib/utils/getConnectedSchematicTraceIdsByHoveredTrace"
+
+const sorted = (values: Set | undefined) =>
+ Array.from(values ?? []).sort()
+
+test("groups schematic traces through shared source net ids", () => {
+ const groups = getConnectedSchematicTraceIdsByHoveredTrace([
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_1",
+ connected_source_net_ids: ["source_net_gnd"],
+ },
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_2",
+ connected_source_net_ids: ["source_net_gnd"],
+ },
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_3",
+ connected_source_net_ids: ["source_net_vcc"],
+ },
+ {
+ 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",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_3",
+ source_trace_id: "source_trace_3",
+ },
+ ] as any)
+
+ expect(sorted(groups.get("schematic_trace_1"))).toEqual([
+ "schematic_trace_1",
+ "schematic_trace_2",
+ ])
+ expect(sorted(groups.get("schematic_trace_3"))).toEqual(["schematic_trace_3"])
+})
+
+test("falls back to connected source ports when source nets are missing", () => {
+ const groups = getConnectedSchematicTraceIdsByHoveredTrace([
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_1",
+ connected_source_port_ids: ["source_port_shared", "source_port_a"],
+ },
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_2",
+ connected_source_port_ids: ["source_port_shared", "source_port_b"],
+ },
+ {
+ 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(sorted(groups.get("schematic_trace_1"))).toEqual([
+ "schematic_trace_1",
+ "schematic_trace_2",
+ ])
+})
+
+test("groups multiple schematic segments from the same source trace", () => {
+ const groups = getConnectedSchematicTraceIdsByHoveredTrace([
+ {
+ 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_1",
+ },
+ ] as any)
+
+ expect(sorted(groups.get("schematic_trace_1"))).toEqual([
+ "schematic_trace_1",
+ "schematic_trace_2",
+ ])
+})
+
+test("resolves solver-generated source trace ids through source port connectivity", () => {
+ const groups = getConnectedSchematicTraceIdsByHoveredTrace([
+ {
+ type: "source_component",
+ source_component_id: "source_component_r1",
+ name: "R1",
+ },
+ {
+ 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",
+ name: "pin1",
+ pin_number: 1,
+ subcircuit_connectivity_map_key: "connectivity_gnd",
+ },
+ {
+ type: "source_port",
+ source_port_id: "source_port_c1_1",
+ source_component_id: "source_component_c1",
+ name: "pin1",
+ pin_number: 1,
+ subcircuit_connectivity_map_key: "connectivity_gnd",
+ },
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_direct",
+ connected_source_port_ids: ["source_port_c1_1"],
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_solver",
+ source_trace_id: "solver_R1.1-C1.1",
+ },
+ {
+ type: "schematic_trace",
+ schematic_trace_id: "schematic_trace_direct",
+ source_trace_id: "source_trace_direct",
+ },
+ ] as any)
+
+ expect(sorted(groups.get("schematic_trace_solver"))).toEqual([
+ "schematic_trace_direct",
+ "schematic_trace_solver",
+ ])
+})