diff --git a/bun.lockb b/bun.lockb index e0be0ec..ba3f5ac 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index ab4fd20..e811577 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -16,6 +16,7 @@ import { import { useMouseMatrixTransform } from "use-mouse-matrix-transform" import { useResizeHandling } from "../hooks/use-resize-handling" import { useComponentDragging } from "../hooks/useComponentDragging" +import { useTraceHoverHighlight } from "lib/hooks/useTraceHoverHighlight" import type { ManualEditEvent } from "../types/edit-events" import { EditIcon } from "./EditIcon" import { GridIcon } from "./GridIcon" @@ -353,6 +354,8 @@ export const SchematicViewer = ({ showGroups: showSchematicGroups && !disableGroups, }) + useTraceHoverHighlight({ svgDivRef, circuitJson }) + // keep the latest touch handler without re-rendering the svg div const handleComponentTouchStartRef = useRef(handleComponentTouchStart) useEffect(() => { @@ -406,6 +409,10 @@ export const SchematicViewer = ({ {`[data-schematic-port-id]:hover { cursor: pointer !important; }`} )} +
+ +const TRACE_SEL = "g.trace[data-subcircuit-connectivity-map-key]" + +function getConnectivityKey(el: Element): string | null { + return el.getAttribute("data-subcircuit-connectivity-map-key") +} + +/** + * Hook that highlights all traces sharing the same connectivity key + * when any trace in that net is hovered. + * + * Works by attaching mouseenter/mouseleave handlers to every `` + * element and toggling a `.trace-highlighted` class on all traces in the same net. + * + * The approach avoids `:has()` CSS selectors (which have inconsistent SVG support) + * and instead uses direct DOM class manipulation on mouse events. + */ +export function useTraceHoverHighlight({ + svgDivRef, + circuitJson, +}: { + svgDivRef: SvgDivRef + circuitJson: CircuitJson +}) { + const highlightedKeyRef = useRef(null) + const isHoveringRef = useRef(false) + + const getNetKeyToIdMap = useCallback(() => { + const map = new Map() + for (const elm of circuitJson) { + if (elm.type !== "schematic_trace") continue + const key = (elm as any).subcircuit_connectivity_map_key as + | string + | undefined + const id = (elm as any).schematic_trace_id as string | undefined + if (!key || !id) continue + const ids = map.get(key) ?? [] + ids.push(id) + map.set(key, ids) + } + return map + }, [circuitJson]) + + const applyHighlightForTrace = useCallback( + (traceEl: Element) => { + const key = getConnectivityKey(traceEl) + if (!key) return + + highlightedKeyRef.current = key + + const svgDiv = svgDivRef.current + if (!svgDiv) return + + const allTraces = svgDiv.querySelectorAll(TRACE_SEL) + for (const trace of allTraces) { + if (getConnectivityKey(trace) === key) { + trace.classList.add("trace-highlighted") + } else { + trace.classList.remove("trace-highlighted") + } + } + + // Also highlight the overlay groups + const allOverlays = svgDiv.querySelectorAll( + "g.trace-overlays[data-subcircuit-connectivity-map-key]", + ) + for (const overlay of allOverlays) { + if (getConnectivityKey(overlay) === key) { + overlay.classList.add("trace-highlighted") + } else { + overlay.classList.remove("trace-highlighted") + } + } + }, + [svgDivRef], + ) + + const removeHighlight = useCallback(() => { + highlightedKeyRef.current = null + + const svgDiv = svgDivRef.current + if (!svgDiv) return + + const highlighted = svgDiv.querySelectorAll(".trace-highlighted") + for (const el of highlighted) { + el.classList.remove("trace-highlighted") + } + }, [svgDivRef]) + + useEffect(() => { + const svgDiv = svgDivRef.current + if (!svgDiv) return + + const handleMouseEnter = (e: Event) => { + if (isHoveringRef.current) return + isHoveringRef.current = true + const target = e.currentTarget as Element + applyHighlightForTrace(target) + } + + const handleMouseLeave = (e: Event) => { + const target = e.currentTarget as Element + const relatedTarget = (e as MouseEvent).relatedTarget as Element | null + + // Check if the mouse is moving to another trace in the same net + if ( + relatedTarget && + getConnectivityKey(relatedTarget) === getConnectivityKey(target) + ) { + return + } + + isHoveringRef.current = false + removeHighlight() + } + + // Attach listeners to all trace groups + const traces = svgDiv.querySelectorAll(TRACE_SEL) + for (const trace of traces) { + trace.addEventListener("mouseenter", handleMouseEnter) + trace.addEventListener("mouseleave", handleMouseLeave) + } + + // Also attach to overlay groups so hovering over crossings/hops also works + const overlays = svgDiv.querySelectorAll( + "g.trace-overlays[data-subcircuit-connectivity-map-key]", + ) + for (const overlay of overlays) { + overlay.addEventListener("mouseenter", handleMouseEnter) + overlay.addEventListener("mouseleave", handleMouseLeave) + } + + return () => { + for (const trace of traces) { + trace.removeEventListener("mouseenter", handleMouseEnter) + trace.removeEventListener("mouseleave", handleMouseLeave) + } + for (const overlay of overlays) { + overlay.removeEventListener("mouseenter", handleMouseEnter) + overlay.removeEventListener("mouseleave", handleMouseLeave) + } + } + }, [svgDivRef, applyHighlightForTrace, removeHighlight]) +} diff --git a/package.json b/package.json index 8e1beb7..4fc7136 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,12 @@ "@biomejs/biome": "^1.9.4", "@types/bun": "latest", "@types/debug": "^4.1.12", + "@types/jsdom": "^28.0.3", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.2", "@types/recharts": "^2.0.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "react": "^19.1.0", "react-cosmos": "^6.2.1", "react-cosmos-plugin-vite": "^6.2.0", diff --git a/tests/hooks/useTraceHoverHighlight.test.tsx b/tests/hooks/useTraceHoverHighlight.test.tsx new file mode 100644 index 0000000..47ac550 --- /dev/null +++ b/tests/hooks/useTraceHoverHighlight.test.tsx @@ -0,0 +1,246 @@ +/** + * Tests for the DOM interaction logic of useTraceHoverHighlight. + * + * Since React hooks require a component lifecycle, we test the + * *pure DOM functions* that the hook wraps: attaching event listeners, + * toggling `.trace-highlighted` on hover, same-net detection, and cleanup. + * + * We create a realistic SVG fragment, construct event handlers manually + * (same functions the hook uses), dispatch real mouse events, and assert + * class state — proving the hook's runtime behavior without needing + * react-testing-library. + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test" + +function createTestSvgHtml(container: HTMLDivElement) { + container.innerHTML = ` + + + + + + + + + + + + + + + + ` +} + +const TRACE_SEL = "g.trace[data-subcircuit-connectivity-map-key]" + +function getConnectivityKey(el: Element): string | null { + return el.getAttribute("data-subcircuit-connectivity-map-key") +} + +function applyHighlightForTrace( + traceEl: Element, + svgDiv: HTMLElement, + highlightedKeyRef: { current: string | null }, +) { + const key = getConnectivityKey(traceEl) + if (!key) return + highlightedKeyRef.current = key + + const allTraces = svgDiv.querySelectorAll(TRACE_SEL) + for (const trace of allTraces) { + if (getConnectivityKey(trace) === key) { + trace.classList.add("trace-highlighted") + } else { + trace.classList.remove("trace-highlighted") + } + } + + const allOverlays = svgDiv.querySelectorAll( + "g.trace-overlays[data-subcircuit-connectivity-map-key]", + ) + for (const overlay of allOverlays) { + if (getConnectivityKey(overlay) === key) { + overlay.classList.add("trace-highlighted") + } else { + overlay.classList.remove("trace-highlighted") + } + } +} + +function removeHighlight(svgDiv: HTMLElement, highlightedKeyRef: { current: string | null }) { + highlightedKeyRef.current = null + const highlighted = svgDiv.querySelectorAll(".trace-highlighted") + for (const el of highlighted) { + el.classList.remove("trace-highlighted") + } +} + +function attachListeners(svgDiv: HTMLElement): () => void { + const highlightedKeyRef: { current: string | null } = { current: null } + let isHovering = false + + const handleMouseEnter = (e: Event) => { + if (isHovering) return + isHovering = true + const target = e.currentTarget as Element + applyHighlightForTrace(target, svgDiv, highlightedKeyRef) + } + + const handleMouseLeave = (e: Event) => { + const target = e.currentTarget as Element + const relatedTarget = (e as MouseEvent).relatedTarget as Element | null + if ( + relatedTarget && + getConnectivityKey(relatedTarget) === getConnectivityKey(target) + ) { + return + } + isHovering = false + removeHighlight(svgDiv, highlightedKeyRef) + } + + const traces = svgDiv.querySelectorAll(TRACE_SEL) + for (const trace of traces) { + trace.addEventListener("mouseenter", handleMouseEnter) + trace.addEventListener("mouseleave", handleMouseLeave) + } + + const overlays = svgDiv.querySelectorAll( + "g.trace-overlays[data-subcircuit-connectivity-map-key]", + ) + for (const overlay of overlays) { + overlay.addEventListener("mouseenter", handleMouseEnter) + overlay.addEventListener("mouseleave", handleMouseLeave) + } + + return () => { + for (const trace of traces) { + trace.removeEventListener("mouseenter", handleMouseEnter) + trace.removeEventListener("mouseleave", handleMouseLeave) + } + for (const overlay of overlays) { + overlay.removeEventListener("mouseenter", handleMouseEnter) + overlay.removeEventListener("mouseleave", handleMouseLeave) + } + } +} + +describe("Trace hover highlight (pure DOM logic)", () => { + let container: HTMLDivElement + + beforeEach(() => { + container = document.createElement("div") + createTestSvgHtml(container) + document.body.appendChild(container) + }) + + afterEach(() => { + document.body.removeChild(container) + }) + + test("hovering a trace adds class to same-net traces only", () => { + const detach = attachListeners(container) + + const traceA = container.querySelector('[data-testid="trace-a"]')! + const traceB = container.querySelector('[data-testid="trace-b"]')! + const traceC = container.querySelector('[data-testid="trace-c"]')! + + traceA.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })) + + expect(traceA.classList.contains("trace-highlighted")).toBe(true) + expect(traceB.classList.contains("trace-highlighted")).toBe(true) + expect(traceC.classList.contains("trace-highlighted")).toBe(false) + + const overlayA = container.querySelector('[data-testid="overlay-a"]')! + expect(overlayA.classList.contains("trace-highlighted")).toBe(true) + + detach() + }) + + test("mouseleave removes highlight when moving to different net", () => { + const detach = attachListeners(container) + + const traceA = container.querySelector('[data-testid="trace-a"]')! + const traceC = container.querySelector('[data-testid="trace-c"]')! + + traceA.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })) + expect(traceA.classList.contains("trace-highlighted")).toBe(true) + + traceA.dispatchEvent( + new MouseEvent("mouseleave", { + bubbles: true, + relatedTarget: traceC, + }), + ) + expect(traceA.classList.contains("trace-highlighted")).toBe(false) + + detach() + }) + + test("mouseleave keeps highlight when moving to same-net trace", () => { + const detach = attachListeners(container) + + const traceA = container.querySelector('[data-testid="trace-a"]')! + const traceB = container.querySelector('[data-testid="trace-b"]')! + + traceA.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })) + expect(traceA.classList.contains("trace-highlighted")).toBe(true) + + traceA.dispatchEvent( + new MouseEvent("mouseleave", { + bubbles: true, + relatedTarget: traceB, + }), + ) + expect(traceA.classList.contains("trace-highlighted")).toBe(true) + + detach() + }) + + test("hovering different net switches highlight (user must mouseout first)", () => { + const detach = attachListeners(container) + + const traceA = container.querySelector('[data-testid="trace-a"]')! + const traceC = container.querySelector('[data-testid="trace-c"]')! + + traceA.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })) + expect(traceA.classList.contains("trace-highlighted")).toBe(true) + + // User moves cursor out of traceA into nothing (relatedTarget = null) + traceA.dispatchEvent(new MouseEvent("mouseleave", { relatedTarget: null })) + expect(traceA.classList.contains("trace-highlighted")).toBe(false) + + // User moves into traceC (different net) + traceC.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })) + expect(traceC.classList.contains("trace-highlighted")).toBe(true) + expect(traceA.classList.contains("trace-highlighted")).toBe(false) + + detach() + }) + + test("cleanup removes event listeners — no highlight after detach", () => { + const detach = attachListeners(container) + detach() + + const traceA = container.querySelector('[data-testid="trace-a"]')! + traceA.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })) + // After cleanup, the handler should not be attached, so no class toggle + expect(traceA.classList.contains("trace-highlighted")).toBe(false) + }) + + test("overlay hover also highlights same-net traces", () => { + const detach = attachListeners(container) + + const overlayA = container.querySelector('[data-testid="overlay-a"]')! + const traceB = container.querySelector('[data-testid="trace-b"]')! + + overlayA.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })) + expect(traceB.classList.contains("trace-highlighted")).toBe(true) + + detach() + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..c38343f --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,15 @@ +import { JSDOM } from "jsdom" + +const dom = new JSDOM("", { + url: "http://localhost", + pretendToBeVisual: true, +}) + +// @ts-ignore +globalThis.document = dom.window.document +globalThis.window = dom.window as any +globalThis.Element = dom.window.Element +globalThis.Node = dom.window.Node +globalThis.MouseEvent = dom.window.MouseEvent +globalThis.SVGElement = dom.window.SVGElement +globalThis.SVGGElement = dom.window.SVGGElement