altium-toolkit 1.0.10 → 1.1.0

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.
Files changed (63) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +4 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +132 -1
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +692 -2
  11. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  12. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  13. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  14. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  15. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  16. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  17. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  18. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  19. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  20. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  21. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  22. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  23. package/package.json +1 -1
  24. package/src/core/altium/AltiumParser.mjs +7 -2
  25. package/src/core/altium/CiArtifactBundleBuilder.mjs +16 -5
  26. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  27. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  28. package/src/core/altium/DraftsmanDigestParser.mjs +246 -7
  29. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  30. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  31. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  32. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  33. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  34. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  35. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  36. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  37. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  38. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  39. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  40. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  41. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  42. package/src/core/altium/PcbModelParser.mjs +182 -18
  43. package/src/core/altium/PcbPickPlacePositionResolver.mjs +3 -0
  44. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  45. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  46. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  47. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  48. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  49. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  50. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  51. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  52. package/src/core/altium/PrjPcbModelParser.mjs +45 -0
  53. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  54. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  55. package/src/core/altium/ProjectDesignBundleBuilder.mjs +61 -2
  56. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  57. package/src/core/altium/SvgModelCrossLinkValidator.mjs +35 -2
  58. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +164 -0
  59. package/src/parser.mjs +15 -0
  60. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  61. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  62. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  63. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -20,6 +20,7 @@ import { PcbModelParser } from './PcbModelParser.mjs'
20
20
  import { PcbLibModelParser } from './PcbLibModelParser.mjs'
21
21
  import { PcbLibStreamExtractor } from './PcbLibStreamExtractor.mjs'
22
22
  import { PrjPcbModelParser } from './PrjPcbModelParser.mjs'
23
+ import { PrjScrModelParser } from './PrjScrModelParser.mjs'
23
24
  import { PcbStreamExtractor } from './PcbStreamExtractor.mjs'
24
25
  import { SchematicMultipartOwnerMatcher } from './SchematicMultipartOwnerMatcher.mjs'
25
26
  import { SchematicSheetStyleResolver } from './SchematicSheetStyleResolver.mjs'
@@ -90,7 +91,7 @@ export class AltiumParser {
90
91
  * Parses a native Altium buffer into the renderer compatibility model.
91
92
  * @param {string} fileName
92
93
  * @param {ArrayBuffer} arrayBuffer
93
- * @returns {{ schema: string, kind: 'schematic' | 'pcb' | 'pcb-library' | 'project' | 'integrated-library' | 'draftsman', fileType: 'SchDoc' | 'PcbDoc' | 'PcbLib' | 'PrjPcb' | 'IntLib' | 'PCBDwf', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], schematic?: Record<string, unknown>, pcb?: Record<string, unknown>, pcbLibrary?: Record<string, unknown>, project?: Record<string, unknown>, integratedLibrary?: Record<string, unknown>, draftsman?: Record<string, unknown>, bom: { designators: string[], quantity: number, pattern: string, source: string, value: string }[] }}
94
+ * @returns {{ schema: string, kind: 'schematic' | 'pcb' | 'pcb-library' | 'project' | 'project-script' | 'integrated-library' | 'draftsman', fileType: 'SchDoc' | 'PcbDoc' | 'PcbLib' | 'PrjPcb' | 'PrjScr' | 'IntLib' | 'PCBDwf', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], schematic?: Record<string, unknown>, pcb?: Record<string, unknown>, pcbLibrary?: Record<string, unknown>, project?: Record<string, unknown>, projectScript?: Record<string, unknown>, integratedLibrary?: Record<string, unknown>, draftsman?: Record<string, unknown>, bom: { designators: string[], quantity: number, pattern: string, source: string, value: string }[] }}
94
95
  */
95
96
  static parseArrayBufferToRendererModel(fileName, arrayBuffer) {
96
97
  const records = AsciiRecordParser.parse(arrayBuffer)
@@ -123,6 +124,9 @@ export class AltiumParser {
123
124
  if (fileType === 'PrjPcb') {
124
125
  return PrjPcbModelParser.parse(fileName, arrayBuffer)
125
126
  }
127
+ if (fileType === 'PrjScr') {
128
+ return PrjScrModelParser.parse(fileName, arrayBuffer)
129
+ }
126
130
  if (fileType === 'IntLib') {
127
131
  return IntLibModelParser.parse(
128
132
  fileName,
@@ -139,7 +143,7 @@ export class AltiumParser {
139
143
  * Chooses the format based on extension and content.
140
144
  * @param {string} fileName
141
145
  * @param {{ fields: Record<string, string | string[]> }[]} records
142
- * @returns {'SchDoc' | 'PcbDoc' | 'PcbLib' | 'PrjPcb' | 'IntLib' | 'PCBDwf'}
146
+ * @returns {'SchDoc' | 'PcbDoc' | 'PcbLib' | 'PrjPcb' | 'PrjScr' | 'IntLib' | 'PCBDwf'}
143
147
  */
144
148
  static #sniffFileType(fileName, records) {
145
149
  const normalized = String(fileName || '').toLowerCase()
@@ -147,6 +151,7 @@ export class AltiumParser {
147
151
  if (normalized.endsWith('.pcbdoc')) return 'PcbDoc'
148
152
  if (normalized.endsWith('.pcblib')) return 'PcbLib'
149
153
  if (normalized.endsWith('.prjpcb')) return 'PrjPcb'
154
+ if (normalized.endsWith('.prjscr')) return 'PrjScr'
150
155
  if (normalized.endsWith('.intlib')) return 'IntLib'
151
156
  if (normalized.endsWith('.pcbdwf')) return 'PCBDwf'
152
157
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { PcbSvgRenderer } from '../../ui/PcbSvgRenderer.mjs'
6
6
  import { SchematicSvgRenderer } from '../../ui/SchematicSvgRenderer.mjs'
7
+ import { ContractGateReportBuilder } from './ContractGateReportBuilder.mjs'
7
8
  import { PcbStatisticsBuilder } from './PcbStatisticsBuilder.mjs'
8
9
  import { ProjectDesignBundleBuilder } from './ProjectDesignBundleBuilder.mjs'
9
10
  import { ProjectDocumentGraphBuilder } from './ProjectDocumentGraphBuilder.mjs'
@@ -64,6 +65,17 @@ export class CiArtifactBundleBuilder {
64
65
  )
65
66
  const netlistJson =
66
67
  ProjectNetlistExporter.buildNetlistJson(activeBundle)
68
+ const netlist = {
69
+ json: netlistJson,
70
+ wirelist: ProjectNetlistExporter.buildWirelist(activeBundle)
71
+ }
72
+ const contractGate = ContractGateReportBuilder.build({
73
+ documentModels,
74
+ netlist,
75
+ schematicSvgs,
76
+ pcbLayerSvgs,
77
+ diagnostics
78
+ })
67
79
  const documentGraph =
68
80
  designBundle.project?.documentGraph ||
69
81
  ProjectDocumentGraphBuilder.build(
@@ -83,16 +95,14 @@ export class CiArtifactBundleBuilder {
83
95
  bomRowCount: (activeBundle.bom || designBundle.bom || [])
84
96
  .length,
85
97
  pnpCount: (activeBundle.pnp?.entries || []).length,
86
- diagnosticCount: diagnostics.length
98
+ diagnosticCount: diagnostics.length,
99
+ contractGateStatus: contractGate.status
87
100
  },
88
101
  units: designBundle.units || CiArtifactBundleBuilder.#UNITS,
89
102
  designBundle,
90
103
  documentGraph,
91
104
  normalizedModels: documentModels,
92
- netlist: {
93
- json: netlistJson,
94
- wirelist: ProjectNetlistExporter.buildWirelist(activeBundle)
95
- },
105
+ netlist,
96
106
  bom: {
97
107
  rows: activeBundle.bom || designBundle.bom || []
98
108
  },
@@ -100,6 +110,7 @@ export class CiArtifactBundleBuilder {
100
110
  schematicSvgs,
101
111
  pcbLayerSvgs,
102
112
  statistics,
113
+ contractGate,
103
114
  diagnostics
104
115
  }
105
116
  }
@@ -0,0 +1,351 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SvgModelCrossLinkValidator } from './SvgModelCrossLinkValidator.mjs'
6
+
7
+ /**
8
+ * Builds deterministic contract-gate reports for CI artifact bundles.
9
+ */
10
+ export class ContractGateReportBuilder {
11
+ static SCHEMA = 'altium-toolkit.contract-gate.a1'
12
+
13
+ /**
14
+ * Builds a contract-gate report over normalized and rendered artifacts.
15
+ * @param {{ documentModels?: object[], netlist?: { json?: object, wirelist?: string }, schematicSvgs?: object[], pcbLayerSvgs?: object[], diagnostics?: object[] }} options Gate input artifacts.
16
+ * @returns {object}
17
+ */
18
+ static build(options = {}) {
19
+ const documentModels = options.documentModels || []
20
+ const svgLinkReports = ContractGateReportBuilder.#svgLinkReports(
21
+ documentModels,
22
+ options.schematicSvgs || [],
23
+ options.pcbLayerSvgs || []
24
+ )
25
+ const gates = [
26
+ ContractGateReportBuilder.#normalizedModelGate(documentModels),
27
+ ContractGateReportBuilder.#netlistJsonGate(options.netlist?.json),
28
+ ContractGateReportBuilder.#wirelistGate(options.netlist?.wirelist),
29
+ ContractGateReportBuilder.#svgLinkageGate(svgLinkReports),
30
+ ContractGateReportBuilder.#diagnosticsGate(
31
+ options.diagnostics || []
32
+ )
33
+ ]
34
+ const failingGateCount = gates.filter(
35
+ (gate) => gate.status === 'fail'
36
+ ).length
37
+
38
+ return {
39
+ schema: ContractGateReportBuilder.SCHEMA,
40
+ status: failingGateCount > 0 ? 'fail' : 'pass',
41
+ summary: {
42
+ gateCount: gates.length,
43
+ failingGateCount,
44
+ documentCount: documentModels.length,
45
+ svgLinkReportCount: svgLinkReports.length,
46
+ diagnosticCount: (options.diagnostics || []).length
47
+ },
48
+ gates,
49
+ svgLinkReports
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Builds SVG link-validation reports for all rendered document outputs.
55
+ * @param {object[]} documentModels Normalized document models.
56
+ * @param {object[]} schematicSvgs Schematic SVG entries.
57
+ * @param {object[]} pcbLayerSvgs PCB layer SVG entries.
58
+ * @returns {object[]}
59
+ */
60
+ static #svgLinkReports(documentModels, schematicSvgs, pcbLayerSvgs) {
61
+ return [
62
+ ...ContractGateReportBuilder.#schematicSvgReports(
63
+ documentModels,
64
+ schematicSvgs
65
+ ),
66
+ ...ContractGateReportBuilder.#pcbLayerSvgReports(
67
+ documentModels,
68
+ pcbLayerSvgs
69
+ )
70
+ ]
71
+ }
72
+
73
+ /**
74
+ * Builds schematic SVG link reports.
75
+ * @param {object[]} documentModels Normalized document models.
76
+ * @param {object[]} schematicSvgs Schematic SVG entries.
77
+ * @returns {object[]}
78
+ */
79
+ static #schematicSvgReports(documentModels, schematicSvgs) {
80
+ return (schematicSvgs || []).map((entry) => {
81
+ const model = ContractGateReportBuilder.#modelForFileName(
82
+ documentModels,
83
+ entry.fileName
84
+ )
85
+ return ContractGateReportBuilder.#linkReport(
86
+ entry.fileName,
87
+ model,
88
+ [entry.svg || '']
89
+ )
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Builds PCB layer SVG link reports as aggregate layer-view sets.
95
+ * @param {object[]} documentModels Normalized document models.
96
+ * @param {object[]} pcbLayerSvgs PCB layer SVG entries.
97
+ * @returns {object[]}
98
+ */
99
+ static #pcbLayerSvgReports(documentModels, pcbLayerSvgs) {
100
+ return (pcbLayerSvgs || []).map((entry) => {
101
+ const model = ContractGateReportBuilder.#modelForFileName(
102
+ documentModels,
103
+ entry.fileName
104
+ )
105
+ return ContractGateReportBuilder.#linkReport(
106
+ entry.fileName,
107
+ model,
108
+ (entry.layers || []).map((layer) => layer.svg || '')
109
+ )
110
+ })
111
+ }
112
+
113
+ /**
114
+ * Builds one SVG link report.
115
+ * @param {string} fileName Source file name.
116
+ * @param {object | undefined} model Normalized model.
117
+ * @param {string[]} svgMarkups SVG markup strings.
118
+ * @returns {object}
119
+ */
120
+ static #linkReport(fileName, model, svgMarkups) {
121
+ if (!model) {
122
+ return {
123
+ fileName,
124
+ documentKind: 'unknown',
125
+ status: 'fail',
126
+ summary: {
127
+ missingElementCount: 0,
128
+ orphanElementCount: 0,
129
+ unresolvedReferenceCount: 1
130
+ },
131
+ missingElements: [],
132
+ orphanElements: [],
133
+ unresolvedReferences: [
134
+ {
135
+ referenceKind: 'document',
136
+ value: fileName
137
+ }
138
+ ]
139
+ }
140
+ }
141
+
142
+ const report = ContractGateReportBuilder.#normalizeLayerSetReport(
143
+ SvgModelCrossLinkValidator.validateSet(model, svgMarkups),
144
+ model,
145
+ svgMarkups
146
+ )
147
+ const status =
148
+ report.summary.missingElementCount > 0 ||
149
+ report.summary.orphanElementCount > 0 ||
150
+ report.summary.unresolvedReferenceCount > 0
151
+ ? 'fail'
152
+ : 'pass'
153
+
154
+ return {
155
+ fileName,
156
+ documentKind: report.documentKind,
157
+ status,
158
+ summary: report.summary,
159
+ missingElements: report.missingElements,
160
+ orphanElements: report.orphanElements,
161
+ unresolvedReferences: report.unresolvedReferences
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Removes composite-only component omissions from PCB layer-set reports.
167
+ * @param {object} report Raw SVG link report.
168
+ * @param {object} model Normalized model.
169
+ * @param {string[]} svgMarkups SVG markup strings.
170
+ * @returns {object}
171
+ */
172
+ static #normalizeLayerSetReport(report, model, svgMarkups) {
173
+ if (
174
+ model?.kind !== 'pcb' ||
175
+ !ContractGateReportBuilder.#isLayerSvgSet(svgMarkups)
176
+ ) {
177
+ return report
178
+ }
179
+
180
+ const missingElements = (report.missingElements || []).filter(
181
+ (element) => element.collectionKey !== 'components'
182
+ )
183
+
184
+ return {
185
+ ...report,
186
+ summary: {
187
+ ...report.summary,
188
+ linkedElementCount:
189
+ Number(report.summary.expectedElementCount || 0) -
190
+ missingElements.length,
191
+ missingElementCount: missingElements.length
192
+ },
193
+ missingElements
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Returns true when all supplied SVGs are layer-view exports.
199
+ * @param {string[]} svgMarkups SVG markup strings.
200
+ * @returns {boolean}
201
+ */
202
+ static #isLayerSvgSet(svgMarkups) {
203
+ return (
204
+ (svgMarkups || []).length > 0 &&
205
+ (svgMarkups || []).every((svgMarkup) =>
206
+ String(svgMarkup || '').includes('data-view-kind="layer"')
207
+ )
208
+ )
209
+ }
210
+
211
+ /**
212
+ * Builds the normalized-model gate.
213
+ * @param {object[]} documentModels Normalized document models.
214
+ * @returns {object}
215
+ */
216
+ static #normalizedModelGate(documentModels) {
217
+ const failures = (documentModels || []).filter(
218
+ (model) => !model?.schema || !model?.kind
219
+ )
220
+
221
+ return ContractGateReportBuilder.#gate({
222
+ key: 'normalized-models',
223
+ status: failures.length ? 'fail' : 'pass',
224
+ checkedCount: documentModels.length,
225
+ failureCount: failures.length
226
+ })
227
+ }
228
+
229
+ /**
230
+ * Builds the netlist JSON gate.
231
+ * @param {object | undefined} netlistJson Netlist JSON payload.
232
+ * @returns {object}
233
+ */
234
+ static #netlistJsonGate(netlistJson) {
235
+ const pass =
236
+ Boolean(netlistJson?.schema) && Array.isArray(netlistJson?.nets)
237
+
238
+ return ContractGateReportBuilder.#gate({
239
+ key: 'netlist-json',
240
+ status: pass ? 'pass' : 'fail',
241
+ checkedCount: pass ? 1 : 0,
242
+ failureCount: pass ? 0 : 1
243
+ })
244
+ }
245
+
246
+ /**
247
+ * Builds the wirelist gate.
248
+ * @param {string | undefined} wirelist Wirelist text.
249
+ * @returns {object}
250
+ */
251
+ static #wirelistGate(wirelist) {
252
+ const pass = typeof wirelist === 'string'
253
+
254
+ return ContractGateReportBuilder.#gate({
255
+ key: 'wirelist',
256
+ status: pass ? 'pass' : 'fail',
257
+ checkedCount: pass ? 1 : 0,
258
+ failureCount: pass ? 0 : 1
259
+ })
260
+ }
261
+
262
+ /**
263
+ * Builds the SVG linkage gate.
264
+ * @param {object[]} svgLinkReports SVG link reports.
265
+ * @returns {object}
266
+ */
267
+ static #svgLinkageGate(svgLinkReports) {
268
+ const failingReports = (svgLinkReports || []).filter(
269
+ (report) => report.status === 'fail'
270
+ )
271
+
272
+ return ContractGateReportBuilder.#gate({
273
+ key: 'svg-linkage',
274
+ status: failingReports.length ? 'fail' : 'pass',
275
+ checkedCount: svgLinkReports.length,
276
+ failureCount: failingReports.length,
277
+ missingElementCount: ContractGateReportBuilder.#sumSummary(
278
+ svgLinkReports,
279
+ 'missingElementCount'
280
+ ),
281
+ orphanElementCount: ContractGateReportBuilder.#sumSummary(
282
+ svgLinkReports,
283
+ 'orphanElementCount'
284
+ ),
285
+ unresolvedReferenceCount: ContractGateReportBuilder.#sumSummary(
286
+ svgLinkReports,
287
+ 'unresolvedReferenceCount'
288
+ )
289
+ })
290
+ }
291
+
292
+ /**
293
+ * Builds the diagnostics gate.
294
+ * @param {object[]} diagnostics Diagnostic rows.
295
+ * @returns {object}
296
+ */
297
+ static #diagnosticsGate(diagnostics) {
298
+ const errorCount = (diagnostics || []).filter(
299
+ (diagnostic) => diagnostic.severity === 'error'
300
+ ).length
301
+
302
+ return ContractGateReportBuilder.#gate({
303
+ key: 'diagnostics',
304
+ status: errorCount ? 'fail' : 'pass',
305
+ checkedCount: diagnostics.length,
306
+ failureCount: errorCount,
307
+ warningCount: diagnostics.filter(
308
+ (diagnostic) => diagnostic.severity === 'warning'
309
+ ).length,
310
+ errorCount
311
+ })
312
+ }
313
+
314
+ /**
315
+ * Finds a normalized model by file name.
316
+ * @param {object[]} documentModels Normalized document models.
317
+ * @param {string} fileName Source file name.
318
+ * @returns {object | undefined}
319
+ */
320
+ static #modelForFileName(documentModels, fileName) {
321
+ return (documentModels || []).find(
322
+ (model) => model?.fileName === fileName
323
+ )
324
+ }
325
+
326
+ /**
327
+ * Sums one SVG link report summary field.
328
+ * @param {object[]} reports SVG link reports.
329
+ * @param {string} field Summary field.
330
+ * @returns {number}
331
+ */
332
+ static #sumSummary(reports, field) {
333
+ return (reports || []).reduce(
334
+ (total, report) => total + Number(report.summary?.[field] || 0),
335
+ 0
336
+ )
337
+ }
338
+
339
+ /**
340
+ * Removes undefined gate fields.
341
+ * @param {object} gate Gate row.
342
+ * @returns {object}
343
+ */
344
+ static #gate(gate) {
345
+ return Object.fromEntries(
346
+ Object.entries(gate || {}).filter(
347
+ ([, value]) => value !== undefined
348
+ )
349
+ )
350
+ }
351
+ }