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,
}