@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.
- package/dist/index.d.ts +25 -2
- package/dist/index.js +441 -44
- package/dist/index.js.map +1 -1
- package/examples/example14-schematic-component-click.fixture.tsx +46 -0
- package/lib/components/ControlledSchematicViewer.tsx +6 -0
- package/lib/components/MouseTracker.tsx +227 -0
- package/lib/components/SchematicComponentMouseTarget.tsx +182 -0
- package/lib/components/SchematicViewer.tsx +170 -120
- package/lib/components/SpicePlot.tsx +18 -0
- package/lib/components/SpiceSimulationOverlay.tsx +4 -1
- package/lib/hooks/useMouseEventsOverBoundingBox.ts +74 -0
- package/lib/index.ts +2 -0
- package/lib/utils/z-index-map.ts +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
<
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
package/lib/utils/z-index-map.ts
CHANGED