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 92e6b56..23fde87 100755 Binary files a/bun.lockb and b/bun.lockb differ 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..fd62a67 --- /dev/null +++ b/lib/components/SchematicPortMouseTarget.tsx @@ -0,0 +1,224 @@ +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 || !showOutline) { + return null + } + + const rect = measurement.rect + + return ( + <> +
+ {hovering && portLabel && ( +
+ {portLabel} +
+ )} + + ) +} diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index bac9443..94cc6d0 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,32 @@ 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 +393,11 @@ export const SchematicViewer = ({ {`.schematic-component-clickable [data-schematic-component-id]:hover { cursor: pointer !important; }`} )} + {onSchematicPortClicked && ( + + )}
))} {svgDiv} + {showSchematicPorts && + schematicPortsInfo.map(({ portId, label }) => ( + { + onSchematicPortClicked?.({ + schematicPortId: id, + event, + }) + } + : undefined + } + /> + ))}
) 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, }