From cb884f61a615b412bc1a1ac7f98d790acff0e88d Mon Sep 17 00:00:00 2001 From: Suryatheja Date: Wed, 10 Jun 2026 23:07:15 -0500 Subject: [PATCH] highlight connected schematic traces on hover --- ...xample21-connected-trace-hover.fixture.tsx | 62 ++++ lib/components/SchematicViewer.tsx | 39 ++- .../useHighlightConnectedTracesOnHover.ts | 104 ++++++ ...onnectedSchematicTraceIdsByHoveredTrace.ts | 316 ++++++++++++++++++ ...ighlight-connected-traces-on-hover.test.ts | 147 ++++++++ 5 files changed, 655 insertions(+), 13 deletions(-) create mode 100644 examples/example21-connected-trace-hover.fixture.tsx create mode 100644 lib/hooks/useHighlightConnectedTracesOnHover.ts create mode 100644 lib/utils/getConnectedSchematicTraceIdsByHoveredTrace.ts create mode 100644 tests/use-highlight-connected-traces-on-hover.test.ts diff --git a/examples/example21-connected-trace-hover.fixture.tsx b/examples/example21-connected-trace-hover.fixture.tsx new file mode 100644 index 0000000..37b59cc --- /dev/null +++ b/examples/example21-connected-trace-hover.fixture.tsx @@ -0,0 +1,62 @@ +import type { CircuitJson } from "circuit-json" +import { SchematicViewer } from "lib/components/SchematicViewer" + +const circuitJson = [ + { + type: "source_net", + source_net_id: "source_net_gnd", + name: "GND", + }, + { + type: "source_net", + source_net_id: "source_net_signal", + name: "SIGNAL", + }, + { + type: "source_trace", + source_trace_id: "source_trace_gnd_left", + connected_source_net_ids: ["source_net_gnd"], + connected_source_port_ids: [], + }, + { + type: "source_trace", + source_trace_id: "source_trace_gnd_right", + connected_source_net_ids: ["source_net_gnd"], + connected_source_port_ids: [], + }, + { + type: "source_trace", + source_trace_id: "source_trace_signal", + connected_source_net_ids: ["source_net_signal"], + connected_source_port_ids: [], + }, + { + type: "schematic_trace", + schematic_trace_id: "schematic_trace_gnd_left", + source_trace_id: "source_trace_gnd_left", + edges: [{ from: { x: -4, y: 1 }, to: { x: -1, y: 1 } }], + junctions: [{ x: -1, y: 1 }], + }, + { + type: "schematic_trace", + schematic_trace_id: "schematic_trace_gnd_right", + source_trace_id: "source_trace_gnd_right", + edges: [{ from: { x: 1, y: 1 }, to: { x: 4, y: 1 } }], + junctions: [{ x: 1, y: 1 }], + }, + { + type: "schematic_trace", + schematic_trace_id: "schematic_trace_signal", + source_trace_id: "source_trace_signal", + edges: [{ from: { x: -4, y: -1 }, to: { x: 4, y: -1 } }], + junctions: [], + }, +] as CircuitJson + +export default () => ( + +) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index ab4fd20..01cb960 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -1,10 +1,16 @@ +import { su } from "@tscircuit/soup-util" +import type { CircuitJson } from "circuit-json" import { - convertCircuitJsonToSchematicSvg, type ColorOverrides, + convertCircuitJsonToSchematicSvg, } from "circuit-to-svg" -import { su } from "@tscircuit/soup-util" import { useChangeSchematicComponentLocationsInSvg } from "lib/hooks/useChangeSchematicComponentLocationsInSvg" import { useChangeSchematicTracesForMovedComponents } from "lib/hooks/useChangeSchematicTracesForMovedComponents" +import { + schematicTraceHoverStyle, + useHighlightConnectedTracesOnHover, +} from "lib/hooks/useHighlightConnectedTracesOnHover" +import { getStoredBoolean, setStoredBoolean } from "lib/hooks/useLocalStorage" import { useSchematicGroupsOverlay } from "lib/hooks/useSchematicGroupsOverlay" import { enableDebug } from "lib/utils/debug" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -16,21 +22,19 @@ import { import { useMouseMatrixTransform } from "use-mouse-matrix-transform" import { useResizeHandling } from "../hooks/use-resize-handling" import { useComponentDragging } from "../hooks/useComponentDragging" +import { useSpiceSimulation } from "../hooks/useSpiceSimulation" import type { ManualEditEvent } from "../types/edit-events" +import { getSpiceFromCircuitJson } from "../utils/spice-utils" +import { zIndexMap } from "../utils/z-index-map" import { EditIcon } from "./EditIcon" import { GridIcon } from "./GridIcon" -import { ViewMenuIcon } from "./ViewMenuIcon" -import { ViewMenu } from "./ViewMenu" -import type { CircuitJson } from "circuit-json" -import { SpiceSimulationIcon } from "./SpiceSimulationIcon" -import { SpiceSimulationOverlay } from "./SpiceSimulationOverlay" -import { zIndexMap } from "../utils/z-index-map" -import { useSpiceSimulation } from "../hooks/useSpiceSimulation" -import { getSpiceFromCircuitJson } from "../utils/spice-utils" -import { getStoredBoolean, setStoredBoolean } from "lib/hooks/useLocalStorage" import { MouseTracker } from "./MouseTracker" import { SchematicComponentMouseTarget } from "./SchematicComponentMouseTarget" import { SchematicPortMouseTarget } from "./SchematicPortMouseTarget" +import { SpiceSimulationIcon } from "./SpiceSimulationIcon" +import { SpiceSimulationOverlay } from "./SpiceSimulationOverlay" +import { ViewMenu } from "./ViewMenu" +import { ViewMenuIcon } from "./ViewMenuIcon" interface Props { circuitJson: CircuitJson @@ -345,6 +349,12 @@ export const SchematicViewer = ({ editEvents: editEventsWithUnappliedEditEvents, }) + useHighlightConnectedTracesOnHover({ + svgDivRef, + circuitJson, + circuitJsonKey, + }) + // Add group overlays when enabled useSchematicGroupsOverlay({ svgDivRef, @@ -398,14 +408,17 @@ export const SchematicViewer = ({ {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", + ]) +})