@tscircuit/schematic-viewer 2.0.41 → 2.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import {
2
2
  convertCircuitJsonToSchematicSvg,
3
3
  type ColorOverrides,
4
4
  } from "circuit-to-svg"
5
+ import { su } from "@tscircuit/soup-util"
5
6
  import { useChangeSchematicComponentLocationsInSvg } from "lib/hooks/useChangeSchematicComponentLocationsInSvg"
6
7
  import { useChangeSchematicTracesForMovedComponents } from "lib/hooks/useChangeSchematicTracesForMovedComponents"
7
8
  import { useSchematicGroupsOverlay } from "lib/hooks/useSchematicGroupsOverlay"
@@ -27,6 +28,8 @@ import { zIndexMap } from "../utils/z-index-map"
27
28
  import { useSpiceSimulation } from "../hooks/useSpiceSimulation"
28
29
  import { getSpiceFromCircuitJson } from "../utils/spice-utils"
29
30
  import { getStoredBoolean, setStoredBoolean } from "lib/hooks/useLocalStorage"
31
+ import { MouseTracker } from "./MouseTracker"
32
+ import { SchematicComponentMouseTarget } from "./SchematicComponentMouseTarget"
30
33
 
31
34
  interface Props {
32
35
  circuitJson: CircuitJson
@@ -41,6 +44,10 @@ interface Props {
41
44
  colorOverrides?: ColorOverrides
42
45
  spiceSimulationEnabled?: boolean
43
46
  disableGroups?: boolean
47
+ onSchematicComponentClicked?: (options: {
48
+ schematicComponentId: string
49
+ event: MouseEvent
50
+ }) => void
44
51
  }
45
52
 
46
53
  export const SchematicViewer = ({
@@ -56,6 +63,7 @@ export const SchematicViewer = ({
56
63
  colorOverrides,
57
64
  spiceSimulationEnabled = false,
58
65
  disableGroups = false,
66
+ onSchematicComponentClicked,
59
67
  }: Props) => {
60
68
  if (debug) {
61
69
  enableDebug()
@@ -92,12 +100,18 @@ export const SchematicViewer = ({
92
100
  spiceSimOptions.duration,
93
101
  ])
94
102
 
103
+ const [hasSpiceSimRun, setHasSpiceSimRun] = useState(false)
104
+
105
+ useEffect(() => {
106
+ setHasSpiceSimRun(false)
107
+ }, [circuitJsonKey])
108
+
95
109
  const {
96
110
  plotData,
97
111
  nodes,
98
112
  isLoading: isSpiceSimLoading,
99
113
  error: spiceSimError,
100
- } = useSpiceSimulation(spiceString)
114
+ } = useSpiceSimulation(hasSpiceSimRun ? spiceString : null)
101
115
 
102
116
  const [editModeEnabled, setEditModeEnabled] = useState(defaultEditMode)
103
117
  const [snapToGrid, setSnapToGrid] = useState(true)
@@ -112,6 +126,19 @@ export const SchematicViewer = ({
112
126
  const svgDivRef = useRef<HTMLDivElement>(null)
113
127
  const touchStartRef = useRef<{ x: number; y: number } | null>(null)
114
128
 
129
+ const schematicComponentIds = useMemo(() => {
130
+ try {
131
+ return (
132
+ su(circuitJson)
133
+ .schematic_component?.list()
134
+ ?.map((component) => component.schematic_component_id as string) ?? []
135
+ )
136
+ } catch (err) {
137
+ console.error("Failed to derive schematic component ids", err)
138
+ return []
139
+ }
140
+ }, [circuitJsonKey, circuitJson])
141
+
115
142
  const handleTouchStart = (e: React.TouchEvent) => {
116
143
  const touch = e.touches[0]
117
144
  touchStartRef.current = {
@@ -289,133 +316,156 @@ export const SchematicViewer = ({
289
316
  )
290
317
 
291
318
  return (
292
- <div
293
- ref={containerRef}
294
- style={{
295
- position: "relative",
296
- backgroundColor: containerBackgroundColor,
297
- overflow: "hidden",
298
- cursor: showSpiceOverlay
299
- ? "auto"
300
- : isDragging
301
- ? "grabbing"
302
- : clickToInteractEnabled && !isInteractionEnabled
303
- ? "pointer"
304
- : "grab",
305
- minHeight: "300px",
306
- ...containerStyle,
307
- }}
308
- onWheelCapture={(e) => {
309
- if (showSpiceOverlay) {
310
- e.stopPropagation()
311
- }
312
- }}
313
- onMouseDown={(e) => {
314
- if (clickToInteractEnabled && !isInteractionEnabled) {
315
- e.preventDefault()
316
- e.stopPropagation()
317
- return
318
- }
319
- handleMouseDown(e)
320
- }}
321
- onMouseDownCapture={(e) => {
322
- if (clickToInteractEnabled && !isInteractionEnabled) {
323
- e.preventDefault()
324
- e.stopPropagation()
325
- return
326
- }
327
- }}
328
- onTouchStart={(e) => {
329
- if (showSpiceOverlay) return
330
- handleTouchStart(e)
331
- }}
332
- onTouchEnd={(e) => {
333
- if (showSpiceOverlay) return
334
- handleTouchEnd(e)
335
- }}
336
- >
337
- {!isInteractionEnabled && clickToInteractEnabled && (
338
- <div
339
- onClick={(e) => {
319
+ <MouseTracker>
320
+ <div
321
+ ref={containerRef}
322
+ style={{
323
+ position: "relative",
324
+ backgroundColor: containerBackgroundColor,
325
+ overflow: "hidden",
326
+ cursor: showSpiceOverlay
327
+ ? "auto"
328
+ : isDragging
329
+ ? "grabbing"
330
+ : clickToInteractEnabled && !isInteractionEnabled
331
+ ? "pointer"
332
+ : "grab",
333
+ minHeight: "300px",
334
+ ...containerStyle,
335
+ }}
336
+ onWheelCapture={(e) => {
337
+ if (showSpiceOverlay) {
338
+ e.stopPropagation()
339
+ }
340
+ }}
341
+ onMouseDown={(e) => {
342
+ if (clickToInteractEnabled && !isInteractionEnabled) {
340
343
  e.preventDefault()
341
344
  e.stopPropagation()
342
- setIsInteractionEnabled(true)
343
- }}
344
- style={{
345
- position: "absolute",
346
- inset: 0,
347
- cursor: "pointer",
348
- zIndex: zIndexMap.clickToInteractOverlay,
349
- display: "flex",
350
- alignItems: "center",
351
- justifyContent: "center",
352
- pointerEvents: "all",
353
- touchAction: "pan-x pan-y pinch-zoom",
354
- }}
355
- >
345
+ return
346
+ }
347
+ handleMouseDown(e)
348
+ }}
349
+ onMouseDownCapture={(e) => {
350
+ if (clickToInteractEnabled && !isInteractionEnabled) {
351
+ e.preventDefault()
352
+ e.stopPropagation()
353
+ return
354
+ }
355
+ }}
356
+ onTouchStart={(e) => {
357
+ if (showSpiceOverlay) return
358
+ handleTouchStart(e)
359
+ }}
360
+ onTouchEnd={(e) => {
361
+ if (showSpiceOverlay) return
362
+ handleTouchEnd(e)
363
+ }}
364
+ >
365
+ {!isInteractionEnabled && clickToInteractEnabled && (
356
366
  <div
367
+ onClick={(e) => {
368
+ e.preventDefault()
369
+ e.stopPropagation()
370
+ setIsInteractionEnabled(true)
371
+ }}
357
372
  style={{
358
- backgroundColor: "rgba(0, 0, 0, 0.8)",
359
- color: "white",
360
- padding: "12px 24px",
361
- borderRadius: "8px",
362
- fontSize: "16px",
363
- fontFamily: "sans-serif",
364
- pointerEvents: "none",
373
+ position: "absolute",
374
+ inset: 0,
375
+ cursor: "pointer",
376
+ zIndex: zIndexMap.clickToInteractOverlay,
377
+ display: "flex",
378
+ alignItems: "center",
379
+ justifyContent: "center",
380
+ pointerEvents: "all",
381
+ touchAction: "pan-x pan-y pinch-zoom",
365
382
  }}
366
383
  >
367
- {typeof window !== "undefined" &&
368
- ("ontouchstart" in window || navigator.maxTouchPoints > 0)
369
- ? "Touch to Interact"
370
- : "Click to Interact"}
384
+ <div
385
+ style={{
386
+ backgroundColor: "rgba(0, 0, 0, 0.8)",
387
+ color: "white",
388
+ padding: "12px 24px",
389
+ borderRadius: "8px",
390
+ fontSize: "16px",
391
+ fontFamily: "sans-serif",
392
+ pointerEvents: "none",
393
+ }}
394
+ >
395
+ {typeof window !== "undefined" &&
396
+ ("ontouchstart" in window || navigator.maxTouchPoints > 0)
397
+ ? "Touch to Interact"
398
+ : "Click to Interact"}
399
+ </div>
371
400
  </div>
372
- </div>
373
- )}
374
- <ViewMenuIcon
375
- active={showViewMenu}
376
- onClick={() => setShowViewMenu(!showViewMenu)}
377
- />
378
- {editingEnabled && (
379
- <EditIcon
380
- active={editModeEnabled}
381
- onClick={() => setEditModeEnabled(!editModeEnabled)}
382
- />
383
- )}
384
- {editingEnabled && editModeEnabled && (
385
- <GridIcon
386
- active={snapToGrid}
387
- onClick={() => setSnapToGrid(!snapToGrid)}
401
+ )}
402
+ <ViewMenuIcon
403
+ active={showViewMenu}
404
+ onClick={() => setShowViewMenu(!showViewMenu)}
388
405
  />
389
- )}
390
- <ViewMenu
391
- circuitJson={circuitJson}
392
- circuitJsonKey={circuitJsonKey}
393
- isVisible={showViewMenu}
394
- onClose={() => setShowViewMenu(false)}
395
- showGroups={showSchematicGroups}
396
- onToggleGroups={(value) => {
397
- if (!disableGroups) {
398
- setShowSchematicGroups(value)
399
- setStoredBoolean("schematic_viewer_show_groups", value)
400
- }
401
- }}
402
- />
403
- {spiceSimulationEnabled && (
404
- <SpiceSimulationIcon onClick={() => setShowSpiceOverlay(true)} />
405
- )}
406
- {showSpiceOverlay && (
407
- <SpiceSimulationOverlay
408
- spiceString={spiceString}
409
- onClose={() => setShowSpiceOverlay(false)}
410
- plotData={plotData}
411
- nodes={nodes}
412
- isLoading={isSpiceSimLoading}
413
- error={spiceSimError}
414
- simOptions={spiceSimOptions}
415
- onSimOptionsChange={setSpiceSimOptions}
406
+ {editingEnabled && (
407
+ <EditIcon
408
+ active={editModeEnabled}
409
+ onClick={() => setEditModeEnabled(!editModeEnabled)}
410
+ />
411
+ )}
412
+ {editingEnabled && editModeEnabled && (
413
+ <GridIcon
414
+ active={snapToGrid}
415
+ onClick={() => setSnapToGrid(!snapToGrid)}
416
+ />
417
+ )}
418
+ <ViewMenu
419
+ circuitJson={circuitJson}
420
+ circuitJsonKey={circuitJsonKey}
421
+ isVisible={showViewMenu}
422
+ onClose={() => setShowViewMenu(false)}
423
+ showGroups={showSchematicGroups}
424
+ onToggleGroups={(value) => {
425
+ if (!disableGroups) {
426
+ setShowSchematicGroups(value)
427
+ setStoredBoolean("schematic_viewer_show_groups", value)
428
+ }
429
+ }}
416
430
  />
417
- )}
418
- {svgDiv}
419
- </div>
431
+ {spiceSimulationEnabled && (
432
+ <SpiceSimulationIcon onClick={() => setShowSpiceOverlay(true)} />
433
+ )}
434
+ {showSpiceOverlay && (
435
+ <SpiceSimulationOverlay
436
+ spiceString={spiceString}
437
+ onClose={() => setShowSpiceOverlay(false)}
438
+ plotData={plotData}
439
+ nodes={nodes}
440
+ isLoading={isSpiceSimLoading}
441
+ error={spiceSimError}
442
+ simOptions={spiceSimOptions}
443
+ onSimOptionsChange={(options) => {
444
+ setHasSpiceSimRun(true)
445
+ setSpiceSimOptions(options)
446
+ }}
447
+ hasRun={hasSpiceSimRun}
448
+ />
449
+ )}
450
+ {onSchematicComponentClicked &&
451
+ schematicComponentIds.map((componentId) => (
452
+ <SchematicComponentMouseTarget
453
+ key={componentId}
454
+ componentId={componentId}
455
+ svgDivRef={svgDivRef}
456
+ containerRef={containerRef}
457
+ showOutline={true}
458
+ circuitJsonKey={circuitJsonKey}
459
+ onComponentClick={(id, event) => {
460
+ onSchematicComponentClicked?.({
461
+ schematicComponentId: id,
462
+ event,
463
+ })
464
+ }}
465
+ />
466
+ ))}
467
+ {svgDiv}
468
+ </div>
469
+ </MouseTracker>
420
470
  )
421
471
  }
@@ -56,11 +56,13 @@ export const SpicePlot = ({
56
56
  nodes,
57
57
  isLoading,
58
58
  error,
59
+ hasRun,
59
60
  }: {
60
61
  plotData: PlotPoint[]
61
62
  nodes: string[]
62
63
  isLoading: boolean
63
64
  error: string | null
65
+ hasRun: boolean
64
66
  }) => {
65
67
  const yAxisLabel = useMemo(() => {
66
68
  const hasVoltage = nodes.some((n) => n.toLowerCase().startsWith("v("))
@@ -87,6 +89,22 @@ export const SpicePlot = ({
87
89
  )
88
90
  }
89
91
 
92
+ if (!hasRun) {
93
+ return (
94
+ <div
95
+ style={{
96
+ height: "300px",
97
+ width: "100%",
98
+ display: "flex",
99
+ alignItems: "center",
100
+ justifyContent: "center",
101
+ }}
102
+ >
103
+ Click "Run" to start the simulation.
104
+ </div>
105
+ )
106
+ }
107
+
90
108
  if (error) {
91
109
  return (
92
110
  <div
@@ -18,6 +18,7 @@ interface SpiceSimulationOverlayProps {
18
18
  onSimOptionsChange: (
19
19
  options: SpiceSimulationOverlayProps["simOptions"],
20
20
  ) => void
21
+ hasRun: boolean
21
22
  }
22
23
 
23
24
  export const SpiceSimulationOverlay = ({
@@ -29,6 +30,7 @@ export const SpiceSimulationOverlay = ({
29
30
  error,
30
31
  simOptions,
31
32
  onSimOptionsChange,
33
+ hasRun,
32
34
  }: SpiceSimulationOverlayProps) => {
33
35
  const [startTimeDraft, setStartTimeDraft] = useState(
34
36
  String(simOptions.startTime),
@@ -125,6 +127,7 @@ export const SpiceSimulationOverlay = ({
125
127
  nodes={filteredNodes}
126
128
  isLoading={isLoading}
127
129
  error={error}
130
+ hasRun={hasRun}
128
131
  />
129
132
  </div>
130
133
  <div
@@ -209,7 +212,7 @@ export const SpiceSimulationOverlay = ({
209
212
  cursor: "pointer",
210
213
  }}
211
214
  >
212
- Rerun
215
+ {hasRun ? "Rerun" : "Run"}
213
216
  </button>
214
217
  </div>
215
218
  </div>
@@ -0,0 +1,74 @@
1
+ import {
2
+ useContext,
3
+ useEffect,
4
+ useId,
5
+ useMemo,
6
+ useRef,
7
+ useSyncExternalStore,
8
+ } from "react"
9
+ import {
10
+ MouseTrackerContext,
11
+ type BoundingBoxBounds,
12
+ } from "../components/MouseTracker"
13
+
14
+ interface UseMouseEventsOverBoundingBoxOptions {
15
+ bounds: BoundingBoxBounds | null
16
+ onClick?: (event: MouseEvent) => void
17
+ }
18
+
19
+ export const useMouseEventsOverBoundingBox = (
20
+ options: UseMouseEventsOverBoundingBoxOptions,
21
+ ) => {
22
+ const context = useContext(MouseTrackerContext)
23
+
24
+ if (!context) {
25
+ throw new Error(
26
+ "useMouseEventsOverBoundingBox must be used within a MouseTracker",
27
+ )
28
+ }
29
+
30
+ const id = useId()
31
+ const latestOptionsRef = useRef(options)
32
+ latestOptionsRef.current = options
33
+
34
+ const handleClick = useMemo(
35
+ () => (event: MouseEvent) => {
36
+ latestOptionsRef.current.onClick?.(event)
37
+ },
38
+ [],
39
+ )
40
+
41
+ useEffect(() => {
42
+ context.registerBoundingBox(id, {
43
+ bounds: latestOptionsRef.current.bounds,
44
+ onClick: latestOptionsRef.current.onClick ? handleClick : undefined,
45
+ })
46
+ return () => {
47
+ context.unregisterBoundingBox(id)
48
+ }
49
+ }, [context, handleClick, id])
50
+
51
+ useEffect(() => {
52
+ context.updateBoundingBox(id, {
53
+ bounds: latestOptionsRef.current.bounds,
54
+ onClick: latestOptionsRef.current.onClick ? handleClick : undefined,
55
+ })
56
+ }, [
57
+ context,
58
+ handleClick,
59
+ id,
60
+ options.bounds?.minX,
61
+ options.bounds?.maxX,
62
+ options.bounds?.minY,
63
+ options.bounds?.maxY,
64
+ options.onClick,
65
+ ])
66
+
67
+ const hovering = useSyncExternalStore(
68
+ context.subscribe,
69
+ () => context.isHovering(id),
70
+ () => false,
71
+ )
72
+
73
+ return { hovering }
74
+ }
package/lib/index.ts CHANGED
@@ -1 +1,3 @@
1
1
  export { SchematicViewer } from "./components/SchematicViewer"
2
+ export { MouseTracker } from "./components/MouseTracker"
3
+ export { useMouseEventsOverBoundingBox } from "./hooks/useMouseEventsOverBoundingBox"
@@ -6,4 +6,5 @@ export const zIndexMap = {
6
6
  viewMenu: 55,
7
7
  viewMenuBackdrop: 54,
8
8
  clickToInteractOverlay: 100,
9
+ schematicComponentHoverOutline: 47,
9
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/schematic-viewer",
3
- "version": "2.0.41",
3
+ "version": "2.0.43",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "scripts": {