From b78c8a4811daf2d0fd187bf9cd3f25a72f627f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=ED=9A=A8=EB=A6=BC?= Date: Sun, 17 May 2026 19:12:17 +0900 Subject: [PATCH] fix(schematic-viewer): highlight all connected traces on hover Closes tscircuit/tscircuit#1130 Adds useConnectedTracesHoverHighlighting hook that uses getFullConnectivityMapFromCircuitJson to compute the full equivalence class of source-level connectivity, then highlights every schematic_trace whose source_trace_id maps to the same net as the hovered trace. This fixes the issue raised on PR #149 where traces extending to the right (or otherwise disjoint trace segments on the same net) were not being highlighted. --- lib/components/SchematicViewer.tsx | 9 + .../useConnectedTracesHoverHighlighting.ts | 169 ++++++++++++++++++ package.json | 2 + 3 files changed, 180 insertions(+) create mode 100644 lib/hooks/useConnectedTracesHoverHighlighting.ts diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index ab4fd20..13d0c49 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -5,6 +5,7 @@ import { import { su } from "@tscircuit/soup-util" import { useChangeSchematicComponentLocationsInSvg } from "lib/hooks/useChangeSchematicComponentLocationsInSvg" import { useChangeSchematicTracesForMovedComponents } from "lib/hooks/useChangeSchematicTracesForMovedComponents" +import { useConnectedTracesHoverHighlighting } from "lib/hooks/useConnectedTracesHoverHighlighting" import { useSchematicGroupsOverlay } from "lib/hooks/useSchematicGroupsOverlay" import { enableDebug } from "lib/utils/debug" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -353,6 +354,14 @@ export const SchematicViewer = ({ showGroups: showSchematicGroups && !disableGroups, }) + // Highlight all traces on the same electrical net when hovering a trace + useConnectedTracesHoverHighlighting({ + svgDivRef, + circuitJson, + circuitJsonKey, + enabled: !editModeEnabled, + }) + // 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..041a845 --- /dev/null +++ b/lib/hooks/useConnectedTracesHoverHighlighting.ts @@ -0,0 +1,169 @@ +import { useEffect, useMemo, useRef } from "react" +import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map" +import { su } from "@tscircuit/soup-util" +import type { CircuitJson } from "circuit-json" + +const HIGHLIGHT_CLASS = "schematic-viewer-trace-hover-highlight" +const STYLE_ELEMENT_ID = "schematic-viewer-trace-hover-style" +const HIGHLIGHT_COLOR = "#ff6b35" + +const ensureStyleInjected = () => { + if (typeof document === "undefined") return + if (document.getElementById(STYLE_ELEMENT_ID)) return + + const style = document.createElement("style") + style.id = STYLE_ELEMENT_ID + style.textContent = ` +.${HIGHLIGHT_CLASS} path, +.${HIGHLIGHT_CLASS} line, +.${HIGHLIGHT_CLASS} polyline, +.${HIGHLIGHT_CLASS} circle, +.${HIGHLIGHT_CLASS} rect { + stroke: ${HIGHLIGHT_COLOR} !important; + stroke-width: 2.5px !important; + filter: drop-shadow(0 0 1.5px rgba(255, 107, 53, 0.55)); + transition: stroke 0.12s ease, stroke-width 0.12s ease; +} +` + document.head.appendChild(style) +} + +interface Options { + svgDivRef: React.RefObject + circuitJson: CircuitJson + circuitJsonKey: string + enabled?: boolean +} + +/** + * On hover over a schematic_trace, highlights every other trace that is on the + * same electrical net (computed via the full connectivity map, so disjoint + * trace segments that share a net or share a port are all highlighted). + */ +export const useConnectedTracesHoverHighlighting = ({ + svgDivRef, + circuitJson, + circuitJsonKey, + enabled = true, +}: Options) => { + const traceIdToGroupKey = useMemo(() => { + const map = new Map() + if (!enabled) return map + + try { + const sourceElements = circuitJson.filter((e) => + e.type.startsWith("source_"), + ) as any[] + + const connectivity = getFullConnectivityMapFromCircuitJson(sourceElements) + const netMap = connectivity.netMap as Record + + const sourceTraceIdToNetKey = new Map() + for (const [netKey, connectedIds] of Object.entries(netMap)) { + for (const id of connectedIds) { + if (id.startsWith("source_trace_")) { + sourceTraceIdToNetKey.set(id, netKey) + } + } + } + + const schematicTraces = + su(circuitJson as any).schematic_trace?.list() ?? [] + for (const schTrace of schematicTraces) { + const netKey = sourceTraceIdToNetKey.get(schTrace.source_trace_id) + if (netKey) { + map.set(schTrace.schematic_trace_id, netKey) + } + } + } catch (err) { + console.error( + "[schematic-viewer] failed to build trace connectivity map", + err, + ) + } + + return map + }, [circuitJsonKey, enabled]) + + const groupKeyToTraceIds = useMemo(() => { + const reverse = new Map>() + for (const [traceId, key] of traceIdToGroupKey.entries()) { + let set = reverse.get(key) + if (!set) { + set = new Set() + reverse.set(key, set) + } + set.add(traceId) + } + return reverse + }, [traceIdToGroupKey]) + + const activeKeyRef = useRef(null) + + useEffect(() => { + if (!enabled) return + const container = svgDivRef.current + if (!container) return + + ensureStyleInjected() + + const clearHighlight = () => { + if (!activeKeyRef.current) return + const elements = container.querySelectorAll(`.${HIGHLIGHT_CLASS}`) + elements.forEach((el) => el.classList.remove(HIGHLIGHT_CLASS)) + activeKeyRef.current = null + } + + const applyHighlight = (key: string) => { + if (activeKeyRef.current === key) return + clearHighlight() + const traceIds = groupKeyToTraceIds.get(key) + if (!traceIds || traceIds.size === 0) return + for (const traceId of traceIds) { + const escaped = + typeof CSS !== "undefined" && CSS.escape + ? CSS.escape(traceId) + : traceId.replace(/"/g, '\\"') + container + .querySelectorAll(`[data-schematic-trace-id="${escaped}"]`) + .forEach((el) => el.classList.add(HIGHLIGHT_CLASS)) + } + activeKeyRef.current = key + } + + const getTraceIdFromEvent = (target: EventTarget | null): string | null => { + if (!(target instanceof Element)) return null + const traceGroup = target.closest("[data-schematic-trace-id]") + if (!traceGroup) return null + return traceGroup.getAttribute("data-schematic-trace-id") + } + + const handleMouseOver = (e: MouseEvent) => { + const traceId = getTraceIdFromEvent(e.target) + if (!traceId) return + const key = traceIdToGroupKey.get(traceId) + if (!key) return + applyHighlight(key) + } + + const handleMouseOut = (e: MouseEvent) => { + const fromTraceId = getTraceIdFromEvent(e.target) + if (!fromTraceId) return + const toTraceId = getTraceIdFromEvent(e.relatedTarget) + if (toTraceId) { + const toKey = traceIdToGroupKey.get(toTraceId) + if (toKey && toKey === activeKeyRef.current) return + } + clearHighlight() + } + + container.addEventListener("mouseover", handleMouseOver) + container.addEventListener("mouseout", handleMouseOut) + + return () => { + container.removeEventListener("mouseover", handleMouseOver) + container.removeEventListener("mouseout", handleMouseOut) + clearHighlight() + } + }, [svgDivRef, enabled, traceIdToGroupKey, groupKeyToTraceIds]) +} diff --git a/package.json b/package.json index 8e1beb7..7ffbf76 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "tscircuit": "*" }, "dependencies": { + "@tscircuit/soup-util": "*", "chart.js": "^4.5.0", + "circuit-json-to-connectivity-map": "^0.0.17", "circuit-json-to-spice": "^0.0.30", "debug": "^4.4.0", "performance-now": "^2.1.0",