From dc1af05d83e101673fd7380fca5a7ec0217b1d54 Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 27 Jan 2026 21:10:24 -0800 Subject: [PATCH 1/3] ability to show schematic ports on hover when view mode enabled --- CLAUDE.md | 1 + bun.lockb | Bin 275869 -> 275869 bytes .../example17-schematic-ports.fixture.tsx | 49 ++++ lib/components/SchematicPortMouseTarget.tsx | 218 ++++++++++++++++++ lib/components/SchematicViewer.tsx | 85 ++++++- lib/utils/z-index-map.ts | 1 + 6 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 examples/example17-schematic-ports.fixture.tsx create mode 100644 lib/components/SchematicPortMouseTarget.tsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..749a42d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +- Use `bun`/`bunx` instead of `npm`/`npx` \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 92e6b567130a2c2276b7c1efca112ccea0d688e9..23fde871b5890c282ff9be83264c7eb5e02c3e79 100755 GIT binary patch delta 33 rcmV++0N($d>kysm5P*aMv;uzMm#p#v2$w1(0~&{*-~zXx-~;_s18WZ- delta 39 xcmV+?0NDSX>kysm5P*aMv;uzM1+V}B0121h9s?DZfad}ThoImBx1ity{Zv085KsUB diff --git a/examples/example17-schematic-ports.fixture.tsx b/examples/example17-schematic-ports.fixture.tsx new file mode 100644 index 0000000..15a85af --- /dev/null +++ b/examples/example17-schematic-ports.fixture.tsx @@ -0,0 +1,49 @@ +import { SchematicViewer } from "lib/components/SchematicViewer" +import { renderToCircuitJson } from "lib/dev/render-to-circuit-json" + +const circuitJson = renderToCircuitJson( + + + + + + + + + + + + , +) + +export default () => ( + { + console.log("Port clicked:", schematicPortId) + }} + /> +) diff --git a/lib/components/SchematicPortMouseTarget.tsx b/lib/components/SchematicPortMouseTarget.tsx new file mode 100644 index 0000000..71d3b7a --- /dev/null +++ b/lib/components/SchematicPortMouseTarget.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { useMouseEventsOverBoundingBox } from "../hooks/useMouseEventsOverBoundingBox" +import type { BoundingBoxBounds } from "./MouseTracker" +import { zIndexMap } from "../utils/z-index-map" + +interface RelativeRect { + left: number + top: number + width: number + height: number +} + +interface Measurement { + bounds: BoundingBoxBounds + rect: RelativeRect +} + +const areMeasurementsEqual = (a: Measurement | null, b: Measurement | null) => { + if (!a && !b) return true + if (!a || !b) return false + return ( + Math.abs(a.bounds.minX - b.bounds.minX) < 0.5 && + Math.abs(a.bounds.maxX - b.bounds.maxX) < 0.5 && + Math.abs(a.bounds.minY - b.bounds.minY) < 0.5 && + Math.abs(a.bounds.maxY - b.bounds.maxY) < 0.5 && + Math.abs(a.rect.left - b.rect.left) < 0.5 && + Math.abs(a.rect.top - b.rect.top) < 0.5 && + Math.abs(a.rect.width - b.rect.width) < 0.5 && + Math.abs(a.rect.height - b.rect.height) < 0.5 + ) +} + +interface Props { + portId: string + portLabel?: string + svgDivRef: React.RefObject + containerRef: React.RefObject + onPortClick?: (portId: string, event: MouseEvent) => void + onHoverChange?: (portId: string, isHovering: boolean) => void + showOutline: boolean + circuitJsonKey: string +} + +export const SchematicPortMouseTarget = ({ + portId, + portLabel, + svgDivRef, + containerRef, + onPortClick, + onHoverChange, + showOutline, + circuitJsonKey, +}: Props) => { + const [measurement, setMeasurement] = useState(null) + const frameRef = useRef(null) + + const measure = useCallback(() => { + frameRef.current = null + const svgDiv = svgDivRef.current + const container = containerRef.current + if (!svgDiv || !container) { + setMeasurement((prev) => (prev ? null : prev)) + return + } + const element = svgDiv.querySelector( + `[data-schematic-port-id="${portId}"]`, + ) + if (!element) { + setMeasurement((prev) => (prev ? null : prev)) + return + } + + const elementRect = element.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + // Add some padding around the port for easier interaction + const padding = 4 + + const nextMeasurement: Measurement = { + bounds: { + minX: elementRect.left - padding, + maxX: elementRect.right + padding, + minY: elementRect.top - padding, + maxY: elementRect.bottom + padding, + }, + rect: { + left: elementRect.left - containerRect.left - padding, + top: elementRect.top - containerRect.top - padding, + width: elementRect.width + padding * 2, + height: elementRect.height + padding * 2, + }, + } + + setMeasurement((prev) => + areMeasurementsEqual(prev, nextMeasurement) ? prev : nextMeasurement, + ) + }, [portId, containerRef, svgDivRef]) + + const scheduleMeasure = useCallback(() => { + if (frameRef.current !== null) return + frameRef.current = window.requestAnimationFrame(measure) + }, [measure]) + + useEffect(() => { + scheduleMeasure() + }, [scheduleMeasure, circuitJsonKey]) + + useEffect(() => { + scheduleMeasure() + const svgDiv = svgDivRef.current + const container = containerRef.current + if (!svgDiv || !container) return + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => { + scheduleMeasure() + }) + : null + resizeObserver?.observe(container) + resizeObserver?.observe(svgDiv) + + const mutationObserver = + typeof MutationObserver !== "undefined" + ? new MutationObserver(() => { + scheduleMeasure() + }) + : null + mutationObserver?.observe(svgDiv, { + attributes: true, + attributeFilter: ["style", "transform"], + subtree: true, + childList: true, + }) + + window.addEventListener("scroll", scheduleMeasure, true) + window.addEventListener("resize", scheduleMeasure) + + return () => { + resizeObserver?.disconnect() + mutationObserver?.disconnect() + window.removeEventListener("scroll", scheduleMeasure, true) + window.removeEventListener("resize", scheduleMeasure) + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + } + }, [scheduleMeasure, svgDivRef, containerRef]) + + const handleClick = useCallback( + (event: MouseEvent) => { + if (onPortClick) { + onPortClick(portId, event) + } + }, + [portId, onPortClick], + ) + + const bounds = measurement?.bounds ?? null + + const { hovering } = useMouseEventsOverBoundingBox({ + bounds, + onClick: onPortClick ? handleClick : undefined, + }) + + // Notify parent of hover state changes + useEffect(() => { + if (onHoverChange) { + onHoverChange(portId, hovering) + } + }, [hovering, portId, onHoverChange]) + + if (!measurement || !hovering || !showOutline) { + return null + } + + const rect = measurement.rect + + return ( + <> +
+ {portLabel && ( +
+ {portLabel} +
+ )} + + ) +} diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index bac9443..fb48d3e 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -30,6 +30,7 @@ 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" interface Props { circuitJson: CircuitJson @@ -48,6 +49,11 @@ interface Props { schematicComponentId: string event: MouseEvent }) => void + showSchematicPorts?: boolean + onSchematicPortClicked?: (options: { + schematicPortId: string + event: MouseEvent + }) => void } export const SchematicViewer = ({ @@ -64,6 +70,8 @@ export const SchematicViewer = ({ spiceSimulationEnabled = false, disableGroups = false, onSchematicComponentClicked, + showSchematicPorts = false, + onSchematicPortClicked, }: Props) => { if (debug) { enableDebug() @@ -139,6 +147,22 @@ export const SchematicViewer = ({ }, [], ) + + const [isHoveringClickablePort, setIsHoveringClickablePort] = useState(false) + const hoveringPortsRef = useRef>(new Set()) + + const handlePortHoverChange = useCallback( + (portId: string, isHovering: boolean) => { + if (isHovering) { + hoveringPortsRef.current.add(portId) + } else { + hoveringPortsRef.current.delete(portId) + } + setIsHoveringClickablePort(hoveringPortsRef.current.size > 0) + }, + [], + ) + const svgDivRef = useRef(null) const touchStartRef = useRef<{ x: number; y: number } | null>(null) @@ -155,6 +179,35 @@ export const SchematicViewer = ({ } }, [circuitJsonKey, circuitJson]) + const schematicPortsInfo = useMemo(() => { + if (!showSchematicPorts) return [] + try { + const ports = su(circuitJson).schematic_port?.list() ?? [] + return ports.map((port) => { + const sourcePort = su(circuitJson).source_port.get(port.source_port_id) + const sourceComponent = + sourcePort?.source_component_id + ? su(circuitJson).source_component.get( + sourcePort.source_component_id, + ) + : null + const componentName = sourceComponent?.name ?? "?" + const pinLabel = + port.display_pin_label ?? + (sourcePort as any)?.pin_number ?? + (sourcePort as any)?.name ?? + "?" + return { + portId: port.source_port_id as string, + label: `${componentName}.${pinLabel}`, + } + }) + } catch (err) { + console.error("Failed to derive schematic port info", err) + return [] + } + }, [circuitJsonKey, circuitJson, showSchematicPorts]) + const handleTouchStart = (e: React.TouchEvent) => { const touch = e.touches[0] touchStartRef.current = { @@ -343,6 +396,11 @@ export const SchematicViewer = ({ {`.schematic-component-clickable [data-schematic-component-id]:hover { cursor: pointer !important; }`} )} + {onSchematicPortClicked && ( + + )}
))} + {showSchematicPorts && + schematicPortsInfo.map(({ portId, label }) => ( + { + onSchematicPortClicked?.({ + schematicPortId: id, + event, + }) + } + : undefined + } + /> + ))} {svgDiv}
diff --git a/lib/utils/z-index-map.ts b/lib/utils/z-index-map.ts index 083ef60..be9e55d 100644 --- a/lib/utils/z-index-map.ts +++ b/lib/utils/z-index-map.ts @@ -7,4 +7,5 @@ export const zIndexMap = { viewMenuBackdrop: 54, clickToInteractOverlay: 100, schematicComponentHoverOutline: 47, + schematicPortHoverOutline: 48, } From 8ffd0ecae07985e1a5b77b7898b6f9aaf7d2fa69 Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 27 Jan 2026 21:12:25 -0800 Subject: [PATCH 2/3] wip --- lib/components/SchematicPortMouseTarget.tsx | 12 +++++++++--- lib/components/SchematicViewer.tsx | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/components/SchematicPortMouseTarget.tsx b/lib/components/SchematicPortMouseTarget.tsx index 71d3b7a..fd62a67 100644 --- a/lib/components/SchematicPortMouseTarget.tsx +++ b/lib/components/SchematicPortMouseTarget.tsx @@ -171,7 +171,7 @@ export const SchematicPortMouseTarget = ({ } }, [hovering, portId, onHoverChange]) - if (!measurement || !hovering || !showOutline) { + if (!measurement || !showOutline) { return null } @@ -186,13 +186,19 @@ export const SchematicPortMouseTarget = ({ top: rect.top, width: rect.width, height: rect.height, - border: "1.5px solid rgba(255, 153, 51, 0.9)", + border: hovering + ? "1.5px solid rgba(255, 153, 51, 0.9)" + : "1.5px solid rgba(255, 153, 51, 0.3)", + backgroundColor: hovering + ? "rgba(255, 153, 51, 0.15)" + : "rgba(255, 153, 51, 0.05)", borderRadius: "50%", pointerEvents: "none", zIndex: zIndexMap.schematicPortHoverOutline, + transition: "border-color 0.15s, background-color 0.15s", }} /> - {portLabel && ( + {hovering && portLabel && (
))} + {svgDiv} {showSchematicPorts && schematicPortsInfo.map(({ portId, label }) => ( ))} - {svgDiv}
) From 3097066c22ab8f5fe0b50a46ca048c87d8c318a0 Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 27 Jan 2026 21:14:21 -0800 Subject: [PATCH 3/3] format --- lib/components/SchematicViewer.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 992a68b..94cc6d0 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -185,12 +185,9 @@ export const SchematicViewer = ({ const ports = su(circuitJson).schematic_port?.list() ?? [] return ports.map((port) => { const sourcePort = su(circuitJson).source_port.get(port.source_port_id) - const sourceComponent = - sourcePort?.source_component_id - ? su(circuitJson).source_component.get( - sourcePort.source_component_id, - ) - : null + const sourceComponent = sourcePort?.source_component_id + ? su(circuitJson).source_component.get(sourcePort.source_component_id) + : null const componentName = sourceComponent?.name ?? "?" const pinLabel = port.display_pin_label ??