@tscircuit/schematic-viewer 2.0.27 → 2.0.29
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.js +164 -73
- package/dist/index.js.map +1 -1
- package/examples/example10-groups-view-schematic-groups.fixture.tsx +49 -63
- package/examples/example11-automatic-grouping-view-schematic-groups.fixture.tsx +63 -53
- package/lib/components/SchematicViewer.tsx +6 -1
- package/lib/hooks/useSchematicGroupsOverlay.ts +216 -85
- package/package.json +1 -1
|
@@ -2,12 +2,31 @@ import { useEffect } from "react"
|
|
|
2
2
|
import { su } from "@tscircuit/soup-util"
|
|
3
3
|
import type { CircuitJson } from "circuit-json"
|
|
4
4
|
|
|
5
|
+
interface UseSchematicGroupsOverlayOptions {
|
|
6
|
+
svgDivRef: React.RefObject<HTMLDivElement | null>
|
|
7
|
+
circuitJson: CircuitJson
|
|
8
|
+
circuitJsonKey: string
|
|
9
|
+
showGroups: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const GROUP_COLORS = [
|
|
13
|
+
"#8B0000", // Dark Red
|
|
14
|
+
"#2F4F4F", // Dark Slate Gray
|
|
15
|
+
"#191970", // Midnight Blue
|
|
16
|
+
"#006400", // Dark Green
|
|
17
|
+
"#FF4500", // Dark Orange
|
|
18
|
+
"#800080", // Purple
|
|
19
|
+
"#2E8B57", // Sea Green
|
|
20
|
+
"#B8860B", // Dark Goldenrod
|
|
21
|
+
"#C71585", // Medium Violet Red
|
|
22
|
+
"#008B8B", // Dark Cyan
|
|
23
|
+
]
|
|
24
|
+
|
|
5
25
|
export const useSchematicGroupsOverlay = (
|
|
6
|
-
|
|
7
|
-
circuitJson: CircuitJson,
|
|
8
|
-
circuitJsonKey: string,
|
|
9
|
-
showGroups: boolean,
|
|
26
|
+
options: UseSchematicGroupsOverlayOptions,
|
|
10
27
|
) => {
|
|
28
|
+
const { svgDivRef, circuitJson, circuitJsonKey, showGroups } = options
|
|
29
|
+
|
|
11
30
|
useEffect(() => {
|
|
12
31
|
if (
|
|
13
32
|
!svgDivRef.current ||
|
|
@@ -15,7 +34,6 @@ export const useSchematicGroupsOverlay = (
|
|
|
15
34
|
!circuitJson ||
|
|
16
35
|
circuitJson.length === 0
|
|
17
36
|
) {
|
|
18
|
-
// Remove any existing group overlays
|
|
19
37
|
if (svgDivRef.current) {
|
|
20
38
|
const existingOverlays = svgDivRef.current.querySelectorAll(
|
|
21
39
|
".schematic-group-overlay",
|
|
@@ -30,29 +48,69 @@ export const useSchematicGroupsOverlay = (
|
|
|
30
48
|
return
|
|
31
49
|
}
|
|
32
50
|
|
|
33
|
-
// Remove existing overlays first
|
|
34
51
|
const existingOverlays = svg.querySelectorAll(".schematic-group-overlay")
|
|
35
52
|
existingOverlays.forEach((overlay) => overlay.remove())
|
|
36
53
|
|
|
37
54
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
const sourceGroups =
|
|
56
|
+
su(circuitJson)
|
|
57
|
+
.source_group?.list()
|
|
58
|
+
.filter((x) => !!!x.is_subcircuit) || []
|
|
40
59
|
const schematicComponents =
|
|
41
60
|
su(circuitJson).schematic_component?.list() || []
|
|
42
61
|
|
|
62
|
+
const sourceGroupHierarchy = new Map<string, string[]>()
|
|
63
|
+
sourceGroups.forEach((group) => {
|
|
64
|
+
const groupWithParent = group as any
|
|
65
|
+
if (groupWithParent.parent_source_group_id) {
|
|
66
|
+
const children =
|
|
67
|
+
sourceGroupHierarchy.get(groupWithParent.parent_source_group_id) ||
|
|
68
|
+
[]
|
|
69
|
+
children.push(group.source_group_id)
|
|
70
|
+
sourceGroupHierarchy.set(
|
|
71
|
+
groupWithParent.parent_source_group_id,
|
|
72
|
+
children,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const getAllDescendantSourceGroups = (
|
|
78
|
+
sourceGroupId: string,
|
|
79
|
+
): string[] => {
|
|
80
|
+
const descendants: string[] = []
|
|
81
|
+
const children = sourceGroupHierarchy.get(sourceGroupId) || []
|
|
82
|
+
for (const child of children) {
|
|
83
|
+
descendants.push(child)
|
|
84
|
+
descendants.push(...getAllDescendantSourceGroups(child))
|
|
85
|
+
}
|
|
86
|
+
return descendants
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const getGroupDepthLevel = (sourceGroupId: string): number => {
|
|
90
|
+
const groupWithParent = sourceGroups.find(
|
|
91
|
+
(g) => g.source_group_id === sourceGroupId,
|
|
92
|
+
) as any
|
|
93
|
+
if (!groupWithParent?.parent_source_group_id) {
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
96
|
+
return 1 + getGroupDepthLevel(groupWithParent.parent_source_group_id)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const hasMeaningfulGroups =
|
|
100
|
+
sourceGroups.length > 0 &&
|
|
101
|
+
sourceGroups.some((group) => group.name && group.name.trim() !== "")
|
|
102
|
+
|
|
43
103
|
let groupsToRender: Array<{
|
|
44
104
|
id: string
|
|
45
105
|
name: string
|
|
46
106
|
components: any[]
|
|
47
107
|
color: string
|
|
108
|
+
depthLevel: number
|
|
109
|
+
hasChildren: boolean
|
|
110
|
+
sourceGroupId?: string
|
|
48
111
|
}> = []
|
|
49
112
|
|
|
50
|
-
// Check if we have meaningful explicit groups (not just auto-generated default groups)
|
|
51
|
-
const hasMeaningfulGroups = sourceGroups.length > 0 &&
|
|
52
|
-
sourceGroups.some(group => group.name && group.name !== "default" && group.name !== "")
|
|
53
|
-
|
|
54
113
|
if (hasMeaningfulGroups) {
|
|
55
|
-
// Use explicit groups
|
|
56
114
|
const groupMap = new Map<string, any[]>()
|
|
57
115
|
|
|
58
116
|
for (const comp of schematicComponents) {
|
|
@@ -67,97 +125,186 @@ export const useSchematicGroupsOverlay = (
|
|
|
67
125
|
}
|
|
68
126
|
}
|
|
69
127
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const group = sourceGroups.find(
|
|
73
|
-
(g) => g.source_group_id === groupId,
|
|
74
|
-
)
|
|
75
|
-
return {
|
|
76
|
-
id: groupId,
|
|
77
|
-
name: group?.name || `Group ${index + 1}`,
|
|
78
|
-
components,
|
|
79
|
-
color: getGroupColor(index),
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
)
|
|
83
|
-
} else {
|
|
84
|
-
// Create virtual groups by component type
|
|
85
|
-
const componentTypeGroups = new Map<string, any[]>()
|
|
128
|
+
sourceGroups.forEach((group, index) => {
|
|
129
|
+
let groupComponents = groupMap.get(group.source_group_id) || []
|
|
86
130
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
comp.source_component_id,
|
|
131
|
+
const descendantGroups = getAllDescendantSourceGroups(
|
|
132
|
+
group.source_group_id,
|
|
90
133
|
)
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
componentTypeGroups.set(componentType, [])
|
|
95
|
-
}
|
|
96
|
-
componentTypeGroups.get(componentType)!.push(comp)
|
|
134
|
+
for (const descendantGroupId of descendantGroups) {
|
|
135
|
+
const descendantComponents = groupMap.get(descendantGroupId) || []
|
|
136
|
+
groupComponents = [...groupComponents, ...descendantComponents]
|
|
97
137
|
}
|
|
98
|
-
}
|
|
99
138
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
139
|
+
if (groupComponents.length > 0) {
|
|
140
|
+
const depthLevel = getGroupDepthLevel(group.source_group_id)
|
|
141
|
+
const hasChildren =
|
|
142
|
+
getAllDescendantSourceGroups(group.source_group_id).length > 0
|
|
143
|
+
|
|
144
|
+
if (group.name?.startsWith("unnamed_board")) return
|
|
145
|
+
groupsToRender.push({
|
|
146
|
+
id: group.source_group_id,
|
|
147
|
+
name: group.name || `Group ${index + 1}`,
|
|
148
|
+
components: groupComponents,
|
|
149
|
+
color: GROUP_COLORS[index % GROUP_COLORS.length],
|
|
150
|
+
depthLevel,
|
|
151
|
+
hasChildren,
|
|
152
|
+
sourceGroupId: group.source_group_id,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
})
|
|
108
156
|
}
|
|
157
|
+
// else {
|
|
158
|
+
// const componentTypeGroups = new Map<string, any[]>()
|
|
159
|
+
|
|
160
|
+
// for (const comp of schematicComponents) {
|
|
161
|
+
// const sourceComp = su(circuitJson).source_component.get(comp.source_component_id)
|
|
162
|
+
// if (sourceComp) {
|
|
163
|
+
// const componentType = sourceComp.ftype || "other"
|
|
164
|
+
// if (!componentTypeGroups.has(componentType)) {
|
|
165
|
+
// componentTypeGroups.set(componentType, [])
|
|
166
|
+
// }
|
|
167
|
+
// componentTypeGroups.get(componentType)!.push(comp)
|
|
168
|
+
// }
|
|
169
|
+
// }
|
|
170
|
+
// // groupsToRender = Array.from(componentTypeGroups.entries()).map(
|
|
171
|
+
// // ([type, components], index) => ({
|
|
172
|
+
// // id: `type_${type}`,
|
|
173
|
+
// // name: `${type.charAt(0).toUpperCase() + type.slice(1)}s`,
|
|
174
|
+
// // components,
|
|
175
|
+
// // color: GROUP_COLORS[index % GROUP_COLORS.length],
|
|
176
|
+
// // depthLevel: 0,
|
|
177
|
+
// // hasChildren: false,
|
|
178
|
+
// // }),
|
|
179
|
+
// // )
|
|
180
|
+
// }
|
|
181
|
+
|
|
182
|
+
const viewBox = svg.viewBox.baseVal
|
|
183
|
+
const svgRect = svg.getBoundingClientRect()
|
|
184
|
+
const scale =
|
|
185
|
+
Math.min(
|
|
186
|
+
svgRect.width / viewBox.width,
|
|
187
|
+
svgRect.height / viewBox.height,
|
|
188
|
+
) || 1
|
|
109
189
|
|
|
110
|
-
|
|
111
|
-
|
|
190
|
+
groupsToRender.sort((a, b) => a.depthLevel - b.depthLevel)
|
|
191
|
+
|
|
192
|
+
groupsToRender.forEach((group) => {
|
|
112
193
|
if (group.components.length === 0) return
|
|
113
194
|
|
|
114
|
-
// Calculate bounding box for the group
|
|
115
195
|
const groupBounds = calculateGroupBounds(group.components, svg)
|
|
116
196
|
if (!groupBounds) return
|
|
117
197
|
|
|
118
|
-
|
|
198
|
+
const basePadding = Math.max(8, Math.min(25, 15 / Math.max(scale, 0.3)))
|
|
199
|
+
const hierarchyPadding = group.hasChildren ? basePadding * 0.6 : 0
|
|
200
|
+
const totalPadding = basePadding + hierarchyPadding
|
|
201
|
+
|
|
202
|
+
const baseStrokeWidth = Math.max(1, 2 / Math.max(scale, 0.5))
|
|
203
|
+
const strokeWidth =
|
|
204
|
+
group.depthLevel === 0 ? baseStrokeWidth : baseStrokeWidth * 0.7
|
|
205
|
+
|
|
206
|
+
const baseDashSize = Math.max(4, 8 / Math.max(scale, 0.5))
|
|
207
|
+
const dashMultiplier = group.hasChildren ? 1.3 : 1
|
|
208
|
+
const dashSize = baseDashSize * dashMultiplier
|
|
209
|
+
const gapSize = dashSize * 0.5
|
|
210
|
+
|
|
119
211
|
const groupOverlay = document.createElementNS(
|
|
120
212
|
"http://www.w3.org/2000/svg",
|
|
121
213
|
"rect",
|
|
122
214
|
)
|
|
123
215
|
groupOverlay.setAttribute("class", "schematic-group-overlay")
|
|
124
|
-
groupOverlay.setAttribute(
|
|
125
|
-
|
|
216
|
+
groupOverlay.setAttribute(
|
|
217
|
+
"x",
|
|
218
|
+
(groupBounds.minX - totalPadding).toString(),
|
|
219
|
+
)
|
|
220
|
+
groupOverlay.setAttribute(
|
|
221
|
+
"y",
|
|
222
|
+
(groupBounds.minY - totalPadding).toString(),
|
|
223
|
+
)
|
|
126
224
|
groupOverlay.setAttribute(
|
|
127
225
|
"width",
|
|
128
|
-
(groupBounds.maxX - groupBounds.minX +
|
|
226
|
+
(groupBounds.maxX - groupBounds.minX + totalPadding * 2).toString(),
|
|
129
227
|
)
|
|
130
228
|
groupOverlay.setAttribute(
|
|
131
229
|
"height",
|
|
132
|
-
(groupBounds.maxY - groupBounds.minY +
|
|
230
|
+
(groupBounds.maxY - groupBounds.minY + totalPadding * 2).toString(),
|
|
133
231
|
)
|
|
134
232
|
groupOverlay.setAttribute("fill", "none")
|
|
135
233
|
groupOverlay.setAttribute("stroke", group.color)
|
|
136
|
-
groupOverlay.setAttribute("stroke-width",
|
|
137
|
-
groupOverlay.setAttribute("stroke-dasharray",
|
|
234
|
+
groupOverlay.setAttribute("stroke-width", strokeWidth.toString())
|
|
235
|
+
groupOverlay.setAttribute("stroke-dasharray", `${dashSize},${gapSize}`)
|
|
138
236
|
groupOverlay.setAttribute("opacity", "0.8")
|
|
139
237
|
groupOverlay.setAttribute("rx", "4")
|
|
140
238
|
groupOverlay.setAttribute("ry", "4")
|
|
141
239
|
|
|
142
|
-
|
|
240
|
+
const baseFontSize = Math.max(
|
|
241
|
+
6,
|
|
242
|
+
Math.min(20, 14 / Math.max(scale, 0.2)),
|
|
243
|
+
)
|
|
244
|
+
const fontSizeReduction =
|
|
245
|
+
group.depthLevel === 0 || group.depthLevel === 1
|
|
246
|
+
? 0
|
|
247
|
+
: group.depthLevel * 0.2
|
|
248
|
+
const fontSize = baseFontSize * (1 - fontSizeReduction)
|
|
249
|
+
|
|
250
|
+
const labelPadding = Math.max(1, fontSize * 0.2)
|
|
251
|
+
const labelText = group.name
|
|
252
|
+
|
|
253
|
+
const tempText = document.createElementNS(
|
|
254
|
+
"http://www.w3.org/2000/svg",
|
|
255
|
+
"text",
|
|
256
|
+
)
|
|
257
|
+
tempText.setAttribute("font-size", fontSize.toString())
|
|
258
|
+
tempText.setAttribute("font-family", "Arial, sans-serif")
|
|
259
|
+
tempText.textContent = labelText
|
|
260
|
+
svg.appendChild(tempText)
|
|
261
|
+
const textBBox = tempText.getBBox()
|
|
262
|
+
svg.removeChild(tempText)
|
|
263
|
+
|
|
264
|
+
const labelWidth = textBBox.width + labelPadding * 2
|
|
265
|
+
const labelHeight = fontSize + labelPadding * 2
|
|
266
|
+
const labelX = groupBounds.minX - totalPadding
|
|
267
|
+
const labelY = groupBounds.minY - totalPadding - labelHeight
|
|
268
|
+
|
|
269
|
+
const labelBg = document.createElementNS(
|
|
270
|
+
"http://www.w3.org/2000/svg",
|
|
271
|
+
"rect",
|
|
272
|
+
)
|
|
273
|
+
labelBg.setAttribute("class", "schematic-group-overlay")
|
|
274
|
+
labelBg.setAttribute("x", labelX.toString())
|
|
275
|
+
labelBg.setAttribute("y", (labelY - labelHeight).toString())
|
|
276
|
+
labelBg.setAttribute("width", labelWidth.toString())
|
|
277
|
+
labelBg.setAttribute("height", labelHeight.toString())
|
|
278
|
+
labelBg.setAttribute("fill", "transparent")
|
|
279
|
+
labelBg.setAttribute("rx", "3")
|
|
280
|
+
labelBg.setAttribute("ry", "3")
|
|
281
|
+
|
|
143
282
|
const groupLabel = document.createElementNS(
|
|
144
283
|
"http://www.w3.org/2000/svg",
|
|
145
284
|
"text",
|
|
146
285
|
)
|
|
147
286
|
groupLabel.setAttribute("class", "schematic-group-overlay")
|
|
148
|
-
groupLabel.setAttribute("x", (
|
|
149
|
-
groupLabel.setAttribute(
|
|
287
|
+
groupLabel.setAttribute("x", (labelX + labelPadding).toString())
|
|
288
|
+
groupLabel.setAttribute(
|
|
289
|
+
"y",
|
|
290
|
+
(labelY + labelHeight - labelPadding).toString(),
|
|
291
|
+
)
|
|
150
292
|
groupLabel.setAttribute("fill", group.color)
|
|
151
|
-
groupLabel.setAttribute("font-size",
|
|
293
|
+
groupLabel.setAttribute("font-size", fontSize.toString())
|
|
152
294
|
groupLabel.setAttribute("font-family", "Arial, sans-serif")
|
|
153
|
-
groupLabel.setAttribute(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
groupLabel.
|
|
295
|
+
groupLabel.setAttribute(
|
|
296
|
+
"font-weight",
|
|
297
|
+
group.depthLevel === 0 ? "600" : "500",
|
|
298
|
+
)
|
|
299
|
+
groupLabel.setAttribute("stroke", group.color)
|
|
300
|
+
groupLabel.setAttribute(
|
|
301
|
+
"stroke-width",
|
|
302
|
+
Math.max(0.2, fontSize * 0.02).toString(),
|
|
303
|
+
)
|
|
304
|
+
groupLabel.textContent = labelText
|
|
158
305
|
|
|
159
|
-
// Add overlays to the SVG (use appendChild to ensure they're on top)
|
|
160
306
|
svg.appendChild(groupOverlay)
|
|
307
|
+
svg.appendChild(labelBg)
|
|
161
308
|
svg.appendChild(groupLabel)
|
|
162
309
|
})
|
|
163
310
|
} catch (error) {
|
|
@@ -166,20 +313,6 @@ export const useSchematicGroupsOverlay = (
|
|
|
166
313
|
}, [svgDivRef, circuitJsonKey, showGroups])
|
|
167
314
|
}
|
|
168
315
|
|
|
169
|
-
function getGroupColor(index: number): string {
|
|
170
|
-
const colors = [
|
|
171
|
-
"#FF6B6B", // Red
|
|
172
|
-
"#4ECDC4", // Teal
|
|
173
|
-
"#45B7D1", // Blue
|
|
174
|
-
"#96CEB4", // Green
|
|
175
|
-
"#FF8C42", // Orange
|
|
176
|
-
"#DDA0DD", // Plum
|
|
177
|
-
"#98D8C8", // Mint
|
|
178
|
-
"#F7DC6F", // Light Yellow
|
|
179
|
-
]
|
|
180
|
-
return colors[index % colors.length]
|
|
181
|
-
}
|
|
182
|
-
|
|
183
316
|
function calculateGroupBounds(components: any[], svg: SVGElement) {
|
|
184
317
|
let minX = Infinity,
|
|
185
318
|
minY = Infinity,
|
|
@@ -187,18 +320,16 @@ function calculateGroupBounds(components: any[], svg: SVGElement) {
|
|
|
187
320
|
maxY = -Infinity
|
|
188
321
|
|
|
189
322
|
for (const component of components) {
|
|
190
|
-
// Look for the component group element (based on circuit-to-svg documentation)
|
|
191
323
|
let componentElement = svg.querySelector(
|
|
192
324
|
`g[data-schematic-component-id="${component.schematic_component_id}"]`,
|
|
193
325
|
)
|
|
194
|
-
|
|
326
|
+
|
|
195
327
|
if (!componentElement) {
|
|
196
|
-
// Fallback to any element with the data attribute
|
|
197
328
|
componentElement = svg.querySelector(
|
|
198
329
|
`[data-schematic-component-id="${component.schematic_component_id}"]`,
|
|
199
330
|
)
|
|
200
331
|
}
|
|
201
|
-
|
|
332
|
+
|
|
202
333
|
if (componentElement) {
|
|
203
334
|
const bbox = (componentElement as SVGGraphicsElement).getBBox()
|
|
204
335
|
minX = Math.min(minX, bbox.x)
|