diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 51e782a..24e2943 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 { useConnectedTracesHoverHighlighting } from "lib/hooks/useConnectedTracesHoverHighlighting" import { enableDebug } from "lib/utils/debug" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { @@ -294,6 +295,13 @@ export const SchematicViewer = ({ showGroups: showSchematicGroups && !disableGroups, }) + // Add trace hover highlighting for electrical connectivity + useConnectedTracesHoverHighlighting({ + svgDivRef, + circuitJson, + enabled: !editModeEnabled && !showSpiceOverlay, + }) + // keep the latest touch handler without re-rendering the svg div const handleComponentTouchStartRef = useRef(handleComponentTouchStart) useEffect(() => { diff --git a/lib/hooks/useConnectedTracesHoverHighlighting.ts b/lib/hooks/useConnectedTracesHoverHighlighting.ts new file mode 100644 index 0000000..1cc2fca --- /dev/null +++ b/lib/hooks/useConnectedTracesHoverHighlighting.ts @@ -0,0 +1,166 @@ +import { useEffect, useRef } from "react" +import type { CircuitJson } from "circuit-json" +import { findConnectedTraceIds } from "../utils/trace-connectivity" + +/** + * Hook that adds hover highlighting functionality to schematic traces. + * When hovering over a trace, all electrically connected traces in the same net + * will be highlighted with an orange glow effect. + */ +export const useConnectedTracesHoverHighlighting = ({ + svgDivRef, + circuitJson, + enabled = true, +}: { + svgDivRef: React.RefObject + circuitJson: CircuitJson + enabled?: boolean +}) => { + const highlightedTracesRef = useRef>(new Set()) + const highlightTimeoutRef = useRef | null>(null) + + useEffect(() => { + if (!enabled) return + + const svgElement = svgDivRef.current + if (!svgElement) return + + const handleTraceMouseEnter = (event: Event) => { + const target = event.target as Element + const traceGroup = target.closest("[data-schematic-trace-id]") + if (!traceGroup) return + + const traceId = traceGroup.getAttribute("data-schematic-trace-id") + if (!traceId) return + + // Clear any pending unhighlight timeout + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current) + highlightTimeoutRef.current = null + } + + const connectedTraces = findConnectedTraceIds(circuitJson, traceId) + + // Clear previous highlights + highlightedTracesRef.current.forEach((highlightedTraceId) => { + const traceElement = svgElement.querySelector( + `[data-schematic-trace-id="${highlightedTraceId}"]`, + ) + if (traceElement) { + traceElement.classList.remove("trace-highlighted") + } + }) + + // Highlight all connected traces + const newHighlightedTraces = new Set() + connectedTraces.forEach((connectedTraceId) => { + const traceElement = svgElement.querySelector( + `[data-schematic-trace-id="${connectedTraceId}"]`, + ) + if (traceElement) { + traceElement.classList.add("trace-highlighted") + newHighlightedTraces.add(connectedTraceId) + } + }) + + highlightedTracesRef.current = newHighlightedTraces + } + + const handleTraceMouseLeave = (event: Event) => { + // Add small delay to prevent flickering when moving between connected traces + highlightTimeoutRef.current = setTimeout(() => { + highlightedTracesRef.current.forEach((highlightedTraceId) => { + const traceElement = svgElement.querySelector( + `[data-schematic-trace-id="${highlightedTraceId}"]`, + ) + if (traceElement) { + traceElement.classList.remove("trace-highlighted") + } + }) + highlightedTracesRef.current.clear() + }, 50) + } + + const addEventListeners = () => { + const traceElements = svgElement.querySelectorAll( + "[data-schematic-trace-id]", + ) + + // Inject CSS styles if not already present + if (!svgElement.querySelector("style#trace-highlighting-styles")) { + const style = document.createElement("style") + style.id = "trace-highlighting-styles" + style.textContent = ` + .trace-highlighted { + stroke-width: 3 !important; + stroke: #ff6b35 !important; + filter: drop-shadow(0 0 3px rgba(255, 107, 53, 0.6)) !important; + transition: all 0.15s ease-in-out !important; + z-index: 100 !important; + } + + [data-schematic-trace-id] { + cursor: pointer; + } + + [data-schematic-trace-id]:hover { + opacity: 0.8; + } + + [data-schematic-trace-id].trace-highlighted:hover { + opacity: 1; + } + ` + svgElement.appendChild(style) + } + + traceElements.forEach((traceElement) => { + traceElement.addEventListener("mouseenter", handleTraceMouseEnter) + traceElement.addEventListener("mouseleave", handleTraceMouseLeave) + }) + } + + const removeEventListeners = () => { + const traceElements = svgElement.querySelectorAll( + "[data-schematic-trace-id]", + ) + + traceElements.forEach((traceElement) => { + traceElement.removeEventListener("mouseenter", handleTraceMouseEnter) + traceElement.removeEventListener("mouseleave", handleTraceMouseLeave) + }) + } + + addEventListeners() + + const observer = new MutationObserver(() => { + removeEventListeners() + addEventListeners() + }) + + observer.observe(svgElement, { + childList: true, + subtree: true, + }) + + return () => { + observer.disconnect() + removeEventListeners() + + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current) + } + + // Clear any remaining highlights + highlightedTracesRef.current.forEach((highlightedTraceId) => { + const traceElement = svgElement?.querySelector( + `[data-schematic-trace-id="${highlightedTraceId}"]`, + ) + if (traceElement) { + traceElement.classList.remove("trace-highlighted") + } + }) + highlightedTracesRef.current.clear() + } + }, [svgDivRef, circuitJson, enabled]) +} diff --git a/lib/utils/trace-connectivity.ts b/lib/utils/trace-connectivity.ts new file mode 100644 index 0000000..8bb7fa3 --- /dev/null +++ b/lib/utils/trace-connectivity.ts @@ -0,0 +1,82 @@ +import { su } from "@tscircuit/soup-util" +import type { CircuitJson } from "circuit-json" + +/** + * Finds all schematic traces electrically connected to the hovered trace. + * Uses source_net as the source of truth for electrical connectivity. + * + * Algorithm: + * 1. Get schematic_trace by ID + * 2. Find its source_trace + * 3. Get connected_source_net_ids from source_trace + * 4. Find ALL source_traces that share ANY of these net IDs + * 5. Map back to schematic_trace_ids + * + * @param circuitJson - Circuit JSON soup data + * @param hoveredSchematicTraceId - The schematic_trace_id being hovered + * @returns Array of all connected schematic_trace_ids (including hovered) + */ +export const findConnectedTraceIds = ( + circuitJson: CircuitJson, + hoveredSchematicTraceId: string, +): string[] => { + try { + const soup = su(circuitJson) + + // STEP 1: Get the hovered schematic trace + const hoveredSchematicTrace = soup.schematic_trace.get( + hoveredSchematicTraceId, + ) + if (!hoveredSchematicTrace) { + return [hoveredSchematicTraceId] + } + + // STEP 2: Get the corresponding source_trace + const hoveredSourceTrace = soup.source_trace.get( + hoveredSchematicTrace.source_trace_id, + ) + if (!hoveredSourceTrace) { + return [hoveredSchematicTraceId] + } + + // STEP 3: Get all net IDs this trace belongs to (THE KEY!) + const connectedNetIds = hoveredSourceTrace.connected_source_net_ids || [] + if (connectedNetIds.length === 0) { + return [hoveredSchematicTraceId] + } + + // STEP 4: Find ALL source_traces that share ANY of these nets + const allSourceTraces = soup.source_trace.list() + const connectedSourceTraceIds = new Set() + + for (const sourceTrace of allSourceTraces) { + const sourceTraceNetIds = sourceTrace.connected_source_net_ids || [] + + // Check if this source_trace shares any net with our hovered trace + const sharesNet = sourceTraceNetIds.some((netId) => + connectedNetIds.includes(netId), + ) + + if (sharesNet) { + connectedSourceTraceIds.add(sourceTrace.source_trace_id) + } + } + + // STEP 5: Map source_trace_ids back to schematic_trace_ids + const allSchematicTraces = soup.schematic_trace.list() + const connectedSchematicTraceIds = new Set([ + hoveredSchematicTraceId, + ]) + + for (const schematicTrace of allSchematicTraces) { + if (connectedSourceTraceIds.has(schematicTrace.source_trace_id)) { + connectedSchematicTraceIds.add(schematicTrace.schematic_trace_id) + } + } + + return Array.from(connectedSchematicTraceIds) + } catch (error) { + console.error("[trace-connectivity] Error finding connected traces:", error) + return [hoveredSchematicTraceId] + } +}