diff --git a/examples/example13-trace-hover-highlighting-demo.fixture.tsx b/examples/example13-trace-hover-highlighting-demo.fixture.tsx new file mode 100644 index 0000000..7889a98 --- /dev/null +++ b/examples/example13-trace-hover-highlighting-demo.fixture.tsx @@ -0,0 +1,64 @@ +import { renderToCircuitJson } from "lib/dev/render-to-circuit-json"; +import { SchematicViewer } from "lib/index"; + +const circuit = ( + + {/* Components */} + + + + + + + + + {/* VCC POWER RAIL: Multiple traces connecting VCC pins and capacitor positive terminals */} + .pin8"} to={".U2 > .pin8"} /> + .pin8"} to={".C1 > .pin1"} /> + .pin1"} to={".C2 > .pin1"} /> + + {/* GROUND RAIL: Multiple traces connecting GND pins and capacitor negative terminals */} + .pin4"} to={".U2 > .pin4"} /> + .pin4"} to={".C1 > .pin2"} /> + .pin2"} to={".C2 > .pin2"} /> + .pin2"} to={".R1 > .pin2"} /> + + {/* SIGNAL NET 1: U1 output through R2 to LED */} + .pin1"} to={".R2 > .pin1"} /> + .pin2"} to={".LED1 > .anode"} /> + + {/* SIGNAL NET 2: U1 to U2 communication */} + .pin2"} to={".U2 > .pin1"} /> + + {/* SIGNAL NET 3: U2 output through R1 to ground (current sink) */} + .pin7"} to={".R1 > .pin1"} /> + + {/* LED cathode to ground */} + .cathode"} to={".U2 > .pin4"} /> + + {/* Internal chip connections (isolated) */} + .pin3"} to={".U1 > .pin6"} /> + .pin2"} to={".U2 > .pin3"} /> + +); + +/** + * Example showcasing the new trace hover highlighting feature + * + * Hover over any trace to see all connected traces in the same net highlighted. + * This helps visualize circuit connectivity and debug routing issues. + */ +export default () => { + const circuitJson = renderToCircuitJson(circuit); + + return ( +
+ +
+ ); +}; diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index cdbb89a..f30fc52 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -5,6 +5,7 @@ import { import { useChangeSchematicComponentLocationsInSvg } from "lib/hooks/useChangeSchematicComponentLocationsInSvg" import { useChangeSchematicTracesForMovedComponents } from "lib/hooks/useChangeSchematicTracesForMovedComponents" import { useSchematicGroupsOverlay } from "lib/hooks/useSchematicGroupsOverlay" +import { useConnectedTracesHoverHighlighting } from "lib/hooks/useConnectedTracesHoverHighlighting" import { enableDebug } from "lib/utils/debug" import { useEffect, useMemo, useRef, useState } from "react" import { @@ -243,6 +244,14 @@ export const SchematicViewer = ({ showGroups: showSchematicGroups, }) + // Add trace hover highlighting + useConnectedTracesHoverHighlighting({ + svgDivRef, + circuitJson, + circuitJsonKey, + enabled: true, + }) + const svgDiv = useMemo( () => (
; + circuitJson: any[]; + circuitJsonKey?: string; + enabled?: boolean; +} + +/** + * Optimized trace highlighting using CSS classes and circuit-to-svg metadata + */ +export const useConnectedTracesHoverHighlighting = ({ + svgDivRef, + circuitJson, + circuitJsonKey, + enabled = true, +}: useConnectedTracesHoverHighlightingOptions) => { + const activeNetRef = useRef(null); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + if (!enabled || !svgDivRef.current || !circuitJson || !circuitJsonKey) { + return; + } + + const svgContainer = svgDivRef.current; + + const handleTraceHover = (event: Event) => { + const target = event.currentTarget as SVGElement; + + const traceId = + target.getAttribute("data-schematic-trace-id") || + target.getAttribute("data-circuit-json-type") === "schematic_trace" + ? target.getAttribute("data-schematic-trace-id") + : null; + + if (!traceId) return; + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + const connectedTraces = findConnectedTraceIds(circuitJson, traceId); + activeNetRef.current = traceId; + + const svgElement = svgContainer.querySelector("svg"); + if (svgElement) { + svgElement.querySelectorAll(".trace-highlighted").forEach((el) => { + el.classList.remove("trace-highlighted"); + }); + + connectedTraces.forEach((connectedTraceId) => { + const traceElement = svgElement.querySelector( + `[data-schematic-trace-id="${connectedTraceId}"]` + ); + if (traceElement) { + traceElement.classList.add("trace-highlighted"); + } + }); + } + }; + + const handleTraceLeave = () => { + timeoutRef.current = setTimeout(() => { + const svgElement = svgContainer.querySelector("svg"); + if (svgElement) { + svgElement.querySelectorAll(".trace-highlighted").forEach((el) => { + el.classList.remove("trace-highlighted"); + }); + } + activeNetRef.current = null; + }, 50); + }; + + const observer = new MutationObserver(() => { + const svgElement = svgContainer.querySelector("svg"); + if (svgElement) { + addTraceHighlightingStyles(svgContainer); + + const traceElements = svgElement.querySelectorAll( + 'g.trace[data-circuit-json-type="schematic_trace"], [data-schematic-trace-id]' + ); + + if (traceElements.length > 0) { + traceElements.forEach((el) => { + el.addEventListener("mouseenter", handleTraceHover); + el.addEventListener("mouseleave", handleTraceLeave); + }); + + observer.disconnect(); + } + } + }); + + observer.observe(svgContainer, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + + const svgElement = svgContainer.querySelector("svg"); + if (svgElement) { + const traceElements = svgElement.querySelectorAll( + 'g.trace[data-circuit-json-type="schematic_trace"], [data-schematic-trace-id]' + ); + traceElements.forEach((el) => { + el.removeEventListener("mouseenter", handleTraceHover); + el.removeEventListener("mouseleave", handleTraceLeave); + }); + } + + removeTraceHighlightingStyles(svgContainer); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [svgDivRef, circuitJsonKey, enabled]); + + return { + currentHighlightedNet: activeNetRef.current, + }; +}; diff --git a/lib/utils/trace-connectivity.ts b/lib/utils/trace-connectivity.ts new file mode 100644 index 0000000..d9c7303 --- /dev/null +++ b/lib/utils/trace-connectivity.ts @@ -0,0 +1,62 @@ +import { su } from "@tscircuit/soup-util"; + +/** + * Finds all schematic traces that are electrically connected to the given trace + * @param circuitJson The circuit JSON data + * @param hoveredSchematicTraceId The ID of the trace being hovered + * @returns Array of connected trace IDs (including the original trace) + */ +export const findConnectedTraceIds = ( + circuitJson: any[], + hoveredSchematicTraceId: string +): string[] => { + try { + const soup = su(circuitJson); + + const schematicTrace = soup.schematic_trace.get(hoveredSchematicTraceId); + if (!schematicTrace) { + return [hoveredSchematicTraceId]; + } + + const allSchematicTraces = soup.schematic_trace.list(); + const allSourceTraces = soup.source_trace.list(); + + let sourceTrace = soup.source_trace.get(schematicTrace.source_trace_id); + + // Fallback: find by index if ID mismatch + if (!sourceTrace) { + const schematicTraceIndex = parseInt( + hoveredSchematicTraceId.split("_").pop() || "0" + ); + if (schematicTraceIndex < allSourceTraces.length) { + sourceTrace = allSourceTraces[schematicTraceIndex]; + } + } + + if (!sourceTrace) { + return [hoveredSchematicTraceId]; + } + + const connectedTraceIds = new Set([hoveredSchematicTraceId]); + + // Find traces with same connectivity key + const connectivityKey = sourceTrace.subcircuit_connectivity_map_key; + if (connectivityKey) { + for (const otherSourceTrace of allSourceTraces) { + if (otherSourceTrace.subcircuit_connectivity_map_key === connectivityKey) { + const sourceTraceIndex = allSourceTraces.findIndex( + (st) => st.source_trace_id === otherSourceTrace.source_trace_id + ); + if (sourceTraceIndex >= 0 && sourceTraceIndex < allSchematicTraces.length) { + const mappedSchematicTrace = allSchematicTraces[sourceTraceIndex]; + connectedTraceIds.add(mappedSchematicTrace.schematic_trace_id); + } + } + } + } + + return Array.from(connectedTraceIds); + } catch (error) { + return [hoveredSchematicTraceId]; + } +}; diff --git a/lib/utils/trace-highlighting-styles.ts b/lib/utils/trace-highlighting-styles.ts new file mode 100644 index 0000000..9fb29ff --- /dev/null +++ b/lib/utils/trace-highlighting-styles.ts @@ -0,0 +1,58 @@ +/** + * Optimized CSS-only approach for trace highlighting + * Instead of manipulating SVG, we inject CSS that works with existing circuit-to-svg structure + */ +export const addTraceHighlightingStyles = (svgContainer: HTMLElement): void => { + // Check if styles already added + const existingStyle = svgContainer.querySelector( + "style[data-trace-highlighting]" + ); + if (existingStyle) { + return; + } + + // Create style element + const styleElement = document.createElement("style"); + styleElement.setAttribute("data-trace-highlighting", "true"); + + styleElement.textContent = ` + /* Match the original .trace:hover behavior exactly */ + + /* Use the same filter effect as the original hover */ + svg .trace-highlighted { + filter: invert(1) !important; + } + + /* Hide crossing outlines on highlighted traces - matches original */ + svg .trace-highlighted .trace-crossing-outline { + // opacity: 0 !important; + } + + /* Ensure pointer cursor for all trace groups */ + svg g.trace[data-circuit-json-type="schematic_trace"]:hover { + cursor: pointer !important; + } + + /* Alternative selector for data-schematic-trace-id elements */ + svg [data-schematic-trace-id]:hover { + cursor: pointer !important; + } + `; + + // Add to container (not inside SVG) + svgContainer.appendChild(styleElement); +}; + +/** + * Remove trace highlighting styles + */ +export const removeTraceHighlightingStyles = ( + svgContainer: HTMLElement +): void => { + const styleElement = svgContainer.querySelector( + "style[data-trace-highlighting]" + ); + if (styleElement) { + styleElement.remove(); + } +};