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", + ]) + }) +})