compasso 0.1.0 → 0.3.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.
- package/README.md +126 -8
- package/dist/chunk-F47C6ZEB.js +1041 -0
- package/dist/chunk-F47C6ZEB.js.map +1 -0
- package/dist/chunk-JP4N42AY.js +497 -0
- package/dist/chunk-JP4N42AY.js.map +1 -0
- package/dist/chunk-LRHHUJFZ.js +703 -0
- package/dist/chunk-LRHHUJFZ.js.map +1 -0
- package/dist/{chunk-E456YKAJ.js → chunk-O3BT2O42.js} +69 -10
- package/dist/chunk-O3BT2O42.js.map +1 -0
- package/dist/{chunk-L5CYESBI.js → chunk-Q6DVTCXD.js} +9 -24
- package/dist/chunk-Q6DVTCXD.js.map +1 -0
- package/dist/{chunk-5RRRE2GF.js → chunk-RWPGGWO5.js} +9 -28
- package/dist/chunk-RWPGGWO5.js.map +1 -0
- package/dist/chunk-ZBDABVIO.js +252 -0
- package/dist/chunk-ZBDABVIO.js.map +1 -0
- package/dist/core/index.cjs +74 -7
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +33 -29
- package/dist/core/index.d.ts +33 -29
- package/dist/core/index.js +1 -1
- package/dist/ecomap/index.cjs +43 -28
- package/dist/ecomap/index.cjs.map +1 -1
- package/dist/ecomap/index.js +2 -2
- package/dist/fault-tree/index.cjs +782 -0
- package/dist/fault-tree/index.cjs.map +1 -0
- package/dist/fault-tree/index.d.cts +148 -0
- package/dist/fault-tree/index.d.ts +148 -0
- package/dist/fault-tree/index.js +4 -0
- package/dist/fault-tree/index.js.map +1 -0
- package/dist/fishbone/index.cjs +314 -0
- package/dist/fishbone/index.cjs.map +1 -0
- package/dist/fishbone/index.d.cts +91 -0
- package/dist/fishbone/index.d.ts +91 -0
- package/dist/fishbone/index.js +4 -0
- package/dist/fishbone/index.js.map +1 -0
- package/dist/genogram/index.cjs +47 -32
- package/dist/genogram/index.cjs.map +1 -1
- package/dist/genogram/index.d.cts +7 -4
- package/dist/genogram/index.d.ts +7 -4
- package/dist/genogram/index.js +2 -2
- package/dist/index.cjs +2622 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -2
- package/dist/index.d.ts +12 -2
- package/dist/index.js +7 -3
- package/dist/kinship-DqEklrDN.d.ts +51 -0
- package/dist/kinship-Dy_ijjJV.d.cts +51 -0
- package/dist/labels-CBQ_3Ec9.d.cts +123 -0
- package/dist/labels-CYbM5XV7.d.cts +83 -0
- package/dist/labels-CYbM5XV7.d.ts +83 -0
- package/dist/labels-DNqRkWuI.d.ts +123 -0
- package/dist/labels-iZjijjtK.d.cts +64 -0
- package/dist/labels-iZjijjtK.d.ts +64 -0
- package/dist/locales/pt-br.cjs +94 -0
- package/dist/locales/pt-br.cjs.map +1 -1
- package/dist/locales/pt-br.d.cts +14 -2
- package/dist/locales/pt-br.d.ts +14 -2
- package/dist/locales/pt-br.js +88 -1
- package/dist/locales/pt-br.js.map +1 -1
- package/dist/pedigree/index.cjs +1151 -0
- package/dist/pedigree/index.cjs.map +1 -0
- package/dist/pedigree/index.d.cts +155 -0
- package/dist/pedigree/index.d.ts +155 -0
- package/dist/pedigree/index.js +4 -0
- package/dist/pedigree/index.js.map +1 -0
- package/dist/phylo/index.cjs +553 -0
- package/dist/phylo/index.cjs.map +1 -0
- package/dist/phylo/index.d.cts +158 -0
- package/dist/phylo/index.d.ts +158 -0
- package/dist/phylo/index.js +4 -0
- package/dist/phylo/index.js.map +1 -0
- package/dist/text-DuO_PwYw.d.cts +45 -0
- package/dist/text-DuO_PwYw.d.ts +45 -0
- package/dist/types-BnMG7TCd.d.cts +66 -0
- package/dist/types-BnMG7TCd.d.ts +66 -0
- package/dist/xml-DDae1eUr.d.cts +4 -0
- package/dist/xml-DDae1eUr.d.ts +4 -0
- package/package.json +100 -26
- package/dist/chunk-5RRRE2GF.js.map +0 -1
- package/dist/chunk-E456YKAJ.js.map +0 -1
- package/dist/chunk-L5CYESBI.js.map +0 -1
- package/dist/kinship-BARO5-qz.d.cts +0 -115
- package/dist/kinship-Bkf87Jhu.d.ts +0 -115
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,35 @@ function xmlEscape(text) {
|
|
|
5
5
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// src/core/roman.ts
|
|
9
|
+
var ROMAN_TABLE = [
|
|
10
|
+
[1e3, "M"],
|
|
11
|
+
[900, "CM"],
|
|
12
|
+
[500, "D"],
|
|
13
|
+
[400, "CD"],
|
|
14
|
+
[100, "C"],
|
|
15
|
+
[90, "XC"],
|
|
16
|
+
[50, "L"],
|
|
17
|
+
[40, "XL"],
|
|
18
|
+
[10, "X"],
|
|
19
|
+
[9, "IX"],
|
|
20
|
+
[5, "V"],
|
|
21
|
+
[4, "IV"],
|
|
22
|
+
[1, "I"]
|
|
23
|
+
];
|
|
24
|
+
function romanNumeral(n) {
|
|
25
|
+
if (!Number.isInteger(n) || n < 1 || n > 3999) return String(n);
|
|
26
|
+
let remaining = n;
|
|
27
|
+
let out = "";
|
|
28
|
+
for (const [value, symbol] of ROMAN_TABLE) {
|
|
29
|
+
while (remaining >= value) {
|
|
30
|
+
out += symbol;
|
|
31
|
+
remaining -= value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
8
37
|
// src/core/geometry.ts
|
|
9
38
|
function pathData(points) {
|
|
10
39
|
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
|
|
@@ -15,19 +44,27 @@ var CHAR_W = 0.6;
|
|
|
15
44
|
function estimateTextWidth(text, fontPx) {
|
|
16
45
|
return text.length * fontPx * CHAR_W;
|
|
17
46
|
}
|
|
18
|
-
function wrapLabel(label, perLine) {
|
|
47
|
+
function wrapLabel(label, perLine, maxLines = 2) {
|
|
19
48
|
if (label.length <= perLine) return [label];
|
|
20
49
|
const cap = (s) => s.length > perLine ? s.slice(0, Math.max(1, perLine - 1)) + "\u2026" : s;
|
|
21
|
-
|
|
22
|
-
let line2 = "";
|
|
50
|
+
const lines = [""];
|
|
23
51
|
for (const word of label.split(/\s+/)) {
|
|
24
|
-
|
|
25
|
-
|
|
52
|
+
const last = lines.length - 1;
|
|
53
|
+
const current = lines[last];
|
|
54
|
+
if (current === "" || (current + " " + word).length <= perLine) {
|
|
55
|
+
lines[last] = current === "" ? word : `${current} ${word}`;
|
|
56
|
+
} else if (lines.length < maxLines) {
|
|
57
|
+
lines.push(word);
|
|
26
58
|
} else {
|
|
27
|
-
|
|
59
|
+
lines[last] = `${current} ${word}`;
|
|
28
60
|
}
|
|
29
61
|
}
|
|
30
|
-
|
|
62
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
63
|
+
return lines.map(cap);
|
|
64
|
+
}
|
|
65
|
+
function wrapLabelBalanced(label, maxLines) {
|
|
66
|
+
const perLine = Math.min(26, Math.max(14, Math.ceil(label.length / 2) + 2));
|
|
67
|
+
return wrapLabel(label, perLine, maxLines);
|
|
31
68
|
}
|
|
32
69
|
function clampLabel(label, maxChars) {
|
|
33
70
|
if (maxChars === void 0 || label.length <= maxChars) return label;
|
|
@@ -35,6 +72,28 @@ function clampLabel(label, maxChars) {
|
|
|
35
72
|
}
|
|
36
73
|
var FONT_FAMILY = "Helvetica, Arial, sans-serif";
|
|
37
74
|
|
|
75
|
+
// src/core/legend.ts
|
|
76
|
+
var LEGEND_ROW_H = 18;
|
|
77
|
+
var LEGEND_PAD = 16;
|
|
78
|
+
var LEGEND_SWATCH_W = 22;
|
|
79
|
+
var LEGEND_GAP = 14;
|
|
80
|
+
var LEGEND_FONT = 11;
|
|
81
|
+
var LEGEND_TEXT_FILL = "#52525b";
|
|
82
|
+
function legendBlock(entries, startY) {
|
|
83
|
+
if (entries.length === 0) return { svg: "", width: 0, height: startY };
|
|
84
|
+
const rows = entries.map((entry, i) => {
|
|
85
|
+
const rowCenterY = startY + i * LEGEND_ROW_H + LEGEND_ROW_H / 2;
|
|
86
|
+
const textX = LEGEND_PAD + LEGEND_SWATCH_W + LEGEND_GAP;
|
|
87
|
+
return entry.swatch(LEGEND_PAD, rowCenterY) + `<text x="${textX}" y="${rowCenterY + LEGEND_FONT * 0.32}" font-family="${FONT_FAMILY}" font-size="${LEGEND_FONT}" fill="${LEGEND_TEXT_FILL}">${xmlEscape(entry.label)}</text>`;
|
|
88
|
+
});
|
|
89
|
+
const widestLabel = entries.reduce((m, e) => Math.max(m, estimateTextWidth(e.label, LEGEND_FONT)), 0);
|
|
90
|
+
return {
|
|
91
|
+
svg: `<g data-compasso-legend="true">${rows.join("")}</g>`,
|
|
92
|
+
width: LEGEND_PAD + LEGEND_SWATCH_W + LEGEND_GAP + widestLabel + LEGEND_PAD,
|
|
93
|
+
height: startY + entries.length * LEGEND_ROW_H + LEGEND_PAD / 2
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
38
97
|
// src/core/stroke.ts
|
|
39
98
|
var EDGE_STROKE = {
|
|
40
99
|
plain: { width: 1.5, dash: null, opacity: 0.6 },
|
|
@@ -206,14 +265,6 @@ function shapeForSex(sex) {
|
|
|
206
265
|
if (sex === "female") return "circle";
|
|
207
266
|
return "diamond";
|
|
208
267
|
}
|
|
209
|
-
function wrapNodeLabel(displayLabel) {
|
|
210
|
-
const perLine = Math.min(26, Math.max(14, Math.ceil(displayLabel.length / 2) + 2));
|
|
211
|
-
return wrapLabel(displayLabel, perLine);
|
|
212
|
-
}
|
|
213
|
-
function clampLabel2(label, maxChars) {
|
|
214
|
-
if (maxChars === void 0 || label.length <= maxChars) return label;
|
|
215
|
-
return label.slice(0, Math.max(1, maxChars - 1)) + "\u2026";
|
|
216
|
-
}
|
|
217
268
|
var STUB_OFFSETS = [9, -9, 18, -18, 26, -26];
|
|
218
269
|
var ARRIVAL_HALF = NODE_SIZE / 2 - 8;
|
|
219
270
|
function stubOffset(slot) {
|
|
@@ -437,7 +488,7 @@ function computeGenogramLayout(people, unions, parentLinks, relationships, opts
|
|
|
437
488
|
}
|
|
438
489
|
const measured = /* @__PURE__ */ new Map();
|
|
439
490
|
for (const p of people) {
|
|
440
|
-
const lines =
|
|
491
|
+
const lines = wrapLabelBalanced(clampLabel(p.label, opts.maxLabelChars));
|
|
441
492
|
const contentW = Math.max(NODE_SIZE, lines.reduce((m, l) => Math.max(m, estimateTextWidth(l, LABEL_FONT)), 0));
|
|
442
493
|
measured.set(p.id, { person: p, lines, contentW });
|
|
443
494
|
}
|
|
@@ -1001,11 +1052,6 @@ var STRUCT_WIDTH = 1.5;
|
|
|
1001
1052
|
var STRUCT_OPACITY = 0.75;
|
|
1002
1053
|
var DOTTED_DASH = [2, 3];
|
|
1003
1054
|
var DOTTED_OPACITY = 0.55;
|
|
1004
|
-
var LEGEND_ROW_H = 18;
|
|
1005
|
-
var LEGEND_PAD = 16;
|
|
1006
|
-
var LEGEND_SWATCH_W = 22;
|
|
1007
|
-
var LEGEND_GAP = 14;
|
|
1008
|
-
var LEGEND_FONT = 11;
|
|
1009
1055
|
function glyphSvg(shape, cx, cy, half) {
|
|
1010
1056
|
const stroke = `fill="transparent" stroke="${GLYPH_STROKE}" stroke-width="2"`;
|
|
1011
1057
|
if (shape === "square") {
|
|
@@ -1141,17 +1187,11 @@ function genogramLayoutSvg(layout, opts = {}) {
|
|
|
1141
1187
|
label: labels.isolated
|
|
1142
1188
|
});
|
|
1143
1189
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
return entry.swatch(LEGEND_PAD, rowCenterY) + `<text x="${textX}" y="${rowCenterY + LEGEND_FONT * 0.32}" font-family="${FONT_FAMILY}" font-size="${LEGEND_FONT}" fill="${GLYPH_STROKE}">${xmlEscape(entry.label)}</text>`;
|
|
1150
|
-
});
|
|
1151
|
-
parts.push(`<g data-compasso-legend="true">${rows.join("")}</g>`);
|
|
1152
|
-
const widestLabel = entries.reduce((m, e) => Math.max(m, estimateTextWidth(e.label, LEGEND_FONT)), 0);
|
|
1153
|
-
width = Math.max(width, LEGEND_PAD + LEGEND_SWATCH_W + LEGEND_GAP + widestLabel + LEGEND_PAD);
|
|
1154
|
-
height = startY + entries.length * LEGEND_ROW_H + LEGEND_PAD / 2;
|
|
1190
|
+
const block = legendBlock(entries, layout.height);
|
|
1191
|
+
if (block.svg !== "") {
|
|
1192
|
+
parts.push(block.svg);
|
|
1193
|
+
width = Math.max(width, block.width);
|
|
1194
|
+
height = block.height;
|
|
1155
1195
|
}
|
|
1156
1196
|
}
|
|
1157
1197
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="${xmlEscape(labels.ariaLabel)}">` + parts.join("") + `</svg>`;
|
|
@@ -1228,16 +1268,7 @@ var SINGLE_RING_MAX = 8;
|
|
|
1228
1268
|
var NODE_STROKE = "#52525b";
|
|
1229
1269
|
var LABEL_FILL = "#3f3f46";
|
|
1230
1270
|
var EDGE_INK2 = "#71717a";
|
|
1231
|
-
var LEGEND_ROW_H2 = 18;
|
|
1232
|
-
var LEGEND_PAD2 = 16;
|
|
1233
|
-
var LEGEND_SWATCH_W2 = 22;
|
|
1234
|
-
var LEGEND_GAP2 = 14;
|
|
1235
|
-
var LEGEND_FONT2 = 11;
|
|
1236
1271
|
var round = (n) => Math.round(n * 100) / 100;
|
|
1237
|
-
function wrapTieLabel(displayLabel) {
|
|
1238
|
-
const perLine = Math.min(26, Math.max(14, Math.ceil(displayLabel.length / 2) + 2));
|
|
1239
|
-
return wrapLabel(displayLabel, perLine);
|
|
1240
|
-
}
|
|
1241
1272
|
function arrowHead(tipX, tipY, ux, uy, opacity) {
|
|
1242
1273
|
const LEN = 9;
|
|
1243
1274
|
const HALF_W = 4.5;
|
|
@@ -1256,7 +1287,7 @@ function ecomapSvg(input, opts = {}) {
|
|
|
1256
1287
|
const fontSize = opts.fontSize ?? 12;
|
|
1257
1288
|
const labels = opts.labels ?? ECOMAP_LABELS_EN;
|
|
1258
1289
|
const sats = [...input.ties].sort((a, b) => a.id - b.id).map((tie) => {
|
|
1259
|
-
const lines =
|
|
1290
|
+
const lines = wrapLabelBalanced(clampLabel(tie.label, opts.maxLabelChars));
|
|
1260
1291
|
const w2 = Math.max(...lines.map((l) => estimateTextWidth(l, fontSize))) + NODE_PAD_X * 2;
|
|
1261
1292
|
const h2 = lines.length * LINE_H + NODE_PAD_Y * 2;
|
|
1262
1293
|
return {
|
|
@@ -1270,7 +1301,7 @@ function ecomapSvg(input, opts = {}) {
|
|
|
1270
1301
|
style: qualityLineStyle(tie.quality, opts.qualityLexicon)
|
|
1271
1302
|
};
|
|
1272
1303
|
});
|
|
1273
|
-
const centerLines =
|
|
1304
|
+
const centerLines = wrapLabelBalanced(input.centerLabel);
|
|
1274
1305
|
const centerR = Math.max(
|
|
1275
1306
|
CENTER_MIN_R,
|
|
1276
1307
|
Math.max(...centerLines.map((l) => estimateTextWidth(l, fontSize))) / 2 + 12,
|
|
@@ -1368,7 +1399,7 @@ function ecomapSvg(input, opts = {}) {
|
|
|
1368
1399
|
const entries = [];
|
|
1369
1400
|
if (sats.some((s) => s.style === "plain")) {
|
|
1370
1401
|
entries.push({
|
|
1371
|
-
swatch: (x, y) => `<line x1="${x}" y1="${y}" x2="${x +
|
|
1402
|
+
swatch: (x, y) => `<line x1="${x}" y1="${y}" x2="${x + LEGEND_SWATCH_W}" y2="${y}" stroke="${EDGE_INK2}" stroke-width="${EDGE_STROKE.plain.width}" stroke-opacity="${EDGE_STROKE.plain.opacity}"/>`,
|
|
1372
1403
|
label: labels.neutralTie
|
|
1373
1404
|
});
|
|
1374
1405
|
}
|
|
@@ -1378,64 +1409,2600 @@ function ecomapSvg(input, opts = {}) {
|
|
|
1378
1409
|
const ink = EDGE_STROKE[style];
|
|
1379
1410
|
const dashAttr = ink.dash === null ? "" : ` stroke-dasharray="${ink.dash[0]} ${ink.dash[1]}"`;
|
|
1380
1411
|
entries.push({
|
|
1381
|
-
swatch: (x, y) => `<line x1="${x}" y1="${y}" x2="${x +
|
|
1412
|
+
swatch: (x, y) => `<line x1="${x}" y1="${y}" x2="${x + LEGEND_SWATCH_W}" y2="${y}" stroke="${EDGE_INK2}" stroke-width="${ink.width}" stroke-opacity="${ink.opacity}"${dashAttr}/>`,
|
|
1382
1413
|
label: labels.bondStyles[style]
|
|
1383
1414
|
});
|
|
1384
1415
|
}
|
|
1385
1416
|
if (sats.some((s) => s.tie.direction !== null)) {
|
|
1386
1417
|
entries.push({
|
|
1387
|
-
swatch: (x, y) => `<line x1="${x}" y1="${y}" x2="${x +
|
|
1418
|
+
swatch: (x, y) => `<line x1="${x}" y1="${y}" x2="${x + LEGEND_SWATCH_W - 8}" y2="${y}" stroke="${EDGE_INK2}" stroke-width="1.5" stroke-opacity="0.75"/>` + arrowHead(x + LEGEND_SWATCH_W, y, 1, 0, 0.75),
|
|
1388
1419
|
label: labels.direction
|
|
1389
1420
|
});
|
|
1390
1421
|
}
|
|
1391
1422
|
if (entries.length > 0) {
|
|
1392
|
-
const
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1423
|
+
const block = legendBlock(entries, height);
|
|
1424
|
+
parts.push(block.svg);
|
|
1425
|
+
width = Math.max(width, block.width);
|
|
1426
|
+
height = block.height;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
const w = Math.ceil(width);
|
|
1430
|
+
const h = Math.ceil(height);
|
|
1431
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${xmlEscape(labels.ariaLabel)}">` + parts.join("") + `</svg>`;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// src/fault-tree/types.ts
|
|
1435
|
+
var FAULT_TREE_EVENT_KINDS = [
|
|
1436
|
+
"intermediate",
|
|
1437
|
+
"basic",
|
|
1438
|
+
"undeveloped",
|
|
1439
|
+
"house",
|
|
1440
|
+
"conditioning",
|
|
1441
|
+
"transfer"
|
|
1442
|
+
];
|
|
1443
|
+
var GATE_TYPES = ["and", "or", "xor", "inhibit", "vote"];
|
|
1444
|
+
|
|
1445
|
+
// src/fault-tree/labels.ts
|
|
1446
|
+
var FAULT_TREE_TITLE_LABELS_EN = {
|
|
1447
|
+
gates: {
|
|
1448
|
+
and: "AND gate",
|
|
1449
|
+
or: "OR gate",
|
|
1450
|
+
xor: "Exclusive-OR gate",
|
|
1451
|
+
inhibit: "Inhibit gate",
|
|
1452
|
+
vote: "Voting gate"
|
|
1453
|
+
},
|
|
1454
|
+
condition: "condition"
|
|
1455
|
+
};
|
|
1456
|
+
var FAULT_TREE_SVG_LABELS_EN = {
|
|
1457
|
+
events: {
|
|
1458
|
+
intermediate: "Intermediate event",
|
|
1459
|
+
basic: "Basic event",
|
|
1460
|
+
undeveloped: "Undeveloped event",
|
|
1461
|
+
house: "External event (normally occurs)",
|
|
1462
|
+
conditioning: "Conditioning event",
|
|
1463
|
+
transfer: "Transfer (developed elsewhere)"
|
|
1464
|
+
},
|
|
1465
|
+
gates: {
|
|
1466
|
+
and: "AND gate (all inputs)",
|
|
1467
|
+
or: "OR gate (any input)",
|
|
1468
|
+
xor: "Exclusive-OR gate (exactly one input)",
|
|
1469
|
+
inhibit: "INHIBIT gate (input under condition)",
|
|
1470
|
+
vote: "Voting gate (k of n)"
|
|
1471
|
+
},
|
|
1472
|
+
ariaLabel: "Fault tree"
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1475
|
+
// src/fault-tree/validate.ts
|
|
1476
|
+
var FaultTreeValidationError = class extends Error {
|
|
1477
|
+
issues;
|
|
1478
|
+
constructor(issues) {
|
|
1479
|
+
super(`invalid fault tree: ${issues.map((i) => i.message).join("; ")}`);
|
|
1480
|
+
this.name = "FaultTreeValidationError";
|
|
1481
|
+
this.issues = issues;
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
function listIds(ids) {
|
|
1485
|
+
if (ids.length === 1) return String(ids[0]);
|
|
1486
|
+
return `${ids.slice(0, -1).join(", ")} and ${ids[ids.length - 1]}`;
|
|
1487
|
+
}
|
|
1488
|
+
function sortIssues(issues) {
|
|
1489
|
+
const unique = /* @__PURE__ */ new Map();
|
|
1490
|
+
for (const issue of issues) unique.set(`${issue.code} ${issue.message}`, issue);
|
|
1491
|
+
return [...unique.values()].sort(
|
|
1492
|
+
(a, b) => a.code !== b.code ? a.code < b.code ? -1 : 1 : a.message < b.message ? -1 : a.message > b.message ? 1 : 0
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
function faultTreeIssues(input) {
|
|
1496
|
+
if (input.events.length === 0 && input.gates.length === 0) return [];
|
|
1497
|
+
const issues = [];
|
|
1498
|
+
const push = (code, message) => {
|
|
1499
|
+
issues.push({ code, message });
|
|
1500
|
+
};
|
|
1501
|
+
const eventById = /* @__PURE__ */ new Map();
|
|
1502
|
+
const dupEventIds = /* @__PURE__ */ new Set();
|
|
1503
|
+
for (const e of input.events) {
|
|
1504
|
+
if (eventById.has(e.id)) dupEventIds.add(e.id);
|
|
1505
|
+
else eventById.set(e.id, e);
|
|
1506
|
+
}
|
|
1507
|
+
for (const id of [...dupEventIds].sort((a, b) => a - b)) {
|
|
1508
|
+
push("duplicate-id", `duplicate event id ${id}`);
|
|
1509
|
+
}
|
|
1510
|
+
const gateById = /* @__PURE__ */ new Map();
|
|
1511
|
+
const dupGateIds = /* @__PURE__ */ new Set();
|
|
1512
|
+
for (const g of input.gates) {
|
|
1513
|
+
if (gateById.has(g.id)) dupGateIds.add(g.id);
|
|
1514
|
+
else gateById.set(g.id, g);
|
|
1515
|
+
}
|
|
1516
|
+
for (const id of [...dupGateIds].sort((a, b) => a - b)) {
|
|
1517
|
+
push("duplicate-id", `duplicate gate id ${id}`);
|
|
1518
|
+
}
|
|
1519
|
+
if (issues.length > 0) {
|
|
1520
|
+
return sortIssues(issues);
|
|
1521
|
+
}
|
|
1522
|
+
const gates = [...gateById.values()].sort((a, b) => a.id - b.id);
|
|
1523
|
+
const top = eventById.get(input.topId);
|
|
1524
|
+
if (top === void 0 || top.kind !== "intermediate") {
|
|
1525
|
+
push("top-not-intermediate", `topId ${input.topId} is not an intermediate event`);
|
|
1526
|
+
}
|
|
1527
|
+
const conditioningAsInput = /* @__PURE__ */ new Set();
|
|
1528
|
+
for (const g of gates) {
|
|
1529
|
+
const out = eventById.get(g.eventId);
|
|
1530
|
+
if (out !== void 0 && out.kind === "transfer") {
|
|
1531
|
+
push("transfer-with-gate", `transfer event ${g.eventId} cannot have a gate`);
|
|
1532
|
+
} else if (out === void 0 || out.kind !== "intermediate") {
|
|
1533
|
+
push("gate-on-non-intermediate", `gate ${g.id} resolves non-intermediate event ${g.eventId}`);
|
|
1534
|
+
}
|
|
1535
|
+
if (g.type === "inhibit") {
|
|
1536
|
+
if (g.inputIds.length !== 1) push("gate-arity", `inhibit gate ${g.id} needs exactly 1 input`);
|
|
1537
|
+
} else if (g.inputIds.length < 2) {
|
|
1538
|
+
push("gate-arity", `${g.type} gate ${g.id} needs \u22652 inputs`);
|
|
1539
|
+
}
|
|
1540
|
+
if (g.type === "vote" && (!Number.isInteger(g.k) || g.k < 1 || g.k > g.inputIds.length)) {
|
|
1541
|
+
push("vote-threshold", `vote gate ${g.id}: k=${g.k} of ${g.inputIds.length}`);
|
|
1542
|
+
}
|
|
1543
|
+
if (g.type === "inhibit") {
|
|
1544
|
+
const cond = eventById.get(g.conditionId);
|
|
1545
|
+
if (cond === void 0 || cond.kind !== "conditioning") {
|
|
1546
|
+
push("inhibit-condition", `inhibit gate ${g.id} conditionId ${g.conditionId} is not a conditioning event`);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
for (const inputId of g.inputIds) {
|
|
1550
|
+
const child = eventById.get(inputId);
|
|
1551
|
+
if (child === void 0) {
|
|
1552
|
+
push("unknown-input", `gate ${g.id} input ${inputId} is not a declared event`);
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (inputId === input.topId) push("top-as-input", `top event ${input.topId} is used as a gate input`);
|
|
1556
|
+
if (child.kind === "conditioning") conditioningAsInput.add(inputId);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
for (const id of [...conditioningAsInput].sort((a, b) => a - b)) {
|
|
1560
|
+
push("conditioning-as-input", `conditioning event ${id} used as a gate input`);
|
|
1561
|
+
}
|
|
1562
|
+
const gatesByEvent = /* @__PURE__ */ new Map();
|
|
1563
|
+
for (const g of gates) {
|
|
1564
|
+
const arr = gatesByEvent.get(g.eventId) ?? [];
|
|
1565
|
+
arr.push(g.id);
|
|
1566
|
+
gatesByEvent.set(g.eventId, arr);
|
|
1567
|
+
}
|
|
1568
|
+
for (const [eventId, gateIds] of [...gatesByEvent.entries()].sort((a, b) => a[0] - b[0])) {
|
|
1569
|
+
if (gateIds.length > 1) {
|
|
1570
|
+
push("event-with-two-gates", `event ${eventId} is resolved by gates ${listIds(gateIds.sort((a, b) => a - b))}`);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
for (const e of [...eventById.values()].sort((a, b) => a.id - b.id)) {
|
|
1574
|
+
if (e.kind === "intermediate" && !gatesByEvent.has(e.id)) {
|
|
1575
|
+
push("event-without-gate", `intermediate event ${e.id} has no gate \u2014 declare it kind "undeveloped" instead`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
const intermediateRefs = /* @__PURE__ */ new Map();
|
|
1579
|
+
for (const g of gates) {
|
|
1580
|
+
for (const inputId of g.inputIds) {
|
|
1581
|
+
if (eventById.get(inputId)?.kind !== "intermediate") continue;
|
|
1582
|
+
const arr = intermediateRefs.get(inputId) ?? [];
|
|
1583
|
+
arr.push(g.id);
|
|
1584
|
+
intermediateRefs.set(inputId, arr);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
for (const [eventId, gateIds] of [...intermediateRefs.entries()].sort((a, b) => a[0] - b[0])) {
|
|
1588
|
+
if (gateIds.length > 1) {
|
|
1589
|
+
push(
|
|
1590
|
+
"intermediate-reused",
|
|
1591
|
+
`intermediate event ${eventId} feeds gates ${listIds(gateIds.sort((a, b) => a - b))} \u2014 repeat it via a "transfer" event`
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
const GRAPH_BLOCKING = /* @__PURE__ */ new Set([
|
|
1596
|
+
"duplicate-id",
|
|
1597
|
+
"unknown-input",
|
|
1598
|
+
"gate-on-non-intermediate",
|
|
1599
|
+
"transfer-with-gate",
|
|
1600
|
+
"event-with-two-gates",
|
|
1601
|
+
"inhibit-condition",
|
|
1602
|
+
"top-not-intermediate"
|
|
1603
|
+
]);
|
|
1604
|
+
if (!issues.some((i) => GRAPH_BLOCKING.has(i.code))) {
|
|
1605
|
+
const gateOf = (eventId) => gates.find((g) => g.eventId === eventId);
|
|
1606
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
1607
|
+
const queue = [input.topId];
|
|
1608
|
+
while (queue.length > 0) {
|
|
1609
|
+
const id = queue.shift();
|
|
1610
|
+
if (reachable.has(id)) continue;
|
|
1611
|
+
reachable.add(id);
|
|
1612
|
+
const g = gateOf(id);
|
|
1613
|
+
if (g === void 0) continue;
|
|
1614
|
+
queue.push(...g.inputIds);
|
|
1615
|
+
if (g.type === "inhibit") queue.push(g.conditionId);
|
|
1616
|
+
}
|
|
1617
|
+
for (const e of [...eventById.values()].sort((a, b) => a.id - b.id)) {
|
|
1618
|
+
if (!reachable.has(e.id)) push("unreachable-event", `event ${e.id} is declared but unreachable from the top`);
|
|
1619
|
+
}
|
|
1620
|
+
const color = /* @__PURE__ */ new Map();
|
|
1621
|
+
const seenCycles = /* @__PURE__ */ new Set();
|
|
1622
|
+
const dfs = (start) => {
|
|
1623
|
+
const stack = [{ id: start, nextChild: 0 }];
|
|
1624
|
+
color.set(start, 1);
|
|
1625
|
+
while (stack.length > 0) {
|
|
1626
|
+
const frame = stack[stack.length - 1];
|
|
1627
|
+
const children = gateOf(frame.id)?.inputIds ?? [];
|
|
1628
|
+
if (frame.nextChild >= children.length) {
|
|
1629
|
+
color.set(frame.id, 2);
|
|
1630
|
+
stack.pop();
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
const child = children[frame.nextChild];
|
|
1634
|
+
frame.nextChild += 1;
|
|
1635
|
+
const c = color.get(child) ?? 0;
|
|
1636
|
+
if (c === 1) {
|
|
1637
|
+
const from = stack.findIndex((f) => f.id === child);
|
|
1638
|
+
const cycle = stack.slice(from).map((f) => f.id);
|
|
1639
|
+
const minIdx = cycle.indexOf(Math.min(...cycle));
|
|
1640
|
+
const rotated = [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)];
|
|
1641
|
+
const key = rotated.join(">");
|
|
1642
|
+
if (!seenCycles.has(key)) {
|
|
1643
|
+
seenCycles.add(key);
|
|
1644
|
+
push("cycle", `cycle: ${[...rotated, rotated[0]].join(" \u2192 ")}`);
|
|
1645
|
+
}
|
|
1646
|
+
} else if (c === 0) {
|
|
1647
|
+
color.set(child, 1);
|
|
1648
|
+
stack.push({ id: child, nextChild: 0 });
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
};
|
|
1652
|
+
for (const e of [...eventById.values()].sort((a, b) => a.id - b.id)) {
|
|
1653
|
+
if ((color.get(e.id) ?? 0) === 0) dfs(e.id);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
return sortIssues(issues);
|
|
1657
|
+
}
|
|
1658
|
+
function validateFaultTree(input) {
|
|
1659
|
+
const issues = faultTreeIssues(input);
|
|
1660
|
+
if (issues.length > 0) throw new FaultTreeValidationError(issues);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// src/fault-tree/layout.ts
|
|
1664
|
+
var FT_LABEL_FONT = 12;
|
|
1665
|
+
var FT_LABEL_LINE_H = 14;
|
|
1666
|
+
var CODE_FONT = 10;
|
|
1667
|
+
var LABEL_GAP = 6;
|
|
1668
|
+
var PADDING3 = 32;
|
|
1669
|
+
var H_GAP = 28;
|
|
1670
|
+
var CORRIDOR = 30;
|
|
1671
|
+
var STEM = 10;
|
|
1672
|
+
var COND_GAP = 14;
|
|
1673
|
+
var GATE_W = 44;
|
|
1674
|
+
var GATE_H = 36;
|
|
1675
|
+
var XOR_H = GATE_H + 5;
|
|
1676
|
+
var INHIBIT_W = 36;
|
|
1677
|
+
var INHIBIT_H = 46;
|
|
1678
|
+
var INHIBIT_CY = 23;
|
|
1679
|
+
var FT_STEM_ID_BASE = 1e6;
|
|
1680
|
+
var FT_DROP_ID_BASE = 2e6;
|
|
1681
|
+
var FT_BUS_ID_BASE = 3e6;
|
|
1682
|
+
var FT_RISER_ID_BASE = 4e6;
|
|
1683
|
+
var FT_CONDITION_ID_BASE = 5e6;
|
|
1684
|
+
var round2 = (n) => Math.round(n * 100) / 100;
|
|
1685
|
+
function wrapLeafLabel(displayLabel) {
|
|
1686
|
+
const perLine = Math.min(20, Math.max(12, Math.ceil(displayLabel.length / 2) + 2));
|
|
1687
|
+
return wrapLabel(displayLabel, perLine);
|
|
1688
|
+
}
|
|
1689
|
+
var CODE_REAL_HEADROOM = 1.15;
|
|
1690
|
+
var CODE_MARGIN = 2;
|
|
1691
|
+
function codeGlyphHalfAt(kind, y) {
|
|
1692
|
+
if (kind === "basic") {
|
|
1693
|
+
const dy = y - 22;
|
|
1694
|
+
return Math.sqrt(Math.max(0, 22 * 22 - dy * dy));
|
|
1695
|
+
}
|
|
1696
|
+
if (kind === "undeveloped") {
|
|
1697
|
+
return 24 * Math.max(0, 1 - Math.abs(y - 24) / 24);
|
|
1698
|
+
}
|
|
1699
|
+
if (kind === "house") {
|
|
1700
|
+
return y >= 16 ? 22 : 22 * y / 16;
|
|
1701
|
+
}
|
|
1702
|
+
return 22 * Math.max(0, Math.min(35, y)) / 35;
|
|
1703
|
+
}
|
|
1704
|
+
function codeBaselineHalfWidth(kind, glyphH) {
|
|
1705
|
+
return codeGlyphHalfAt(kind, glyphH / 2 + CODE_FONT * 0.32);
|
|
1706
|
+
}
|
|
1707
|
+
function maxCodeChars(kind, glyphH) {
|
|
1708
|
+
const half = codeBaselineHalfWidth(kind, glyphH);
|
|
1709
|
+
const perChar = CODE_FONT * CHAR_W * CODE_REAL_HEADROOM / 2;
|
|
1710
|
+
return Math.max(1, Math.floor((half - CODE_MARGIN) / perChar));
|
|
1711
|
+
}
|
|
1712
|
+
function measureEvent(event, maxLabelChars) {
|
|
1713
|
+
if (event.kind === "intermediate") {
|
|
1714
|
+
const lines2 = event.label === "" ? [] : wrapLabelBalanced(clampLabel(event.label, maxLabelChars), 3);
|
|
1715
|
+
const maxLineW = lines2.reduce((m, l) => Math.max(m, estimateTextWidth(l, FT_LABEL_FONT)), 0);
|
|
1716
|
+
const w = Math.max(96, maxLineW + 24);
|
|
1717
|
+
const h = lines2.length * FT_LABEL_LINE_H + 18;
|
|
1718
|
+
return { nodeW: w, nodeH: h, glyphW: w, glyphH: h, labelLines: lines2, code: null };
|
|
1719
|
+
}
|
|
1720
|
+
const rawCode = event.code === null || event.code === "" ? null : event.code;
|
|
1721
|
+
const lines = event.label === "" ? [] : wrapLeafLabel(clampLabel(event.label, maxLabelChars));
|
|
1722
|
+
const labelW = lines.reduce((m, l) => Math.max(m, estimateTextWidth(l, FT_LABEL_FONT)), 0);
|
|
1723
|
+
let glyphW;
|
|
1724
|
+
let glyphH;
|
|
1725
|
+
let code = rawCode;
|
|
1726
|
+
if (event.kind === "basic") {
|
|
1727
|
+
glyphW = 44;
|
|
1728
|
+
glyphH = 44;
|
|
1729
|
+
code = rawCode === null ? null : clampLabel(rawCode, maxCodeChars("basic", glyphH));
|
|
1730
|
+
} else if (event.kind === "undeveloped") {
|
|
1731
|
+
glyphW = 48;
|
|
1732
|
+
glyphH = 48;
|
|
1733
|
+
code = rawCode === null ? null : clampLabel(rawCode, maxCodeChars("undeveloped", glyphH));
|
|
1734
|
+
} else if (event.kind === "house") {
|
|
1735
|
+
glyphW = 44;
|
|
1736
|
+
glyphH = 40;
|
|
1737
|
+
code = rawCode === null ? null : clampLabel(rawCode, maxCodeChars("house", glyphH));
|
|
1738
|
+
} else if (event.kind === "conditioning") {
|
|
1739
|
+
code = rawCode === null ? null : clampLabel(rawCode, 6);
|
|
1740
|
+
const rx = Math.max(30, estimateTextWidth(code ?? "", CODE_FONT) / 2 + 8);
|
|
1741
|
+
glyphW = rx * 2;
|
|
1742
|
+
glyphH = 32;
|
|
1743
|
+
} else {
|
|
1744
|
+
glyphW = 44;
|
|
1745
|
+
glyphH = 35;
|
|
1746
|
+
code = rawCode === null ? null : clampLabel(rawCode, maxCodeChars("transfer", glyphH));
|
|
1747
|
+
}
|
|
1748
|
+
const nodeW = Math.max(glyphW, labelW);
|
|
1749
|
+
const nodeH = glyphH + (lines.length > 0 ? LABEL_GAP + lines.length * FT_LABEL_LINE_H : 0);
|
|
1750
|
+
return { nodeW, nodeH, glyphW, glyphH, labelLines: lines, code };
|
|
1751
|
+
}
|
|
1752
|
+
function eventTitle(event) {
|
|
1753
|
+
if (event.title !== void 0) return event.title;
|
|
1754
|
+
return event.code !== null && event.code !== "" ? `${event.code} \xB7 ${event.label}` : event.label;
|
|
1755
|
+
}
|
|
1756
|
+
function computeFaultTreeLayout(input, opts = {}) {
|
|
1757
|
+
if (input.events.length === 0 && input.gates.length === 0) {
|
|
1758
|
+
return { width: PADDING3 * 2, height: PADDING3 * 2, nodes: [], gates: [], elements: [] };
|
|
1759
|
+
}
|
|
1760
|
+
validateFaultTree(input);
|
|
1761
|
+
const titleLabels = opts.titleLabels ?? FAULT_TREE_TITLE_LABELS_EN;
|
|
1762
|
+
const eventById = new Map(input.events.map((e) => [e.id, e]));
|
|
1763
|
+
const gateByEvent = new Map(input.gates.map((g) => [g.eventId, g]));
|
|
1764
|
+
const seqByEvent = /* @__PURE__ */ new Map();
|
|
1765
|
+
const newInst = (event, gate, depth) => {
|
|
1766
|
+
const seq = seqByEvent.get(event.id) ?? 0;
|
|
1767
|
+
seqByEvent.set(event.id, seq + 1);
|
|
1768
|
+
const inst = {
|
|
1769
|
+
event,
|
|
1770
|
+
gate,
|
|
1771
|
+
children: [],
|
|
1772
|
+
cond: null,
|
|
1773
|
+
depth,
|
|
1774
|
+
m: measureEvent(event, opts.maxLabelChars),
|
|
1775
|
+
title: eventTitle(event),
|
|
1776
|
+
seq,
|
|
1777
|
+
spanL: 0,
|
|
1778
|
+
spanR: 0,
|
|
1779
|
+
offset: 0,
|
|
1780
|
+
cx: 0
|
|
1781
|
+
};
|
|
1782
|
+
return inst;
|
|
1783
|
+
};
|
|
1784
|
+
const build = (eventId, depth) => {
|
|
1785
|
+
const event = eventById.get(eventId);
|
|
1786
|
+
const gate = gateByEvent.get(eventId) ?? null;
|
|
1787
|
+
const inst = newInst(event, gate, depth);
|
|
1788
|
+
if (gate !== null) {
|
|
1789
|
+
if (gate.type === "inhibit") {
|
|
1790
|
+
inst.cond = newInst(eventById.get(gate.conditionId), null, depth);
|
|
1791
|
+
}
|
|
1792
|
+
for (const childId of gate.inputIds) inst.children.push(build(childId, depth + 1));
|
|
1793
|
+
}
|
|
1794
|
+
return inst;
|
|
1795
|
+
};
|
|
1796
|
+
const root = build(input.topId, 0);
|
|
1797
|
+
const pack = (inst) => {
|
|
1798
|
+
for (const c of inst.children) pack(c);
|
|
1799
|
+
const condW = inst.cond !== null ? COND_GAP + inst.cond.m.nodeW : 0;
|
|
1800
|
+
const selfL = inst.m.nodeW / 2;
|
|
1801
|
+
const selfR = inst.m.nodeW / 2 + condW;
|
|
1802
|
+
if (inst.children.length === 0) {
|
|
1803
|
+
inst.spanL = selfL;
|
|
1804
|
+
inst.spanR = selfR;
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
const xs = [0];
|
|
1808
|
+
for (let i = 1; i < inst.children.length; i++) {
|
|
1809
|
+
xs.push(xs[i - 1] + inst.children[i - 1].spanR + H_GAP + inst.children[i].spanL);
|
|
1810
|
+
}
|
|
1811
|
+
const first = inst.children[0];
|
|
1812
|
+
const last2 = inst.children[inst.children.length - 1];
|
|
1813
|
+
const px = (xs[0] + xs[xs.length - 1]) / 2;
|
|
1814
|
+
inst.children.forEach((c, i) => c.offset = xs[i] - px);
|
|
1815
|
+
inst.spanL = Math.max(selfL, px - (xs[0] - first.spanL));
|
|
1816
|
+
inst.spanR = Math.max(selfR, xs[xs.length - 1] + last2.spanR - px);
|
|
1817
|
+
};
|
|
1818
|
+
pack(root);
|
|
1819
|
+
root.cx = PADDING3 + root.spanL;
|
|
1820
|
+
const placeX = (inst) => {
|
|
1821
|
+
for (const c of inst.children) {
|
|
1822
|
+
c.cx = inst.cx + c.offset;
|
|
1823
|
+
placeX(c);
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
placeX(root);
|
|
1827
|
+
const rowInsts = [];
|
|
1828
|
+
const visitRows = (inst) => {
|
|
1829
|
+
(rowInsts[inst.depth] ??= []).push(inst);
|
|
1830
|
+
for (const c of inst.children) visitRows(c);
|
|
1831
|
+
};
|
|
1832
|
+
visitRows(root);
|
|
1833
|
+
const depthCount = rowInsts.length;
|
|
1834
|
+
const gateExtent = (inst) => {
|
|
1835
|
+
const g = inst.gate;
|
|
1836
|
+
if (g.type === "xor") return XOR_H;
|
|
1837
|
+
if (g.type === "inhibit") {
|
|
1838
|
+
const condBottom = INHIBIT_CY - 16 + inst.cond.m.nodeH;
|
|
1839
|
+
return Math.max(INHIBIT_H, condBottom);
|
|
1840
|
+
}
|
|
1841
|
+
return GATE_H;
|
|
1842
|
+
};
|
|
1843
|
+
const rowH = [];
|
|
1844
|
+
const gateZone = [];
|
|
1845
|
+
for (let d = 0; d < depthCount; d++) {
|
|
1846
|
+
const insts = rowInsts[d];
|
|
1847
|
+
rowH.push(insts.reduce((m, i) => Math.max(m, i.m.nodeH), 0));
|
|
1848
|
+
const gated = insts.filter((i) => i.gate !== null);
|
|
1849
|
+
gateZone.push(gated.length === 0 ? 0 : STEM + gated.reduce((m, i) => Math.max(m, gateExtent(i)), 0));
|
|
1850
|
+
}
|
|
1851
|
+
const rowTop = [PADDING3];
|
|
1852
|
+
for (let d = 0; d < depthCount - 1; d++) {
|
|
1853
|
+
rowTop.push(rowTop[d] + rowH[d] + gateZone[d] + CORRIDOR);
|
|
1854
|
+
}
|
|
1855
|
+
const busY = (d) => rowTop[d + 1] - CORRIDOR / 2;
|
|
1856
|
+
const width = Math.ceil(PADDING3 * 2 + root.spanL + root.spanR);
|
|
1857
|
+
const last = depthCount - 1;
|
|
1858
|
+
const height = Math.ceil(rowTop[last] + rowH[last] + gateZone[last] + PADDING3);
|
|
1859
|
+
const instanceOf = (inst) => (seqByEvent.get(inst.event.id) ?? 0) > 1 ? inst.seq : null;
|
|
1860
|
+
const nodes = [];
|
|
1861
|
+
const gates = [];
|
|
1862
|
+
const elements = [];
|
|
1863
|
+
const gateTitle = (g) => g.type === "vote" ? `${titleLabels.gates.vote} ${g.k}/${g.inputIds.length}` : titleLabels.gates[g.type];
|
|
1864
|
+
const pushNode = (inst, top) => {
|
|
1865
|
+
const isRect = inst.event.kind === "intermediate";
|
|
1866
|
+
const labelTop = !isRect && inst.m.labelLines.length > 0 ? top + inst.m.glyphH + LABEL_GAP : null;
|
|
1867
|
+
nodes.push({
|
|
1868
|
+
eventId: inst.event.id,
|
|
1869
|
+
kind: inst.event.kind,
|
|
1870
|
+
instance: instanceOf(inst),
|
|
1871
|
+
cx: round2(inst.cx),
|
|
1872
|
+
top: round2(top),
|
|
1873
|
+
nodeW: round2(inst.m.nodeW),
|
|
1874
|
+
nodeH: round2(inst.m.nodeH),
|
|
1875
|
+
glyphW: round2(inst.m.glyphW),
|
|
1876
|
+
glyphH: round2(inst.m.glyphH),
|
|
1877
|
+
labelLines: inst.m.labelLines,
|
|
1878
|
+
labelTop: labelTop === null ? null : round2(labelTop),
|
|
1879
|
+
code: inst.m.code,
|
|
1880
|
+
depth: inst.depth,
|
|
1881
|
+
title: inst.title
|
|
1882
|
+
});
|
|
1883
|
+
};
|
|
1884
|
+
const emit = (inst) => {
|
|
1885
|
+
const d = inst.depth;
|
|
1886
|
+
pushNode(inst, rowTop[d]);
|
|
1887
|
+
const g = inst.gate;
|
|
1888
|
+
if (g === null) return;
|
|
1889
|
+
const gateTop = rowTop[d] + rowH[d] + STEM;
|
|
1890
|
+
const glyphW = g.type === "inhibit" ? INHIBIT_W : GATE_W;
|
|
1891
|
+
const glyphH = g.type === "xor" ? XOR_H : g.type === "inhibit" ? INHIBIT_H : GATE_H;
|
|
1892
|
+
const title = gateTitle(g);
|
|
1893
|
+
gates.push({
|
|
1894
|
+
gateId: g.id,
|
|
1895
|
+
type: g.type,
|
|
1896
|
+
cx: round2(inst.cx),
|
|
1897
|
+
top: round2(gateTop),
|
|
1898
|
+
glyphW,
|
|
1899
|
+
glyphH,
|
|
1900
|
+
title,
|
|
1901
|
+
voteText: g.type === "vote" ? `${g.k}/${g.inputIds.length}` : null
|
|
1902
|
+
});
|
|
1903
|
+
if (inst.cond !== null) {
|
|
1904
|
+
const cond = inst.cond;
|
|
1905
|
+
const nodeLeft = inst.cx + INHIBIT_W / 2 + COND_GAP;
|
|
1906
|
+
cond.cx = nodeLeft + cond.m.nodeW / 2;
|
|
1907
|
+
const condTop = gateTop + INHIBIT_CY - 16;
|
|
1908
|
+
pushNode(cond, condTop);
|
|
1909
|
+
const ovalLeft = cond.cx - cond.m.glyphW / 2;
|
|
1910
|
+
elements.push({
|
|
1911
|
+
edgeId: FT_CONDITION_ID_BASE + g.id,
|
|
1912
|
+
kind: "condition",
|
|
1913
|
+
points: [
|
|
1914
|
+
{ x: round2(inst.cx + INHIBIT_W / 2), y: round2(gateTop + INHIBIT_CY) },
|
|
1915
|
+
{ x: round2(ovalLeft), y: round2(gateTop + INHIBIT_CY) }
|
|
1916
|
+
],
|
|
1917
|
+
instance: null,
|
|
1918
|
+
title: titleLabels.condition
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
elements.push({
|
|
1922
|
+
edgeId: FT_STEM_ID_BASE + g.id,
|
|
1923
|
+
kind: "stem",
|
|
1924
|
+
points: [
|
|
1925
|
+
{ x: round2(inst.cx), y: round2(rowTop[d] + inst.m.nodeH) },
|
|
1926
|
+
{ x: round2(inst.cx), y: round2(gateTop) }
|
|
1927
|
+
],
|
|
1928
|
+
instance: null,
|
|
1929
|
+
title
|
|
1930
|
+
});
|
|
1931
|
+
const by = busY(d);
|
|
1932
|
+
elements.push({
|
|
1933
|
+
edgeId: FT_DROP_ID_BASE + g.id,
|
|
1934
|
+
kind: "drop",
|
|
1935
|
+
points: [
|
|
1936
|
+
{ x: round2(inst.cx), y: round2(gateTop + glyphH) },
|
|
1937
|
+
{ x: round2(inst.cx), y: round2(by) }
|
|
1938
|
+
],
|
|
1939
|
+
instance: null,
|
|
1940
|
+
title
|
|
1941
|
+
});
|
|
1942
|
+
if (inst.children.length > 1) {
|
|
1943
|
+
const xs = inst.children.map((c) => c.cx);
|
|
1944
|
+
elements.push({
|
|
1945
|
+
edgeId: FT_BUS_ID_BASE + g.id,
|
|
1946
|
+
kind: "bus",
|
|
1947
|
+
points: [
|
|
1948
|
+
{ x: round2(Math.min(...xs)), y: round2(by) },
|
|
1949
|
+
{ x: round2(Math.max(...xs)), y: round2(by) }
|
|
1950
|
+
],
|
|
1951
|
+
instance: null,
|
|
1952
|
+
title
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
for (const c of inst.children) {
|
|
1956
|
+
elements.push({
|
|
1957
|
+
edgeId: FT_RISER_ID_BASE + c.event.id,
|
|
1958
|
+
kind: "riser",
|
|
1959
|
+
points: [
|
|
1960
|
+
{ x: round2(c.cx), y: round2(by) },
|
|
1961
|
+
{ x: round2(c.cx), y: round2(rowTop[d + 1]) }
|
|
1962
|
+
],
|
|
1963
|
+
instance: instanceOf(c),
|
|
1964
|
+
title
|
|
1397
1965
|
});
|
|
1398
|
-
parts.push(`<g data-compasso-legend="true">${rows.join("")}</g>`);
|
|
1399
|
-
const widestLabel = entries.reduce((m, e) => Math.max(m, estimateTextWidth(e.label, LEGEND_FONT2)), 0);
|
|
1400
|
-
width = Math.max(width, LEGEND_PAD2 + LEGEND_SWATCH_W2 + LEGEND_GAP2 + widestLabel + LEGEND_PAD2);
|
|
1401
|
-
height = startY + entries.length * LEGEND_ROW_H2 + LEGEND_PAD2 / 2;
|
|
1402
1966
|
}
|
|
1967
|
+
for (const c of inst.children) emit(c);
|
|
1968
|
+
};
|
|
1969
|
+
emit(root);
|
|
1970
|
+
return { width, height, nodes, gates, elements };
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// src/fault-tree/svg.ts
|
|
1974
|
+
var GLYPH_STROKE2 = "#52525b";
|
|
1975
|
+
var LABEL_FILL2 = "#3f3f46";
|
|
1976
|
+
var EDGE_INK3 = "#71717a";
|
|
1977
|
+
var GLYPH_ATTRS = `fill="transparent" stroke="${GLYPH_STROKE2}" stroke-width="2"`;
|
|
1978
|
+
var round3 = (n) => Math.round(n * 100) / 100;
|
|
1979
|
+
function eventGlyph(n) {
|
|
1980
|
+
const cx = n.cx;
|
|
1981
|
+
const top = n.top;
|
|
1982
|
+
if (n.kind === "intermediate") {
|
|
1983
|
+
return `<rect x="${round3(cx - n.nodeW / 2)}" y="${top}" width="${n.nodeW}" height="${n.nodeH}" rx="2" ${GLYPH_ATTRS}/>`;
|
|
1984
|
+
}
|
|
1985
|
+
if (n.kind === "basic") {
|
|
1986
|
+
return `<circle cx="${cx}" cy="${round3(top + 22)}" r="22" ${GLYPH_ATTRS}/>`;
|
|
1987
|
+
}
|
|
1988
|
+
if (n.kind === "undeveloped") {
|
|
1989
|
+
const pts2 = `${cx},${top} ${round3(cx + 24)},${round3(top + 24)} ${cx},${round3(top + 48)} ${round3(cx - 24)},${round3(top + 24)}`;
|
|
1990
|
+
return `<polygon points="${pts2}" ${GLYPH_ATTRS}/>`;
|
|
1991
|
+
}
|
|
1992
|
+
if (n.kind === "house") {
|
|
1993
|
+
const yB = round3(top + 40);
|
|
1994
|
+
const eave = round3(top + 16);
|
|
1995
|
+
const pts2 = `${round3(cx - 22)},${yB} ${round3(cx - 22)},${eave} ${cx},${top} ${round3(cx + 22)},${eave} ${round3(cx + 22)},${yB}`;
|
|
1996
|
+
return `<polygon points="${pts2}" ${GLYPH_ATTRS}/>`;
|
|
1997
|
+
}
|
|
1998
|
+
if (n.kind === "conditioning") {
|
|
1999
|
+
return `<ellipse cx="${cx}" cy="${round3(top + 16)}" rx="${round3(n.glyphW / 2)}" ry="16" ${GLYPH_ATTRS}/>`;
|
|
2000
|
+
}
|
|
2001
|
+
const pts = `${cx},${top} ${round3(cx + 22)},${round3(top + 35)} ${round3(cx - 22)},${round3(top + 35)}`;
|
|
2002
|
+
return `<polygon points="${pts}" ${GLYPH_ATTRS}/>`;
|
|
2003
|
+
}
|
|
2004
|
+
function nodeSvg(n) {
|
|
2005
|
+
const pieces = [`<title>${xmlEscape(n.title)}</title>`, eventGlyph(n)];
|
|
2006
|
+
if (n.code !== null && n.kind !== "intermediate") {
|
|
2007
|
+
pieces.push(
|
|
2008
|
+
`<text x="${n.cx}" y="${round3(n.top + n.glyphH / 2 + CODE_FONT * 0.32)}" text-anchor="middle" font-family="${FONT_FAMILY}" font-size="${CODE_FONT}" fill="${LABEL_FILL2}">${xmlEscape(n.code)}</text>`
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
2011
|
+
if (n.labelLines.length > 0) {
|
|
2012
|
+
const firstBaseline = n.labelTop === null ? round3(n.top + 19) : round3(n.labelTop + 10);
|
|
2013
|
+
const tspans = n.labelLines.map((line, i) => `<tspan x="${n.cx}" y="${round3(firstBaseline + i * FT_LABEL_LINE_H)}">${xmlEscape(line)}</tspan>`).join("");
|
|
2014
|
+
pieces.push(
|
|
2015
|
+
`<text text-anchor="middle" font-family="${FONT_FAMILY}" font-size="${FT_LABEL_FONT}" fill="${LABEL_FILL2}">${tspans}</text>`
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
const instance = n.instance === null ? "" : ` data-instance="${n.instance}"`;
|
|
2019
|
+
return `<g data-node-id="e${n.eventId}"${instance}>${pieces.join("")}</g>`;
|
|
2020
|
+
}
|
|
2021
|
+
function orBodyPath(cx, top, yB) {
|
|
2022
|
+
return `M ${round3(cx - 22)} ${yB} Q ${cx} ${round3(yB - 14)} ${round3(cx + 22)} ${yB} Q ${round3(cx + 22)} ${round3(top + 14)} ${cx} ${top} Q ${round3(cx - 22)} ${round3(top + 14)} ${round3(cx - 22)} ${yB} Z`;
|
|
2023
|
+
}
|
|
2024
|
+
function gateGlyph(g) {
|
|
2025
|
+
const cx = g.cx;
|
|
2026
|
+
const top = g.top;
|
|
2027
|
+
const yB = round3(top + 36);
|
|
2028
|
+
if (g.type === "and") {
|
|
2029
|
+
const d = `M ${round3(cx - 22)} ${yB} L ${round3(cx - 22)} ${round3(yB - 14)} A 22 22 0 0 1 ${round3(cx + 22)} ${round3(yB - 14)} L ${round3(cx + 22)} ${yB} Z`;
|
|
2030
|
+
return `<path d="${d}" ${GLYPH_ATTRS}/>`;
|
|
2031
|
+
}
|
|
2032
|
+
if (g.type === "or") {
|
|
2033
|
+
return `<path d="${orBodyPath(cx, top, yB)}" ${GLYPH_ATTRS}/>`;
|
|
2034
|
+
}
|
|
2035
|
+
if (g.type === "xor") {
|
|
2036
|
+
const arc = `M ${round3(cx - 22)} ${round3(yB + 5)} Q ${cx} ${round3(yB - 9)} ${round3(cx + 22)} ${round3(yB + 5)}`;
|
|
2037
|
+
return `<path d="${orBodyPath(cx, top, yB)}" ${GLYPH_ATTRS}/><path d="${arc}" ${GLYPH_ATTRS}/>`;
|
|
2038
|
+
}
|
|
2039
|
+
if (g.type === "inhibit") {
|
|
2040
|
+
const pts = `${cx},${top} ${round3(cx + 18)},${round3(top + 12)} ${round3(cx + 18)},${round3(top + 34)} ${cx},${round3(top + 46)} ${round3(cx - 18)},${round3(top + 34)} ${round3(cx - 18)},${round3(top + 12)}`;
|
|
2041
|
+
return `<polygon points="${pts}" ${GLYPH_ATTRS}/>`;
|
|
2042
|
+
}
|
|
2043
|
+
return `<path d="${orBodyPath(cx, top, yB)}" ${GLYPH_ATTRS}/><text x="${cx}" y="${round3(yB - 10)}" text-anchor="middle" font-family="${FONT_FAMILY}" font-size="9" fill="${LABEL_FILL2}">${xmlEscape(g.voteText ?? "")}</text>`;
|
|
2044
|
+
}
|
|
2045
|
+
function gateSvg(g) {
|
|
2046
|
+
return `<g data-node-id="g${g.gateId}"><title>${xmlEscape(g.title)}</title>${gateGlyph(g)}</g>`;
|
|
2047
|
+
}
|
|
2048
|
+
function elementSvg2(el) {
|
|
2049
|
+
const a = el.points[0];
|
|
2050
|
+
const b = el.points[1];
|
|
2051
|
+
const instance = el.instance === null ? "" : ` data-instance="${el.instance}"`;
|
|
2052
|
+
return `<g data-edge-id="${el.edgeId}"${instance}><title>${xmlEscape(el.title)}</title><line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${EDGE_INK3}" stroke-width="1.5" stroke-opacity="0.75"/></g>`;
|
|
2053
|
+
}
|
|
2054
|
+
var MINI_ATTRS = `fill="transparent" stroke="${GLYPH_STROKE2}" stroke-width="1.5"`;
|
|
2055
|
+
function miniEventSwatch(kind, x, y) {
|
|
2056
|
+
const cx = round3(x + LEGEND_SWATCH_W / 2);
|
|
2057
|
+
if (kind === "intermediate") {
|
|
2058
|
+
return `<rect x="${round3(cx - 7)}" y="${round3(y - 4.5)}" width="14" height="9" rx="1" ${MINI_ATTRS}/>`;
|
|
2059
|
+
}
|
|
2060
|
+
if (kind === "basic") return `<circle cx="${cx}" cy="${y}" r="6" ${MINI_ATTRS}/>`;
|
|
2061
|
+
if (kind === "undeveloped") {
|
|
2062
|
+
return `<polygon points="${cx},${round3(y - 7)} ${round3(cx + 7)},${y} ${cx},${round3(y + 7)} ${round3(cx - 7)},${y}" ${MINI_ATTRS}/>`;
|
|
2063
|
+
}
|
|
2064
|
+
if (kind === "house") {
|
|
2065
|
+
return `<polygon points="${round3(cx - 6)},${round3(y + 5.5)} ${round3(cx - 6)},${round3(y - 1)} ${cx},${round3(y - 5.5)} ${round3(cx + 6)},${round3(y - 1)} ${round3(cx + 6)},${round3(y + 5.5)}" ${MINI_ATTRS}/>`;
|
|
2066
|
+
}
|
|
2067
|
+
if (kind === "conditioning") return `<ellipse cx="${cx}" cy="${y}" rx="9" ry="5.5" ${MINI_ATTRS}/>`;
|
|
2068
|
+
return `<polygon points="${cx},${round3(y - 5)} ${round3(cx + 6)},${round3(y + 5)} ${round3(cx - 6)},${round3(y + 5)}" ${MINI_ATTRS}/>`;
|
|
2069
|
+
}
|
|
2070
|
+
function miniOrPath(cx, y) {
|
|
2071
|
+
return `M ${round3(cx - 7)} ${round3(y + 5.5)} Q ${cx} ${round3(y + 1)} ${round3(cx + 7)} ${round3(y + 5.5)} Q ${round3(cx + 7)} ${round3(y - 1.5)} ${cx} ${round3(y - 5.5)} Q ${round3(cx - 7)} ${round3(y - 1.5)} ${round3(cx - 7)} ${round3(y + 5.5)} Z`;
|
|
2072
|
+
}
|
|
2073
|
+
function miniGateSwatch(type, x, y) {
|
|
2074
|
+
const cx = round3(x + LEGEND_SWATCH_W / 2);
|
|
2075
|
+
if (type === "and") {
|
|
2076
|
+
const d = `M ${round3(cx - 7)} ${round3(y + 5.5)} L ${round3(cx - 7)} ${round3(y + 1.5)} A 7 7 0 0 1 ${round3(cx + 7)} ${round3(y + 1.5)} L ${round3(cx + 7)} ${round3(y + 5.5)} Z`;
|
|
2077
|
+
return `<path d="${d}" ${MINI_ATTRS}/>`;
|
|
2078
|
+
}
|
|
2079
|
+
if (type === "xor") {
|
|
2080
|
+
return `<path d="${miniOrPath(cx, y)}" ${MINI_ATTRS}/><path d="M ${round3(cx - 7)} ${round3(y + 7.5)} Q ${cx} ${round3(y + 3)} ${round3(cx + 7)} ${round3(y + 7.5)}" ${MINI_ATTRS}/>`;
|
|
2081
|
+
}
|
|
2082
|
+
if (type === "inhibit") {
|
|
2083
|
+
return `<polygon points="${cx},${round3(y - 5.5)} ${round3(cx + 4.5)},${round3(y - 2.5)} ${round3(cx + 4.5)},${round3(y + 2.5)} ${cx},${round3(y + 5.5)} ${round3(cx - 4.5)},${round3(y + 2.5)} ${round3(cx - 4.5)},${round3(y - 2.5)}" ${MINI_ATTRS}/>`;
|
|
2084
|
+
}
|
|
2085
|
+
return `<path d="${miniOrPath(cx, y)}" ${MINI_ATTRS}/>`;
|
|
2086
|
+
}
|
|
2087
|
+
function faultTreeLayoutSvg(layout, opts = {}) {
|
|
2088
|
+
const labels = opts.labels ?? FAULT_TREE_SVG_LABELS_EN;
|
|
2089
|
+
const parts = [];
|
|
2090
|
+
for (const el of layout.elements) parts.push(elementSvg2(el));
|
|
2091
|
+
for (const g of layout.gates) parts.push(gateSvg(g));
|
|
2092
|
+
for (const n of layout.nodes) parts.push(nodeSvg(n));
|
|
2093
|
+
let width = layout.width;
|
|
2094
|
+
let height = layout.height;
|
|
2095
|
+
if (opts.legend !== false && layout.nodes.length > 0) {
|
|
2096
|
+
const kindsUsed = new Set(layout.nodes.map((n) => n.kind));
|
|
2097
|
+
const typesUsed = new Set(layout.gates.map((g) => g.type));
|
|
2098
|
+
const entries = [];
|
|
2099
|
+
for (const kind of FAULT_TREE_EVENT_KINDS) {
|
|
2100
|
+
if (!kindsUsed.has(kind)) continue;
|
|
2101
|
+
entries.push({ swatch: (x, y) => miniEventSwatch(kind, x, y), label: labels.events[kind] });
|
|
2102
|
+
}
|
|
2103
|
+
for (const type of GATE_TYPES) {
|
|
2104
|
+
if (!typesUsed.has(type)) continue;
|
|
2105
|
+
entries.push({ swatch: (x, y) => miniGateSwatch(type, x, y), label: labels.gates[type] });
|
|
2106
|
+
}
|
|
2107
|
+
const block = legendBlock(entries, layout.height);
|
|
2108
|
+
if (block.svg !== "") {
|
|
2109
|
+
parts.push(block.svg);
|
|
2110
|
+
width = Math.max(width, block.width);
|
|
2111
|
+
height = block.height;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
const w = Math.ceil(width);
|
|
2115
|
+
const h = Math.ceil(height);
|
|
2116
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${xmlEscape(labels.ariaLabel)}">` + parts.join("") + `</svg>`;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// src/fault-tree/render.ts
|
|
2120
|
+
function faultTreeSvg(input, opts = {}) {
|
|
2121
|
+
const layout = computeFaultTreeLayout(input, {
|
|
2122
|
+
...opts.maxLabelChars !== void 0 ? { maxLabelChars: opts.maxLabelChars } : {},
|
|
2123
|
+
...opts.titleLabels !== void 0 ? { titleLabels: opts.titleLabels } : {}
|
|
2124
|
+
});
|
|
2125
|
+
const svg = faultTreeLayoutSvg(layout, {
|
|
2126
|
+
...opts.legend === false ? { legend: false } : {},
|
|
2127
|
+
...opts.svgLabels !== void 0 ? { labels: opts.svgLabels } : {}
|
|
2128
|
+
});
|
|
2129
|
+
return { svg, layout };
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// src/fishbone/render.ts
|
|
2133
|
+
var FISHBONE_LABELS_EN = {
|
|
2134
|
+
cause: "Cause",
|
|
2135
|
+
subCause: "Sub-cause",
|
|
2136
|
+
ariaLabel: "Cause-and-effect diagram (Ishikawa)"
|
|
2137
|
+
};
|
|
2138
|
+
var FishboneValidationError = class extends Error {
|
|
2139
|
+
issues;
|
|
2140
|
+
constructor(issues) {
|
|
2141
|
+
super(`invalid fishbone: ${issues.map((i) => i.message).join("; ")}`);
|
|
2142
|
+
this.name = "FishboneValidationError";
|
|
2143
|
+
this.issues = issues;
|
|
2144
|
+
}
|
|
2145
|
+
};
|
|
2146
|
+
function duplicateIds(ids) {
|
|
2147
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2148
|
+
const dups = /* @__PURE__ */ new Set();
|
|
2149
|
+
for (const id of ids) {
|
|
2150
|
+
if (seen.has(id)) dups.add(id);
|
|
2151
|
+
seen.add(id);
|
|
2152
|
+
}
|
|
2153
|
+
return [...dups].sort((a, b) => a - b);
|
|
2154
|
+
}
|
|
2155
|
+
function validateIds(input) {
|
|
2156
|
+
const issues = [];
|
|
2157
|
+
const collect = (noun, ids) => {
|
|
2158
|
+
for (const id of duplicateIds(ids)) {
|
|
2159
|
+
issues.push({ code: "duplicate-id", message: `duplicate ${noun} id ${id}` });
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
collect("category", input.categories.map((c) => c.id));
|
|
2163
|
+
collect("cause", input.categories.flatMap((c) => c.causes.map((k) => k.id)));
|
|
2164
|
+
collect(
|
|
2165
|
+
"sub-cause",
|
|
2166
|
+
input.categories.flatMap((c) => c.causes.flatMap((k) => k.subCauses.map((s) => s.id)))
|
|
2167
|
+
);
|
|
2168
|
+
if (issues.length > 0) throw new FishboneValidationError(issues);
|
|
2169
|
+
}
|
|
2170
|
+
var TAN60 = 1.7320508075688772;
|
|
2171
|
+
var SIN60 = 0.8660254037844386;
|
|
2172
|
+
var COS60 = 0.5;
|
|
2173
|
+
var PADDING4 = 32;
|
|
2174
|
+
var ROW_GAP = 12;
|
|
2175
|
+
var BONE_GAP = 36;
|
|
2176
|
+
var CAT_PAD_X = 12;
|
|
2177
|
+
var CAT_PAD_Y = 8;
|
|
2178
|
+
var CAT_GAP = 12;
|
|
2179
|
+
var TAIL_EXTRA = 44;
|
|
2180
|
+
var HEAD_GAP = 24;
|
|
2181
|
+
var HEAD_PAD_X = 14;
|
|
2182
|
+
var HEAD_PAD_Y = 10;
|
|
2183
|
+
var SUB_PER_LINE = 18;
|
|
2184
|
+
var ASCENT_12 = 11;
|
|
2185
|
+
var DESCENT_12 = 3;
|
|
2186
|
+
function verticalMetrics(fontSize) {
|
|
2187
|
+
const ascent = Math.ceil(ASCENT_12 * fontSize / 12);
|
|
2188
|
+
const descent = Math.ceil(DESCENT_12 * fontSize / 12);
|
|
2189
|
+
const lineH = ascent + descent;
|
|
2190
|
+
const twigGap = descent + 1;
|
|
2191
|
+
return {
|
|
2192
|
+
ascent,
|
|
2193
|
+
lineH,
|
|
2194
|
+
twigGap,
|
|
2195
|
+
sV: 2 * lineH + twigGap + 8,
|
|
2196
|
+
spineClear: 2 * lineH + twigGap,
|
|
2197
|
+
boneClear: Math.ceil((2 * lineH + twigGap) / TAN60) + 25,
|
|
2198
|
+
subGap: lineH
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
var EDGE_INK4 = "#71717a";
|
|
2202
|
+
var BOX_STROKE = "#52525b";
|
|
2203
|
+
var LABEL_FILL3 = "#3f3f46";
|
|
2204
|
+
var SPINE_W = 2.5;
|
|
2205
|
+
var SPINE_OP = 0.85;
|
|
2206
|
+
var BONE_W = 2;
|
|
2207
|
+
var BONE_OP = 0.8;
|
|
2208
|
+
var TWIG_W = 1.5;
|
|
2209
|
+
var TWIG_OP = 0.75;
|
|
2210
|
+
var SUB_W = 1.2;
|
|
2211
|
+
var SUB_OP = 0.7;
|
|
2212
|
+
var round4 = (n) => Math.round(n * 100) / 100;
|
|
2213
|
+
function arrowHead2(tipX, tipY, ux, uy, opacity) {
|
|
2214
|
+
const LEN = 9;
|
|
2215
|
+
const HALF_W = 4.5;
|
|
2216
|
+
const bx = tipX - ux * LEN;
|
|
2217
|
+
const by = tipY - uy * LEN;
|
|
2218
|
+
const px = -uy;
|
|
2219
|
+
const py = ux;
|
|
2220
|
+
const points = [
|
|
2221
|
+
`${round4(tipX)},${round4(tipY)}`,
|
|
2222
|
+
`${round4(bx + px * HALF_W)},${round4(by + py * HALF_W)}`,
|
|
2223
|
+
`${round4(bx - px * HALF_W)},${round4(by - py * HALF_W)}`
|
|
2224
|
+
].join(" ");
|
|
2225
|
+
return `<polygon points="${points}" fill="${EDGE_INK4}" fill-opacity="${opacity}"/>`;
|
|
2226
|
+
}
|
|
2227
|
+
function lineEl(x1, y1, x2, y2, w, op) {
|
|
2228
|
+
return `<line x1="${round4(x1)}" y1="${round4(y1)}" x2="${round4(x2)}" y2="${round4(y2)}" stroke="${EDGE_INK4}" stroke-width="${w}" stroke-opacity="${op}"/>`;
|
|
2229
|
+
}
|
|
2230
|
+
function fishboneSvg(input, opts = {}) {
|
|
2231
|
+
validateIds(input);
|
|
2232
|
+
const fontSize = opts.fontSize ?? 12;
|
|
2233
|
+
const { ascent, lineH, twigGap, sV, spineClear, boneClear, subGap } = verticalMetrics(fontSize);
|
|
2234
|
+
const labels = opts.labels ?? FISHBONE_LABELS_EN;
|
|
2235
|
+
const arrows = opts.arrowheads !== false;
|
|
2236
|
+
const bones = input.categories.map((category, idx) => {
|
|
2237
|
+
let cursor = spineClear;
|
|
2238
|
+
const bands = category.causes.map((cause) => {
|
|
2239
|
+
const lines = wrapLabelBalanced(clampLabel(cause.label, opts.maxLabelChars));
|
|
2240
|
+
const labelW = Math.max(...lines.map((l) => estimateTextWidth(l, fontSize)));
|
|
2241
|
+
const blockH = lines.length * lineH;
|
|
2242
|
+
const offset = cursor;
|
|
2243
|
+
const bx = -offset / TAN60;
|
|
2244
|
+
const subs = [];
|
|
2245
|
+
for (const sub of cause.subCauses) {
|
|
2246
|
+
const line = wrapLabel(clampLabel(sub.label, opts.maxLabelChars), SUB_PER_LINE, 1)[0];
|
|
2247
|
+
const w2 = estimateTextWidth(line, fontSize);
|
|
2248
|
+
const prev = subs.length > 0 ? subs[subs.length - 1] : null;
|
|
2249
|
+
const sx = prev === null ? bx - (sV + lineH) / TAN60 - 10 - w2 / 2 : prev.sx - (prev.w + w2) * 0.6 - subGap;
|
|
2250
|
+
subs.push({ sub, line, w: w2, sx, outerX: sx - sV / TAN60 });
|
|
2251
|
+
}
|
|
2252
|
+
const last = subs.length > 0 ? subs[subs.length - 1] : null;
|
|
2253
|
+
const subSpan = last === null ? 0 : bx - (last.outerX - last.w / 2);
|
|
2254
|
+
const twigLen = last === null ? labelW + boneClear : Math.max(labelW + boneClear, subSpan + boneClear, labelW + (bx - last.outerX) + 10);
|
|
2255
|
+
const bandH = Math.max(blockH + twigGap, subs.length > 0 ? sV + lineH + twigGap : 0) + ROW_GAP;
|
|
2256
|
+
cursor += bandH;
|
|
2257
|
+
return { cause, lines, labelW, blockH, offset, bandH, bx, freeEnd: bx - twigLen, subs };
|
|
2258
|
+
});
|
|
2259
|
+
const B = cursor + 16;
|
|
2260
|
+
const catLines = wrapLabelBalanced(clampLabel(category.label, opts.maxLabelChars));
|
|
2261
|
+
const boxW = Math.max(...catLines.map((l) => estimateTextWidth(l, fontSize))) + CAT_PAD_X * 2;
|
|
2262
|
+
const boxH = catLines.length * lineH + CAT_PAD_Y * 2;
|
|
2263
|
+
const tipX = -B / TAN60;
|
|
2264
|
+
return {
|
|
2265
|
+
category,
|
|
2266
|
+
up: idx % 2 === 0,
|
|
2267
|
+
bands,
|
|
2268
|
+
B,
|
|
2269
|
+
catLines,
|
|
2270
|
+
boxW,
|
|
2271
|
+
boxH,
|
|
2272
|
+
// Left extent: the category box's left edge or the deepest twig free end —
|
|
2273
|
+
// every label sits AT or right of its twig's free end, every sub label sits
|
|
2274
|
+
// right of the free end too (twigLen ≥ subSpan + boneClear).
|
|
2275
|
+
relL: Math.max(B / TAN60 + boxW / 2, ...bands.map((b) => -b.freeEnd)),
|
|
2276
|
+
// Right extent: a wide category box on a short bone may overhang the attach.
|
|
2277
|
+
relR: Math.max(0, boxW / 2 + tipX),
|
|
2278
|
+
ax: 0
|
|
2279
|
+
};
|
|
2280
|
+
});
|
|
2281
|
+
const cursors = { top: 0, bottom: 0 };
|
|
2282
|
+
for (const bone of bones) {
|
|
2283
|
+
const side = bone.up ? "top" : "bottom";
|
|
2284
|
+
bone.ax = cursors[side] - bone.relR;
|
|
2285
|
+
cursors[side] = bone.ax - bone.relL - BONE_GAP;
|
|
2286
|
+
}
|
|
2287
|
+
const effLines = wrapLabelBalanced(clampLabel(input.effectLabel, opts.maxLabelChars));
|
|
2288
|
+
const headW = Math.max(...effLines.map((l) => estimateTextWidth(l, fontSize))) + HEAD_PAD_X * 2;
|
|
2289
|
+
const headH = effLines.length * lineH + HEAD_PAD_Y * 2;
|
|
2290
|
+
const headLeft = bones.length > 0 ? HEAD_GAP : 0;
|
|
2291
|
+
const tailX = bones.length > 0 ? Math.min(...bones.map((b) => b.ax - b.relL)) - TAIL_EXTRA : headLeft - (TAIL_EXTRA + HEAD_GAP);
|
|
2292
|
+
let minY = -headH / 2;
|
|
2293
|
+
let maxY = headH / 2;
|
|
2294
|
+
for (const bone of bones) {
|
|
2295
|
+
const reach = bone.B + CAT_GAP + bone.boxH;
|
|
2296
|
+
if (bone.up) minY = Math.min(minY, -reach);
|
|
2297
|
+
else maxY = Math.max(maxY, reach);
|
|
2298
|
+
}
|
|
2299
|
+
const dx = PADDING4 - tailX;
|
|
2300
|
+
const dy = PADDING4 - minY;
|
|
2301
|
+
let width = headLeft + headW - tailX + PADDING4 * 2;
|
|
2302
|
+
let height = maxY - minY + PADDING4 * 2;
|
|
2303
|
+
const spineY = dy;
|
|
2304
|
+
const centeredYs = (cy, n) => Array.from({ length: n }, (_, i) => cy - (n - 1) * lineH / 2 + i * lineH + fontSize * 0.32);
|
|
2305
|
+
const bandBaselines = (e, n, up) => Array.from(
|
|
2306
|
+
{ length: n },
|
|
2307
|
+
(_, k) => up ? spineY - (e + twigGap + (n - 1 - k) * lineH) : spineY + e + twigGap + ascent + k * lineH
|
|
2308
|
+
);
|
|
2309
|
+
const textBlock = (anchor, x, ys, lines) => `<text text-anchor="${anchor}" font-family="${FONT_FAMILY}" font-size="${fontSize}" fill="${LABEL_FILL3}">` + lines.map((line, i) => `<tspan x="${round4(x)}" y="${round4(ys[i])}">${xmlEscape(line)}</tspan>`).join("") + `</text>`;
|
|
2310
|
+
const parts = [];
|
|
2311
|
+
{
|
|
2312
|
+
const body = [lineEl(tailX + dx, spineY, headLeft + dx, spineY, SPINE_W, SPINE_OP)];
|
|
2313
|
+
if (arrows) body.push(arrowHead2(headLeft + dx, spineY, 1, 0, SPINE_OP));
|
|
2314
|
+
parts.push(`<g data-edge-id="spine"><title>${xmlEscape(labels.ariaLabel)}</title>${body.join("")}</g>`);
|
|
2315
|
+
}
|
|
2316
|
+
for (const bone of bones) {
|
|
2317
|
+
const sgn = bone.up ? -1 : 1;
|
|
2318
|
+
const ax = bone.ax + dx;
|
|
2319
|
+
const tipX = ax - bone.B / TAN60;
|
|
2320
|
+
const body = [lineEl(tipX, spineY + sgn * bone.B, ax, spineY, BONE_W, BONE_OP)];
|
|
2321
|
+
if (arrows) body.push(arrowHead2(ax, spineY, COS60, -sgn * SIN60, BONE_OP));
|
|
2322
|
+
const boxTop = bone.up ? spineY - bone.B - CAT_GAP - bone.boxH : spineY + bone.B + CAT_GAP;
|
|
2323
|
+
body.push(
|
|
2324
|
+
`<rect x="${round4(tipX - bone.boxW / 2)}" y="${round4(boxTop)}" width="${round4(bone.boxW)}" height="${round4(bone.boxH)}" rx="2" fill="transparent" stroke="${BOX_STROKE}" stroke-width="1.5"/>`
|
|
2325
|
+
);
|
|
2326
|
+
body.push(textBlock("middle", tipX, centeredYs(boxTop + bone.boxH / 2, bone.catLines.length), bone.catLines));
|
|
2327
|
+
parts.push(
|
|
2328
|
+
`<g data-node-id="b${bone.category.id}"><title>${xmlEscape(bone.category.title ?? bone.category.label)}</title>${body.join("")}</g>`
|
|
2329
|
+
);
|
|
2330
|
+
for (const band of bone.bands) {
|
|
2331
|
+
const ty = spineY + sgn * band.offset;
|
|
2332
|
+
const bx = ax + band.bx;
|
|
2333
|
+
const freeEnd = ax + band.freeEnd;
|
|
2334
|
+
const cbody = [lineEl(freeEnd, ty, bx, ty, TWIG_W, TWIG_OP)];
|
|
2335
|
+
if (arrows) cbody.push(arrowHead2(bx, ty, 1, 0, TWIG_OP));
|
|
2336
|
+
cbody.push(textBlock("start", freeEnd + 2, bandBaselines(band.offset, band.lines.length, bone.up), band.lines));
|
|
2337
|
+
parts.push(
|
|
2338
|
+
`<g data-node-id="c${band.cause.id}"><title>${xmlEscape(band.cause.title ?? band.cause.label)}</title>${cbody.join("")}</g>`
|
|
2339
|
+
);
|
|
2340
|
+
for (const s of band.subs) {
|
|
2341
|
+
const sx = ax + s.sx;
|
|
2342
|
+
const ox = ax + s.outerX;
|
|
2343
|
+
const sbody = [lineEl(ox, spineY + sgn * (band.offset + sV), sx, ty, SUB_W, SUB_OP)];
|
|
2344
|
+
if (arrows) sbody.push(arrowHead2(sx, ty, COS60, -sgn * SIN60, SUB_OP));
|
|
2345
|
+
const baseline = bone.up ? spineY - (band.offset + sV + twigGap) : spineY + band.offset + sV + twigGap + ascent;
|
|
2346
|
+
sbody.push(textBlock("middle", ox, [baseline], [s.line]));
|
|
2347
|
+
parts.push(`<g data-node-id="s${s.sub.id}"><title>${xmlEscape(s.sub.label)}</title>${sbody.join("")}</g>`);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
{
|
|
2352
|
+
const x = headLeft + dx;
|
|
2353
|
+
parts.push(
|
|
2354
|
+
`<g data-node-id="head"><title>${xmlEscape(input.effectLabel)}</title><rect x="${round4(x)}" y="${round4(spineY - headH / 2)}" width="${round4(headW)}" height="${round4(headH)}" rx="2" fill="transparent" stroke="${BOX_STROKE}" stroke-width="2"/>` + textBlock("middle", x + headW / 2, centeredYs(spineY, effLines.length), effLines) + `</g>`
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
const anySubs = input.categories.some((c) => c.causes.some((k) => k.subCauses.length > 0));
|
|
2358
|
+
if (opts.legend !== false && anySubs) {
|
|
2359
|
+
const entries = [
|
|
2360
|
+
{
|
|
2361
|
+
swatch: (x, y) => lineEl(x, y, x + LEGEND_SWATCH_W, y, TWIG_W, TWIG_OP),
|
|
2362
|
+
label: labels.cause
|
|
2363
|
+
},
|
|
2364
|
+
{
|
|
2365
|
+
swatch: (x, y) => lineEl(x, y + 5, x + LEGEND_SWATCH_W, y - 5, SUB_W, SUB_OP),
|
|
2366
|
+
label: labels.subCause
|
|
2367
|
+
}
|
|
2368
|
+
];
|
|
2369
|
+
const block = legendBlock(entries, height);
|
|
2370
|
+
parts.push(block.svg);
|
|
2371
|
+
width = Math.max(width, block.width);
|
|
2372
|
+
height = block.height;
|
|
1403
2373
|
}
|
|
1404
2374
|
const w = Math.ceil(width);
|
|
1405
2375
|
const h = Math.ceil(height);
|
|
1406
2376
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${xmlEscape(labels.ariaLabel)}">` + parts.join("") + `</svg>`;
|
|
1407
2377
|
}
|
|
1408
2378
|
|
|
2379
|
+
// src/pedigree/types.ts
|
|
2380
|
+
var LIFE_STATUSES = ["alive", "stillbirth"];
|
|
2381
|
+
|
|
2382
|
+
// src/pedigree/labels.ts
|
|
2383
|
+
var PEDIGREE_TITLE_LABELS_EN = {
|
|
2384
|
+
affected: "affected",
|
|
2385
|
+
carrier: "carrier",
|
|
2386
|
+
deceased: "deceased",
|
|
2387
|
+
proband: "proband",
|
|
2388
|
+
consultand: "consultand",
|
|
2389
|
+
consanguineous: "consanguineous mating",
|
|
2390
|
+
mating: "mating",
|
|
2391
|
+
sibship: "sibship",
|
|
2392
|
+
twins: {
|
|
2393
|
+
mz: "monozygotic twins",
|
|
2394
|
+
dz: "dizygotic twins",
|
|
2395
|
+
unknown: "twins, zygosity unknown"
|
|
2396
|
+
},
|
|
2397
|
+
stillbirth: "stillbirth",
|
|
2398
|
+
generation: "generation"
|
|
2399
|
+
};
|
|
2400
|
+
var PEDIGREE_SVG_LABELS_EN = {
|
|
2401
|
+
shapes: {
|
|
2402
|
+
square: "Male",
|
|
2403
|
+
circle: "Female",
|
|
2404
|
+
diamond: "Sex unknown"
|
|
2405
|
+
},
|
|
2406
|
+
unaffected: "Unaffected",
|
|
2407
|
+
carrier: "Carrier",
|
|
2408
|
+
deceased: "Deceased",
|
|
2409
|
+
proband: "Proband",
|
|
2410
|
+
consultand: "Consultand",
|
|
2411
|
+
consanguineous: "Consanguineous mating",
|
|
2412
|
+
twins: {
|
|
2413
|
+
mz: "Monozygotic twins",
|
|
2414
|
+
dz: "Dizygotic twins",
|
|
2415
|
+
unknown: "Twins (zygosity unknown)"
|
|
2416
|
+
},
|
|
2417
|
+
stillbirth: "Stillbirth",
|
|
2418
|
+
isolated: "No recorded relatives",
|
|
2419
|
+
ariaLabel: "Pedigree chart"
|
|
2420
|
+
};
|
|
2421
|
+
|
|
2422
|
+
// src/pedigree/validate.ts
|
|
2423
|
+
var PedigreeValidationError = class extends Error {
|
|
2424
|
+
issues;
|
|
2425
|
+
constructor(issues) {
|
|
2426
|
+
super(`invalid pedigree: ${issues.map((i) => i.message).join("; ")}`);
|
|
2427
|
+
this.name = "PedigreeValidationError";
|
|
2428
|
+
this.issues = issues;
|
|
2429
|
+
}
|
|
2430
|
+
};
|
|
2431
|
+
var MAX_CONDITIONS_PER_INDIVIDUAL = 4;
|
|
2432
|
+
function sortIssues2(issues) {
|
|
2433
|
+
const unique = /* @__PURE__ */ new Map();
|
|
2434
|
+
for (const issue of issues) unique.set(`${issue.code} ${issue.message}`, issue);
|
|
2435
|
+
return [...unique.values()].sort(
|
|
2436
|
+
(a, b) => a.code !== b.code ? a.code < b.code ? -1 : 1 : a.message < b.message ? -1 : a.message > b.message ? 1 : 0
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
function pedigreeIssues(input) {
|
|
2440
|
+
if (input.individuals.length === 0 && input.matings.length === 0 && input.sibships.length === 0) return [];
|
|
2441
|
+
const issues = [];
|
|
2442
|
+
const push = (code, message) => {
|
|
2443
|
+
issues.push({ code, message });
|
|
2444
|
+
};
|
|
2445
|
+
const individualById = /* @__PURE__ */ new Map();
|
|
2446
|
+
const dupIndividualIds = /* @__PURE__ */ new Set();
|
|
2447
|
+
for (const ind of input.individuals) {
|
|
2448
|
+
if (individualById.has(ind.id)) dupIndividualIds.add(ind.id);
|
|
2449
|
+
else individualById.set(ind.id, ind);
|
|
2450
|
+
}
|
|
2451
|
+
for (const id of [...dupIndividualIds].sort((a, b) => a - b)) {
|
|
2452
|
+
push("duplicate-id", `duplicate individual id ${id}`);
|
|
2453
|
+
}
|
|
2454
|
+
const matingById = /* @__PURE__ */ new Map();
|
|
2455
|
+
const dupMatingIds = /* @__PURE__ */ new Set();
|
|
2456
|
+
for (const m of input.matings) {
|
|
2457
|
+
if (matingById.has(m.id)) dupMatingIds.add(m.id);
|
|
2458
|
+
else matingById.set(m.id, m);
|
|
2459
|
+
}
|
|
2460
|
+
for (const id of [...dupMatingIds].sort((a, b) => a - b)) {
|
|
2461
|
+
push("duplicate-id", `duplicate mating id ${id}`);
|
|
2462
|
+
}
|
|
2463
|
+
const sibshipById = /* @__PURE__ */ new Map();
|
|
2464
|
+
const dupSibshipIds = /* @__PURE__ */ new Set();
|
|
2465
|
+
for (const s of input.sibships) {
|
|
2466
|
+
if (sibshipById.has(s.id)) dupSibshipIds.add(s.id);
|
|
2467
|
+
else sibshipById.set(s.id, s);
|
|
2468
|
+
}
|
|
2469
|
+
for (const id of [...dupSibshipIds].sort((a, b) => a - b)) {
|
|
2470
|
+
push("duplicate-id", `duplicate sibship id ${id}`);
|
|
2471
|
+
}
|
|
2472
|
+
if (issues.length > 0) {
|
|
2473
|
+
return sortIssues2(issues);
|
|
2474
|
+
}
|
|
2475
|
+
const individuals = [...individualById.values()].sort((a, b) => a.id - b.id);
|
|
2476
|
+
const matings = [...matingById.values()].sort((a, b) => a.id - b.id);
|
|
2477
|
+
const sibships = [...sibshipById.values()].sort((a, b) => a.id - b.id);
|
|
2478
|
+
const conditionIds = new Set(input.conditions.map((c) => c.id));
|
|
2479
|
+
for (const m of matings) {
|
|
2480
|
+
if (!individualById.has(m.partnerAId)) {
|
|
2481
|
+
push("unknown-partner", `mating ${m.id} references unknown individual ${m.partnerAId}`);
|
|
2482
|
+
}
|
|
2483
|
+
if (!individualById.has(m.partnerBId)) {
|
|
2484
|
+
push("unknown-partner", `mating ${m.id} references unknown individual ${m.partnerBId}`);
|
|
2485
|
+
}
|
|
2486
|
+
if (m.partnerAId === m.partnerBId) {
|
|
2487
|
+
push("self-mating", `mating ${m.id} mates individual ${m.partnerAId} with itself`);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
for (const s of sibships) {
|
|
2491
|
+
if (!matingById.has(s.matingId)) {
|
|
2492
|
+
push("unknown-sibship-mating", `sibship ${s.id} references unknown mating ${s.matingId}`);
|
|
2493
|
+
}
|
|
2494
|
+
for (const childId of s.childIds) {
|
|
2495
|
+
if (!individualById.has(childId)) {
|
|
2496
|
+
push("unknown-child", `sibship ${s.id} references unknown child ${childId}`);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
const childSet = new Set(s.childIds);
|
|
2500
|
+
const offending = /* @__PURE__ */ new Set();
|
|
2501
|
+
for (const tg of s.twinGroups) {
|
|
2502
|
+
for (const memberId of tg.childIds) {
|
|
2503
|
+
if (!childSet.has(memberId)) offending.add(memberId);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
for (const memberId of [...offending].sort((a, b) => a - b)) {
|
|
2507
|
+
push("twin-not-in-sibship", `twin ${memberId} is not a child of sibship ${s.id}`);
|
|
2508
|
+
}
|
|
2509
|
+
for (const tg of s.twinGroups) {
|
|
2510
|
+
if (tg.childIds.length < 2) {
|
|
2511
|
+
const [only] = tg.childIds;
|
|
2512
|
+
push(
|
|
2513
|
+
"twin-group-too-small",
|
|
2514
|
+
`twin group in sibship ${s.id} has only 1 member (${only ?? "none"}) \u2014 at least 2 required`
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
for (const tg of s.twinGroups) {
|
|
2519
|
+
if (tg.childIds.length < 2) continue;
|
|
2520
|
+
const memberSet = new Set(tg.childIds);
|
|
2521
|
+
const positions = s.childIds.map((cid, pos) => memberSet.has(cid) ? pos : -1).filter((p) => p >= 0);
|
|
2522
|
+
if (positions.length === 0) continue;
|
|
2523
|
+
const first = positions[0];
|
|
2524
|
+
const last = positions[positions.length - 1];
|
|
2525
|
+
if (last - first !== positions.length - 1) {
|
|
2526
|
+
const memberList = [...tg.childIds].sort((a, b) => a - b).join(", ");
|
|
2527
|
+
push(
|
|
2528
|
+
"twin-group-not-contiguous",
|
|
2529
|
+
`twin group [${memberList}] in sibship ${s.id} is not a contiguous run in childIds`
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
const sibshipsOfChild = /* @__PURE__ */ new Map();
|
|
2535
|
+
for (const s of sibships) {
|
|
2536
|
+
for (const childId of s.childIds) {
|
|
2537
|
+
const arr = sibshipsOfChild.get(childId) ?? [];
|
|
2538
|
+
arr.push(s.id);
|
|
2539
|
+
sibshipsOfChild.set(childId, arr);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
for (const [childId, sibIds] of [...sibshipsOfChild.entries()].sort((a, b) => a[0] - b[0])) {
|
|
2543
|
+
if (sibIds.length > 1) {
|
|
2544
|
+
push(
|
|
2545
|
+
"child-of-two-sibships",
|
|
2546
|
+
`individual ${childId} is a child of sibships ${[...sibIds].sort((a, b) => a - b).join(", ")}`
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
for (const ind of individuals) {
|
|
2551
|
+
if (!Number.isInteger(ind.generation)) {
|
|
2552
|
+
push("generation-not-integer", `individual ${ind.id} has non-integer generation ${ind.generation}`);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
for (const ind of individuals) {
|
|
2556
|
+
for (const conditionId of ind.affectedBy) {
|
|
2557
|
+
if (!conditionIds.has(conditionId)) {
|
|
2558
|
+
push("unknown-condition", `individual ${ind.id} is affected by unknown condition ${conditionId}`);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
if (ind.affectedBy.length > MAX_CONDITIONS_PER_INDIVIDUAL) {
|
|
2562
|
+
push(
|
|
2563
|
+
"too-many-conditions",
|
|
2564
|
+
`individual ${ind.id} has ${ind.affectedBy.length} conditions \u2014 at most ${MAX_CONDITIONS_PER_INDIVIDUAL} can be drawn`
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
const GRAPH_BLOCKING = /* @__PURE__ */ new Set([
|
|
2569
|
+
"duplicate-id",
|
|
2570
|
+
"unknown-partner",
|
|
2571
|
+
"unknown-sibship-mating",
|
|
2572
|
+
"unknown-child"
|
|
2573
|
+
]);
|
|
2574
|
+
if (!issues.some((i) => GRAPH_BLOCKING.has(i.code))) {
|
|
2575
|
+
for (const s of sibships) {
|
|
2576
|
+
const mating = matingById.get(s.matingId);
|
|
2577
|
+
if (mating === void 0) continue;
|
|
2578
|
+
const a = individualById.get(mating.partnerAId);
|
|
2579
|
+
const b = individualById.get(mating.partnerBId);
|
|
2580
|
+
const parentGenerations = [a, b].filter((p) => p !== void 0 && Number.isInteger(p.generation)).map((p) => p.generation);
|
|
2581
|
+
if (parentGenerations.length === 0) continue;
|
|
2582
|
+
const parentRow = Math.max(...parentGenerations);
|
|
2583
|
+
for (const childId of s.childIds) {
|
|
2584
|
+
const child = individualById.get(childId);
|
|
2585
|
+
if (child === void 0 || !Number.isInteger(child.generation)) continue;
|
|
2586
|
+
if (child.generation <= parentRow) {
|
|
2587
|
+
push(
|
|
2588
|
+
"generation-inversion",
|
|
2589
|
+
`child ${childId} (generation ${child.generation}) is not below its parents (generation ${parentRow}) in sibship ${s.id}`
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
return sortIssues2(issues);
|
|
2596
|
+
}
|
|
2597
|
+
function validatePedigree(input) {
|
|
2598
|
+
const issues = pedigreeIssues(input);
|
|
2599
|
+
if (issues.length > 0) throw new PedigreeValidationError(issues);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// src/pedigree/layout.ts
|
|
2603
|
+
var PED_GLYPH = 40;
|
|
2604
|
+
var PED_LABEL_FONT = 12;
|
|
2605
|
+
var PED_LABEL_LINE_H = 14;
|
|
2606
|
+
var PED_ADDRESS_FONT = 10;
|
|
2607
|
+
var PED_LABEL_GAP = 6;
|
|
2608
|
+
var PADDING5 = 32;
|
|
2609
|
+
var GUTTER = 36;
|
|
2610
|
+
var H_GAP2 = 28;
|
|
2611
|
+
var MATING_RUN = 22;
|
|
2612
|
+
var CORRIDOR2 = 44;
|
|
2613
|
+
var TWIN_JUNCTION_DROP = 12;
|
|
2614
|
+
var BRIDGE_DIP_GAP = 10;
|
|
2615
|
+
var BRIDGE_DIP_STEP = 7;
|
|
2616
|
+
var BRIDGE_STUB_X_STEP = 3;
|
|
2617
|
+
var PED_MATING_ID_BASE = 1e6;
|
|
2618
|
+
var PED_DESCENT_ID_BASE = 2e6;
|
|
2619
|
+
var PED_SIBBAR_ID_BASE = 3e6;
|
|
2620
|
+
var PED_RISER_ID_BASE = 4e6;
|
|
2621
|
+
var PED_TWINBAR_ID_BASE = 5e6;
|
|
2622
|
+
var round5 = (n) => Math.round(n * 100) / 100;
|
|
2623
|
+
function shapeForSex2(sex) {
|
|
2624
|
+
if (sex === "male") return "square";
|
|
2625
|
+
if (sex === "female") return "circle";
|
|
2626
|
+
return "diamond";
|
|
2627
|
+
}
|
|
2628
|
+
var PED_CONDITION_FILLS = ["#52525b", "#a1a1aa", "#3f3f46", "#71717a"];
|
|
2629
|
+
function wrapPedigreeLabel(displayLabel) {
|
|
2630
|
+
if (displayLabel === "") return [];
|
|
2631
|
+
return wrapLabelBalanced(displayLabel, 2);
|
|
2632
|
+
}
|
|
2633
|
+
function individualTitle(ind, address, conditionLabelById, titleLabels) {
|
|
2634
|
+
if (ind.title !== void 0) return ind.title;
|
|
2635
|
+
const parts = [`${address} \xB7 ${ind.label}`];
|
|
2636
|
+
if (ind.affectedBy.length > 0) {
|
|
2637
|
+
const names = ind.affectedBy.map((id) => conditionLabelById.get(id) ?? String(id));
|
|
2638
|
+
parts.push(`${titleLabels.affected}: ${names.join(", ")}`);
|
|
2639
|
+
}
|
|
2640
|
+
if (ind.carrier && ind.affectedBy.length === 0) parts.push(titleLabels.carrier);
|
|
2641
|
+
if (ind.deceased) parts.push(titleLabels.deceased);
|
|
2642
|
+
if (ind.role === "proband") parts.push(titleLabels.proband);
|
|
2643
|
+
else if (ind.role === "consultand") parts.push(titleLabels.consultand);
|
|
2644
|
+
if (ind.lifeStatus === "stillbirth") parts.push(titleLabels.stillbirth);
|
|
2645
|
+
return parts.join(" \xB7 ");
|
|
2646
|
+
}
|
|
2647
|
+
function computePedigreeLayout(input, opts = {}) {
|
|
2648
|
+
if (input.individuals.length === 0 && input.matings.length === 0 && input.sibships.length === 0) {
|
|
2649
|
+
return {
|
|
2650
|
+
width: PADDING5 * 2,
|
|
2651
|
+
height: PADDING5 * 2,
|
|
2652
|
+
nodes: [],
|
|
2653
|
+
elements: [],
|
|
2654
|
+
generations: [],
|
|
2655
|
+
conditionFills: [],
|
|
2656
|
+
twinZygositiesUsed: [],
|
|
2657
|
+
unknownTwinJunctions: [],
|
|
2658
|
+
isolatedIndividualIds: []
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
validatePedigree(input);
|
|
2662
|
+
const titleLabels = opts.titleLabels ?? PEDIGREE_TITLE_LABELS_EN;
|
|
2663
|
+
const individuals = [...input.individuals].sort((a, b) => a.id - b.id);
|
|
2664
|
+
const individualById = new Map(individuals.map((i) => [i.id, i]));
|
|
2665
|
+
const matings = [...input.matings].sort((a, b) => a.id - b.id);
|
|
2666
|
+
const sibships = [...input.sibships].sort((a, b) => a.id - b.id);
|
|
2667
|
+
const conditionLabelById = new Map(input.conditions.map((c) => [c.id, c.label]));
|
|
2668
|
+
const partnersOf = (m) => m.partnerAId <= m.partnerBId ? [m.partnerAId, m.partnerBId] : [m.partnerBId, m.partnerAId];
|
|
2669
|
+
const distinctGenerations = [...new Set(individuals.map((i) => i.generation))].sort((a, b) => a - b);
|
|
2670
|
+
const rowOfGeneration = /* @__PURE__ */ new Map();
|
|
2671
|
+
distinctGenerations.forEach((g, i) => rowOfGeneration.set(g, i));
|
|
2672
|
+
const rowCount = distinctGenerations.length;
|
|
2673
|
+
const labelLinesOf = /* @__PURE__ */ new Map();
|
|
2674
|
+
for (const ind of individuals) {
|
|
2675
|
+
labelLinesOf.set(ind.id, wrapPedigreeLabel(clampLabel(ind.label, opts.maxLabelChars)));
|
|
2676
|
+
}
|
|
2677
|
+
const nodeHalfWidth = (ind) => {
|
|
2678
|
+
const lines = labelLinesOf.get(ind.id) ?? [];
|
|
2679
|
+
const labelW = lines.reduce((m, l) => Math.max(m, estimateTextWidth(l, PED_LABEL_FONT)), 0);
|
|
2680
|
+
return Math.max(PED_GLYPH, labelW) / 2;
|
|
2681
|
+
};
|
|
2682
|
+
const childToSibship = /* @__PURE__ */ new Map();
|
|
2683
|
+
for (const s of sibships) for (const c of s.childIds) childToSibship.set(c, s);
|
|
2684
|
+
const sibshipByMating = /* @__PURE__ */ new Map();
|
|
2685
|
+
for (const s of sibships) {
|
|
2686
|
+
const arr = sibshipByMating.get(s.matingId) ?? [];
|
|
2687
|
+
arr.push(s);
|
|
2688
|
+
sibshipByMating.set(s.matingId, arr);
|
|
2689
|
+
}
|
|
2690
|
+
const matingsOfIndividual = /* @__PURE__ */ new Map();
|
|
2691
|
+
for (const m of matings) {
|
|
2692
|
+
for (const p of partnersOf(m)) {
|
|
2693
|
+
const arr = matingsOfIndividual.get(p) ?? [];
|
|
2694
|
+
arr.push(m);
|
|
2695
|
+
matingsOfIndividual.set(p, arr);
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
const placedById = /* @__PURE__ */ new Map();
|
|
2699
|
+
let cursorX = PADDING5 + GUTTER;
|
|
2700
|
+
const placeLeaf = (ind, leftEdge) => {
|
|
2701
|
+
const half = nodeHalfWidth(ind);
|
|
2702
|
+
const cx = leftEdge + half;
|
|
2703
|
+
const placed = { ind, cx, spanL: half, spanR: half };
|
|
2704
|
+
placedById.set(ind.id, placed);
|
|
2705
|
+
return placed;
|
|
2706
|
+
};
|
|
2707
|
+
const placedMatings = /* @__PURE__ */ new Set();
|
|
2708
|
+
const matingCount = (id) => (matingsOfIndividual.get(id) ?? []).length;
|
|
2709
|
+
const hubCursor = /* @__PURE__ */ new Map();
|
|
2710
|
+
const hubSpouseSeq = /* @__PURE__ */ new Map();
|
|
2711
|
+
const packMating = (mating, leftEdge) => {
|
|
2712
|
+
placedMatings.add(mating.id);
|
|
2713
|
+
const [aId, bId] = partnersOf(mating);
|
|
2714
|
+
const a = individualById.get(aId);
|
|
2715
|
+
const b = individualById.get(bId);
|
|
2716
|
+
const aHalf = nodeHalfWidth(a);
|
|
2717
|
+
const bHalf = nodeHalfWidth(b);
|
|
2718
|
+
const halfSep = Math.max(aHalf, bHalf) + MATING_RUN;
|
|
2719
|
+
const aPlaced = placedById.get(aId);
|
|
2720
|
+
const bPlaced = placedById.get(bId);
|
|
2721
|
+
const hubId = aPlaced !== void 0 ? aId : bPlaced !== void 0 ? bId : matingCount(aId) >= 2 ? aId : matingCount(bId) >= 2 ? bId : null;
|
|
2722
|
+
const blockLeft = hubId !== null && hubCursor.has(hubId) ? hubCursor.get(hubId) : leftEdge;
|
|
2723
|
+
const sibs = (sibshipByMating.get(mating.id) ?? []).sort((s1, s2) => s1.id - s2.id);
|
|
2724
|
+
const childIds = sibs.flatMap((s) => s.childIds);
|
|
2725
|
+
let childCursor = blockLeft;
|
|
2726
|
+
let childLeft = Number.POSITIVE_INFINITY;
|
|
2727
|
+
let childrenRight = blockLeft;
|
|
2728
|
+
let placedAnyChild = false;
|
|
2729
|
+
for (const childId of childIds) {
|
|
2730
|
+
const child = individualById.get(childId);
|
|
2731
|
+
if (child === void 0 || placedById.has(childId)) continue;
|
|
2732
|
+
const downMating = (matingsOfIndividual.get(childId) ?? []).find((m) => {
|
|
2733
|
+
if (placedMatings.has(m.id)) return false;
|
|
2734
|
+
const [pa, pb] = partnersOf(m);
|
|
2735
|
+
const otherId = pa === childId ? pb : pa;
|
|
2736
|
+
const other = individualById.get(otherId);
|
|
2737
|
+
return other === void 0 || child.generation <= other.generation;
|
|
2738
|
+
});
|
|
2739
|
+
if (downMating !== void 0) {
|
|
2740
|
+
const right = packMating(downMating, childCursor);
|
|
2741
|
+
childLeft = Math.min(childLeft, placedById.get(childId).cx - nodeHalfWidth(child));
|
|
2742
|
+
childrenRight = right;
|
|
2743
|
+
childCursor = right + H_GAP2;
|
|
2744
|
+
} else {
|
|
2745
|
+
const placed = placeLeaf(child, childCursor);
|
|
2746
|
+
childLeft = Math.min(childLeft, placed.cx - placed.spanL);
|
|
2747
|
+
childrenRight = placed.cx + placed.spanR;
|
|
2748
|
+
childCursor = childrenRight + H_GAP2;
|
|
2749
|
+
}
|
|
2750
|
+
placedAnyChild = true;
|
|
2751
|
+
}
|
|
2752
|
+
const bandMid = placedAnyChild ? (childLeft + childrenRight) / 2 : blockLeft + halfSep;
|
|
2753
|
+
if (hubId === null) {
|
|
2754
|
+
const pairMid = placedAnyChild ? bandMid : blockLeft + Math.max(aHalf, bHalf) + halfSep;
|
|
2755
|
+
placedById.set(aId, { ind: a, cx: pairMid - halfSep, spanL: aHalf, spanR: aHalf });
|
|
2756
|
+
placedById.set(bId, { ind: b, cx: pairMid + halfSep, spanL: bHalf, spanR: bHalf });
|
|
2757
|
+
} else {
|
|
2758
|
+
const spouseId = hubId === aId ? bId : aId;
|
|
2759
|
+
const spouse = individualById.get(spouseId);
|
|
2760
|
+
const spouseHalf = nodeHalfWidth(spouse);
|
|
2761
|
+
const hubHalf = nodeHalfWidth(individualById.get(hubId));
|
|
2762
|
+
const seq = hubSpouseSeq.get(hubId) ?? 0;
|
|
2763
|
+
const hubExisting = placedById.get(hubId);
|
|
2764
|
+
const minSpouseCx = blockLeft + hubHalf + halfSep;
|
|
2765
|
+
let spouseCx = placedAnyChild ? Math.max(bandMid, minSpouseCx) : minSpouseCx + spouseHalf;
|
|
2766
|
+
if (hubExisting === void 0) {
|
|
2767
|
+
placedById.set(hubId, { ind: individualById.get(hubId), cx: spouseCx - halfSep, spanL: hubHalf, spanR: hubHalf });
|
|
2768
|
+
} else {
|
|
2769
|
+
spouseCx = Math.max(spouseCx, hubExisting.cx + halfSep);
|
|
2770
|
+
}
|
|
2771
|
+
placedById.set(spouseId, { ind: spouse, cx: spouseCx, spanL: spouseHalf, spanR: spouseHalf });
|
|
2772
|
+
hubSpouseSeq.set(hubId, seq + 1);
|
|
2773
|
+
}
|
|
2774
|
+
const blockRight = Math.max(
|
|
2775
|
+
childrenRight,
|
|
2776
|
+
placedById.get(aId).cx + nodeHalfWidth(a),
|
|
2777
|
+
placedById.get(bId).cx + nodeHalfWidth(b)
|
|
2778
|
+
);
|
|
2779
|
+
if (hubId !== null) hubCursor.set(hubId, blockRight + H_GAP2);
|
|
2780
|
+
return blockRight;
|
|
2781
|
+
};
|
|
2782
|
+
const generationOfMating = (m) => {
|
|
2783
|
+
const [aId, bId] = partnersOf(m);
|
|
2784
|
+
const a = individualById.get(aId);
|
|
2785
|
+
const b = individualById.get(bId);
|
|
2786
|
+
const gens = [a, b].filter((p) => p !== void 0).map((p) => p.generation);
|
|
2787
|
+
return gens.length > 0 ? Math.min(...gens) : 0;
|
|
2788
|
+
};
|
|
2789
|
+
const rootMatings = matings.filter((m) => {
|
|
2790
|
+
const [aId, bId] = partnersOf(m);
|
|
2791
|
+
return !childToSibship.has(aId) && !childToSibship.has(bId);
|
|
2792
|
+
}).sort((m1, m2) => generationOfMating(m1) - generationOfMating(m2) || m1.id - m2.id);
|
|
2793
|
+
for (const m of rootMatings) {
|
|
2794
|
+
if (placedMatings.has(m.id)) continue;
|
|
2795
|
+
const right = packMating(m, cursorX);
|
|
2796
|
+
cursorX = right + H_GAP2 * 2;
|
|
2797
|
+
}
|
|
2798
|
+
for (const m of matings) {
|
|
2799
|
+
if (placedMatings.has(m.id)) continue;
|
|
2800
|
+
const right = packMating(m, cursorX);
|
|
2801
|
+
cursorX = right + H_GAP2 * 2;
|
|
2802
|
+
}
|
|
2803
|
+
const inAnyMating = /* @__PURE__ */ new Set();
|
|
2804
|
+
for (const m of matings) for (const p of partnersOf(m)) inAnyMating.add(p);
|
|
2805
|
+
const isolatedIndividualIds = individuals.filter((i) => !inAnyMating.has(i.id) && !childToSibship.has(i.id)).map((i) => i.id);
|
|
2806
|
+
for (const id of isolatedIndividualIds) {
|
|
2807
|
+
const ind = individualById.get(id);
|
|
2808
|
+
if (placedById.has(id)) continue;
|
|
2809
|
+
placeLeaf(ind, cursorX);
|
|
2810
|
+
cursorX = placedById.get(id).cx + nodeHalfWidth(ind) + H_GAP2;
|
|
2811
|
+
}
|
|
2812
|
+
for (const ind of individuals) {
|
|
2813
|
+
if (placedById.has(ind.id)) continue;
|
|
2814
|
+
placeLeaf(ind, cursorX);
|
|
2815
|
+
cursorX = placedById.get(ind.id).cx + nodeHalfWidth(ind) + H_GAP2;
|
|
2816
|
+
}
|
|
2817
|
+
const matingBridgeSlot = /* @__PURE__ */ new Map();
|
|
2818
|
+
{
|
|
2819
|
+
const dipSlotByRow = /* @__PURE__ */ new Map();
|
|
2820
|
+
for (const m of [...matings].sort((m1, m2) => m1.id - m2.id)) {
|
|
2821
|
+
const [aId, bId] = partnersOf(m);
|
|
2822
|
+
const a = individualById.get(aId);
|
|
2823
|
+
const b = individualById.get(bId);
|
|
2824
|
+
if (a === void 0 || b === void 0) continue;
|
|
2825
|
+
if (a.generation !== b.generation) continue;
|
|
2826
|
+
const row = rowOfGeneration.get(a.generation);
|
|
2827
|
+
const loX = Math.min(placedById.get(aId).cx, placedById.get(bId).cx);
|
|
2828
|
+
const hiX = Math.max(placedById.get(aId).cx, placedById.get(bId).cx);
|
|
2829
|
+
const blocked = individuals.some(
|
|
2830
|
+
(i) => i.id !== aId && i.id !== bId && rowOfGeneration.get(i.generation) === row && placedById.has(i.id) && placedById.get(i.id).cx > loX + 0.01 && placedById.get(i.id).cx < hiX - 0.01
|
|
2831
|
+
);
|
|
2832
|
+
if (!blocked) {
|
|
2833
|
+
matingBridgeSlot.set(m.id, 0);
|
|
2834
|
+
continue;
|
|
2835
|
+
}
|
|
2836
|
+
const slot = (dipSlotByRow.get(row) ?? 0) + 1;
|
|
2837
|
+
dipSlotByRow.set(row, slot);
|
|
2838
|
+
matingBridgeSlot.set(m.id, slot);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
const rowDipBand = new Array(rowCount).fill(0);
|
|
2842
|
+
for (const m of matings) {
|
|
2843
|
+
const slot = matingBridgeSlot.get(m.id) ?? 0;
|
|
2844
|
+
if (slot < 1) continue;
|
|
2845
|
+
const [aId, bId] = partnersOf(m);
|
|
2846
|
+
const a = individualById.get(aId);
|
|
2847
|
+
const b = individualById.get(bId);
|
|
2848
|
+
const gens = [a, b].filter((p) => p !== void 0).map((p) => p.generation);
|
|
2849
|
+
if (gens.length === 0) continue;
|
|
2850
|
+
const row = rowOfGeneration.get(Math.min(...gens));
|
|
2851
|
+
rowDipBand[row] = Math.max(rowDipBand[row], BRIDGE_DIP_GAP + slot * BRIDGE_DIP_STEP + PED_LABEL_GAP);
|
|
2852
|
+
}
|
|
2853
|
+
const rowHeight = new Array(rowCount).fill(0);
|
|
2854
|
+
for (const ind of individuals) {
|
|
2855
|
+
const row = rowOfGeneration.get(ind.generation);
|
|
2856
|
+
const lines = labelLinesOf.get(ind.id) ?? [];
|
|
2857
|
+
const h = PED_GLYPH + PED_LABEL_GAP + rowDipBand[row] + (lines.length + 1) * PED_LABEL_LINE_H;
|
|
2858
|
+
rowHeight[row] = Math.max(rowHeight[row], h);
|
|
2859
|
+
}
|
|
2860
|
+
const rowTop = [PADDING5];
|
|
2861
|
+
for (let r = 0; r < rowCount - 1; r++) {
|
|
2862
|
+
rowTop.push(rowTop[r] + rowHeight[r] + CORRIDOR2);
|
|
2863
|
+
}
|
|
2864
|
+
const glyphCyOfRow = (row) => rowTop[row] + PED_GLYPH / 2;
|
|
2865
|
+
const addressByIndividual = /* @__PURE__ */ new Map();
|
|
2866
|
+
for (let row = 0; row < rowCount; row++) {
|
|
2867
|
+
const roman = romanNumeral(row + 1);
|
|
2868
|
+
const inRow = individuals.filter((i) => rowOfGeneration.get(i.generation) === row).sort((a, b) => placedById.get(a.id).cx - placedById.get(b.id).cx || a.id - b.id);
|
|
2869
|
+
inRow.forEach((ind, i) => addressByIndividual.set(ind.id, `${roman}-${i + 1}`));
|
|
2870
|
+
}
|
|
2871
|
+
const declaredConditionIds = new Set(input.conditions.map((c) => c.id));
|
|
2872
|
+
const usedConditionIds = [...new Set(individuals.flatMap((i) => i.affectedBy))].filter((id) => declaredConditionIds.has(id)).sort((x, y) => x - y);
|
|
2873
|
+
const conditionFills = usedConditionIds.map((id, i) => ({
|
|
2874
|
+
id,
|
|
2875
|
+
ink: PED_CONDITION_FILLS[i % PED_CONDITION_FILLS.length],
|
|
2876
|
+
label: conditionLabelById.get(id) ?? `condition ${id}`
|
|
2877
|
+
}));
|
|
2878
|
+
const nodes = [];
|
|
2879
|
+
for (const ind of individuals) {
|
|
2880
|
+
const placed = placedById.get(ind.id);
|
|
2881
|
+
const row = rowOfGeneration.get(ind.generation);
|
|
2882
|
+
const cy = glyphCyOfRow(row);
|
|
2883
|
+
const lines = labelLinesOf.get(ind.id) ?? [];
|
|
2884
|
+
const address = addressByIndividual.get(ind.id);
|
|
2885
|
+
nodes.push({
|
|
2886
|
+
individualId: ind.id,
|
|
2887
|
+
shape: shapeForSex2(ind.sex),
|
|
2888
|
+
cx: round5(placed.cx),
|
|
2889
|
+
cy: round5(cy),
|
|
2890
|
+
size: PED_GLYPH,
|
|
2891
|
+
deceased: ind.deceased,
|
|
2892
|
+
carrier: ind.carrier,
|
|
2893
|
+
role: ind.role,
|
|
2894
|
+
stillbirth: ind.lifeStatus === "stillbirth",
|
|
2895
|
+
affectedBy: ind.affectedBy,
|
|
2896
|
+
labelLines: lines,
|
|
2897
|
+
// Labels sit BELOW the row's serial-union dip band (rowDipBand) so a hub's bridge dips
|
|
2898
|
+
// never cross a label box.
|
|
2899
|
+
labelTop: round5(cy + PED_GLYPH / 2 + PED_LABEL_GAP + rowDipBand[row]),
|
|
2900
|
+
addressLabel: address,
|
|
2901
|
+
title: individualTitle(ind, address, conditionLabelById, titleLabels)
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
const elements = [];
|
|
2905
|
+
const cxOf = (id) => placedById.get(id).cx;
|
|
2906
|
+
const cyOf = (id) => glyphCyOfRow(rowOfGeneration.get(individualById.get(id).generation));
|
|
2907
|
+
const GLYPH_HALF = PED_GLYPH / 2;
|
|
2908
|
+
const matingBarCenter = /* @__PURE__ */ new Map();
|
|
2909
|
+
for (const s of sibships) {
|
|
2910
|
+
const childIds = s.childIds.filter((id) => placedById.has(id));
|
|
2911
|
+
if (childIds.length === 0) continue;
|
|
2912
|
+
const xs = childIds.map(cxOf);
|
|
2913
|
+
const center = (Math.min(...xs) + Math.max(...xs)) / 2;
|
|
2914
|
+
matingBarCenter.set(s.matingId, center);
|
|
2915
|
+
}
|
|
2916
|
+
const matingMidpoint = /* @__PURE__ */ new Map();
|
|
2917
|
+
const bridgeDipY = (slot) => BRIDGE_DIP_GAP + slot * BRIDGE_DIP_STEP;
|
|
2918
|
+
for (const m of matings) {
|
|
2919
|
+
const [aId, bId] = partnersOf(m);
|
|
2920
|
+
const ax = cxOf(aId);
|
|
2921
|
+
const bx = cxOf(bId);
|
|
2922
|
+
const ay = cyOf(aId);
|
|
2923
|
+
const by = cyOf(bId);
|
|
2924
|
+
const leftId = ax <= bx ? aId : bId;
|
|
2925
|
+
const rightId = ax <= bx ? bId : aId;
|
|
2926
|
+
const lx = cxOf(leftId) + GLYPH_HALF;
|
|
2927
|
+
const rx = cxOf(rightId) - GLYPH_HALF;
|
|
2928
|
+
const title = m.consanguineous ? titleLabels.consanguineous : titleLabels.mating;
|
|
2929
|
+
const bridgeSlot = matingBridgeSlot.get(m.id) ?? 0;
|
|
2930
|
+
if (ay === by && bridgeSlot >= 1) {
|
|
2931
|
+
const hubCx = cxOf(leftId);
|
|
2932
|
+
const spouseCx = cxOf(rightId);
|
|
2933
|
+
const glyphBottom = ay + GLYPH_HALF;
|
|
2934
|
+
const dipY = glyphBottom + bridgeDipY(bridgeSlot);
|
|
2935
|
+
const stubOff = Math.min(bridgeSlot * BRIDGE_STUB_X_STEP, GLYPH_HALF - 4);
|
|
2936
|
+
const hubStubX = hubCx + stubOff;
|
|
2937
|
+
const spouseStubX = spouseCx - stubOff;
|
|
2938
|
+
elements.push({
|
|
2939
|
+
edgeId: PED_MATING_ID_BASE + m.id,
|
|
2940
|
+
kind: "mating-elbow",
|
|
2941
|
+
points: [
|
|
2942
|
+
{ x: round5(hubStubX), y: round5(glyphBottom) },
|
|
2943
|
+
{ x: round5(hubStubX), y: round5(dipY) },
|
|
2944
|
+
{ x: round5(spouseStubX), y: round5(dipY) },
|
|
2945
|
+
{ x: round5(spouseStubX), y: round5(glyphBottom) }
|
|
2946
|
+
],
|
|
2947
|
+
consanguineous: m.consanguineous,
|
|
2948
|
+
title
|
|
2949
|
+
});
|
|
2950
|
+
matingMidpoint.set(m.id, { x: round5((hubStubX + spouseStubX) / 2), y: round5(dipY) });
|
|
2951
|
+
} else if (ay === by) {
|
|
2952
|
+
const y = ay;
|
|
2953
|
+
elements.push({
|
|
2954
|
+
edgeId: PED_MATING_ID_BASE + m.id,
|
|
2955
|
+
kind: "mating",
|
|
2956
|
+
points: [
|
|
2957
|
+
{ x: round5(lx), y: round5(y) },
|
|
2958
|
+
{ x: round5(rx), y: round5(y) }
|
|
2959
|
+
],
|
|
2960
|
+
consanguineous: m.consanguineous,
|
|
2961
|
+
title
|
|
2962
|
+
});
|
|
2963
|
+
const midX = (lx + rx) / 2;
|
|
2964
|
+
const channelLeft = cxOf(leftId) + nodeHalfWidth(individualById.get(leftId));
|
|
2965
|
+
const channelRight = cxOf(rightId) - nodeHalfWidth(individualById.get(rightId));
|
|
2966
|
+
const barCenter = matingBarCenter.get(m.id);
|
|
2967
|
+
const originX = barCenter !== void 0 && barCenter >= channelLeft && barCenter <= channelRight ? barCenter : midX;
|
|
2968
|
+
matingMidpoint.set(m.id, { x: round5(originX), y: round5(y) });
|
|
2969
|
+
} else {
|
|
2970
|
+
const upId = ay <= by ? aId : bId;
|
|
2971
|
+
const downId = ay <= by ? bId : aId;
|
|
2972
|
+
const upX = cxOf(upId);
|
|
2973
|
+
const upY = cyOf(upId);
|
|
2974
|
+
const downX = cxOf(downId);
|
|
2975
|
+
const downY = cyOf(downId);
|
|
2976
|
+
if (childToSibship.has(downId)) {
|
|
2977
|
+
const upRow = rowOfGeneration.get(individualById.get(upId).generation);
|
|
2978
|
+
const laneY = rowTop[upRow] + rowHeight[upRow] + CORRIDOR2 / 4 + 4;
|
|
2979
|
+
const downRight = downX >= upX;
|
|
2980
|
+
const upExitX = upX + (downRight ? -1 : 1) * (nodeHalfWidth(individualById.get(upId)) + 6);
|
|
2981
|
+
const downFarX = downX + (downRight ? 1 : -1) * (GLYPH_HALF + H_GAP2 / 2);
|
|
2982
|
+
const downSideX = downX + (downRight ? 1 : -1) * GLYPH_HALF;
|
|
2983
|
+
elements.push({
|
|
2984
|
+
edgeId: PED_MATING_ID_BASE + m.id,
|
|
2985
|
+
kind: "mating-elbow",
|
|
2986
|
+
points: [
|
|
2987
|
+
{ x: round5(upX + (downRight ? -GLYPH_HALF : GLYPH_HALF)), y: round5(upY) },
|
|
2988
|
+
{ x: round5(upExitX), y: round5(upY) },
|
|
2989
|
+
{ x: round5(upExitX), y: round5(laneY) },
|
|
2990
|
+
{ x: round5(downFarX), y: round5(laneY) },
|
|
2991
|
+
{ x: round5(downFarX), y: round5(downY) },
|
|
2992
|
+
{ x: round5(downSideX), y: round5(downY) }
|
|
2993
|
+
],
|
|
2994
|
+
consanguineous: m.consanguineous,
|
|
2995
|
+
title
|
|
2996
|
+
});
|
|
2997
|
+
matingMidpoint.set(m.id, { x: round5(downFarX), y: round5(downY) });
|
|
2998
|
+
} else {
|
|
2999
|
+
const elbowX = (upX + downX) / 2;
|
|
3000
|
+
elements.push({
|
|
3001
|
+
edgeId: PED_MATING_ID_BASE + m.id,
|
|
3002
|
+
kind: "mating-elbow",
|
|
3003
|
+
points: [
|
|
3004
|
+
{ x: round5(upX + (downX >= upX ? GLYPH_HALF : -GLYPH_HALF)), y: round5(upY) },
|
|
3005
|
+
{ x: round5(elbowX), y: round5(upY) },
|
|
3006
|
+
{ x: round5(elbowX), y: round5(downY) },
|
|
3007
|
+
{ x: round5(downX + (upX >= downX ? GLYPH_HALF : -GLYPH_HALF)), y: round5(downY) }
|
|
3008
|
+
],
|
|
3009
|
+
consanguineous: m.consanguineous,
|
|
3010
|
+
title
|
|
3011
|
+
});
|
|
3012
|
+
matingMidpoint.set(m.id, { x: round5(elbowX), y: round5(downY) });
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
const twinZygositiesUsedSet = /* @__PURE__ */ new Set();
|
|
3017
|
+
const unknownTwinJunctions = [];
|
|
3018
|
+
const bendLaneByRow = /* @__PURE__ */ new Map();
|
|
3019
|
+
for (const s of sibships) {
|
|
3020
|
+
const childIds = s.childIds.filter((id) => individualById.has(id));
|
|
3021
|
+
if (childIds.length === 0) continue;
|
|
3022
|
+
const mid = matingMidpoint.get(s.matingId);
|
|
3023
|
+
const childRow = rowOfGeneration.get(individualById.get(childIds[0]).generation);
|
|
3024
|
+
const barY = rowTop[childRow] - CORRIDOR2 / 2;
|
|
3025
|
+
const lane = bendLaneByRow.get(childRow) ?? 0;
|
|
3026
|
+
bendLaneByRow.set(childRow, lane + 1);
|
|
3027
|
+
const childXs = childIds.map(cxOf);
|
|
3028
|
+
const barLeft = Math.min(...childXs);
|
|
3029
|
+
const barRight = Math.max(...childXs);
|
|
3030
|
+
if (mid !== void 0) {
|
|
3031
|
+
const barCenterX = (barLeft + barRight) / 2;
|
|
3032
|
+
const bendY = barY - 2 - lane % 3 * 3;
|
|
3033
|
+
const descentPoints = Math.abs(mid.x - barCenterX) < 0.01 ? [
|
|
3034
|
+
{ x: round5(mid.x), y: round5(mid.y) },
|
|
3035
|
+
{ x: round5(mid.x), y: round5(barY) }
|
|
3036
|
+
] : [
|
|
3037
|
+
{ x: round5(mid.x), y: round5(mid.y) },
|
|
3038
|
+
{ x: round5(mid.x), y: round5(bendY) },
|
|
3039
|
+
{ x: round5(barCenterX), y: round5(bendY) },
|
|
3040
|
+
{ x: round5(barCenterX), y: round5(barY) }
|
|
3041
|
+
];
|
|
3042
|
+
elements.push({
|
|
3043
|
+
edgeId: PED_DESCENT_ID_BASE + s.id,
|
|
3044
|
+
kind: "descent",
|
|
3045
|
+
points: descentPoints,
|
|
3046
|
+
consanguineous: false,
|
|
3047
|
+
title: titleLabels.sibship
|
|
3048
|
+
});
|
|
3049
|
+
}
|
|
3050
|
+
if (childIds.length > 1) {
|
|
3051
|
+
elements.push({
|
|
3052
|
+
edgeId: PED_SIBBAR_ID_BASE + s.id,
|
|
3053
|
+
kind: "sibship-bar",
|
|
3054
|
+
points: [
|
|
3055
|
+
{ x: round5(barLeft), y: round5(barY) },
|
|
3056
|
+
{ x: round5(barRight), y: round5(barY) }
|
|
3057
|
+
],
|
|
3058
|
+
consanguineous: false,
|
|
3059
|
+
title: titleLabels.sibship
|
|
3060
|
+
});
|
|
3061
|
+
}
|
|
3062
|
+
const twinGroupOfChild = /* @__PURE__ */ new Map();
|
|
3063
|
+
s.twinGroups.forEach((tg, ordinal) => {
|
|
3064
|
+
const members = tg.childIds.filter((id) => childIds.includes(id));
|
|
3065
|
+
for (const id of members) twinGroupOfChild.set(id, { ordinal, zygosity: tg.zygosity, members });
|
|
3066
|
+
});
|
|
3067
|
+
const emittedTwinOrdinals = /* @__PURE__ */ new Set();
|
|
3068
|
+
for (const childId of childIds) {
|
|
3069
|
+
const cx = cxOf(childId);
|
|
3070
|
+
const childTop = cyOf(childId) - PED_GLYPH / 2;
|
|
3071
|
+
const tg = twinGroupOfChild.get(childId);
|
|
3072
|
+
if (tg === void 0) {
|
|
3073
|
+
elements.push({
|
|
3074
|
+
edgeId: PED_RISER_ID_BASE + childId,
|
|
3075
|
+
kind: "riser",
|
|
3076
|
+
points: [
|
|
3077
|
+
{ x: round5(cx), y: round5(barY) },
|
|
3078
|
+
{ x: round5(cx), y: round5(childTop) }
|
|
3079
|
+
],
|
|
3080
|
+
consanguineous: false,
|
|
3081
|
+
title: titleLabels.sibship
|
|
3082
|
+
});
|
|
3083
|
+
continue;
|
|
3084
|
+
}
|
|
3085
|
+
const memberXs = tg.members.map(cxOf);
|
|
3086
|
+
const junctionX = (Math.min(...memberXs) + Math.max(...memberXs)) / 2;
|
|
3087
|
+
const junctionY = barY + TWIN_JUNCTION_DROP;
|
|
3088
|
+
twinZygositiesUsedSet.add(tg.zygosity);
|
|
3089
|
+
if (!emittedTwinOrdinals.has(tg.ordinal)) {
|
|
3090
|
+
emittedTwinOrdinals.add(tg.ordinal);
|
|
3091
|
+
if (tg.zygosity === "unknown") {
|
|
3092
|
+
unknownTwinJunctions.push({ x: round5(junctionX), y: round5(junctionY) });
|
|
3093
|
+
}
|
|
3094
|
+
elements.push({
|
|
3095
|
+
edgeId: PED_RISER_ID_BASE + childId,
|
|
3096
|
+
// anchored on the first member for a stable id
|
|
3097
|
+
kind: "riser",
|
|
3098
|
+
points: [
|
|
3099
|
+
{ x: round5(junctionX), y: round5(barY) },
|
|
3100
|
+
{ x: round5(junctionX), y: round5(junctionY) }
|
|
3101
|
+
],
|
|
3102
|
+
consanguineous: false,
|
|
3103
|
+
title: titleLabels.twins[tg.zygosity]
|
|
3104
|
+
});
|
|
3105
|
+
if (tg.zygosity === "mz" && memberXs.length >= 2) {
|
|
3106
|
+
const tieY = junctionY + 6;
|
|
3107
|
+
elements.push({
|
|
3108
|
+
edgeId: PED_TWINBAR_ID_BASE + s.id * 100 + tg.ordinal,
|
|
3109
|
+
kind: "twin-bar",
|
|
3110
|
+
points: [
|
|
3111
|
+
{ x: round5(Math.min(...memberXs)), y: round5(tieY) },
|
|
3112
|
+
{ x: round5(Math.max(...memberXs)), y: round5(tieY) }
|
|
3113
|
+
],
|
|
3114
|
+
consanguineous: false,
|
|
3115
|
+
title: titleLabels.twins.mz
|
|
3116
|
+
});
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
const horizontalThenDown = Math.abs(cx - junctionX) < 0.01 ? [
|
|
3120
|
+
{ x: round5(cx), y: round5(junctionY) },
|
|
3121
|
+
{ x: round5(cx), y: round5(childTop) }
|
|
3122
|
+
] : [
|
|
3123
|
+
{ x: round5(junctionX), y: round5(junctionY) },
|
|
3124
|
+
{ x: round5(cx), y: round5(junctionY) },
|
|
3125
|
+
{ x: round5(cx), y: round5(childTop) }
|
|
3126
|
+
];
|
|
3127
|
+
elements.push({
|
|
3128
|
+
edgeId: PED_RISER_ID_BASE + childId,
|
|
3129
|
+
kind: "riser",
|
|
3130
|
+
points: horizontalThenDown,
|
|
3131
|
+
consanguineous: false,
|
|
3132
|
+
title: titleLabels.twins[tg.zygosity]
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
let maxX = PADDING5 * 2;
|
|
3137
|
+
let maxY = PADDING5 * 2;
|
|
3138
|
+
for (const n of nodes) {
|
|
3139
|
+
const labelW = n.labelLines.reduce((m, l) => Math.max(m, estimateTextWidth(l, PED_LABEL_FONT)), 0);
|
|
3140
|
+
const half = Math.max(PED_GLYPH, labelW) / 2;
|
|
3141
|
+
maxX = Math.max(maxX, n.cx + half + PADDING5);
|
|
3142
|
+
const labelBottom = n.labelTop + (n.labelLines.length + 1) * PED_LABEL_LINE_H;
|
|
3143
|
+
maxY = Math.max(maxY, labelBottom + PADDING5);
|
|
3144
|
+
}
|
|
3145
|
+
for (const el of elements) {
|
|
3146
|
+
for (const p of el.points) {
|
|
3147
|
+
maxX = Math.max(maxX, p.x + PADDING5);
|
|
3148
|
+
maxY = Math.max(maxY, p.y + PADDING5);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
const generations = [];
|
|
3152
|
+
for (let row = 0; row < rowCount; row++) {
|
|
3153
|
+
generations.push({ roman: romanNumeral(row + 1), y: round5(glyphCyOfRow(row)) });
|
|
3154
|
+
}
|
|
3155
|
+
const ZYGOSITY_ORDER = ["mz", "dz", "unknown"];
|
|
3156
|
+
const twinZygositiesUsed = ZYGOSITY_ORDER.filter((z) => twinZygositiesUsedSet.has(z));
|
|
3157
|
+
return {
|
|
3158
|
+
width: Math.ceil(maxX),
|
|
3159
|
+
height: Math.ceil(maxY),
|
|
3160
|
+
nodes,
|
|
3161
|
+
elements,
|
|
3162
|
+
generations,
|
|
3163
|
+
conditionFills,
|
|
3164
|
+
twinZygositiesUsed,
|
|
3165
|
+
unknownTwinJunctions,
|
|
3166
|
+
isolatedIndividualIds
|
|
3167
|
+
};
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
// src/pedigree/svg.ts
|
|
3171
|
+
var GLYPH_STROKE3 = "#52525b";
|
|
3172
|
+
var LABEL_FILL4 = "#3f3f46";
|
|
3173
|
+
var EDGE_INK5 = "#71717a";
|
|
3174
|
+
var GLYPH_ATTRS2 = `fill="transparent" stroke="${GLYPH_STROKE3}" stroke-width="2"`;
|
|
3175
|
+
var CONSANG_GAP = 3;
|
|
3176
|
+
var ZYGOSITIES = ["mz", "dz", "unknown"];
|
|
3177
|
+
var round6 = (n) => Math.round(n * 100) / 100;
|
|
3178
|
+
function glyphOutline(shape, cx, cy, half) {
|
|
3179
|
+
if (shape === "square") {
|
|
3180
|
+
return `<rect x="${round6(cx - half)}" y="${round6(cy - half)}" width="${half * 2}" height="${half * 2}" ${GLYPH_ATTRS2}/>`;
|
|
3181
|
+
}
|
|
3182
|
+
if (shape === "circle") {
|
|
3183
|
+
return `<circle cx="${cx}" cy="${cy}" r="${half}" ${GLYPH_ATTRS2}/>`;
|
|
3184
|
+
}
|
|
3185
|
+
return `<polygon points="${cx},${round6(cy - half)} ${round6(cx + half)},${cy} ${cx},${round6(cy + half)} ${round6(cx - half)},${cy}" ${GLYPH_ATTRS2}/>`;
|
|
3186
|
+
}
|
|
3187
|
+
function glyphVerticalExtentAt(shape, cy, half, dx) {
|
|
3188
|
+
const ax = Math.min(Math.abs(dx), half);
|
|
3189
|
+
if (shape === "square") return [cy - half, cy + half];
|
|
3190
|
+
if (shape === "circle") {
|
|
3191
|
+
const h2 = Math.sqrt(Math.max(0, half * half - ax * ax));
|
|
3192
|
+
return [cy - h2, cy + h2];
|
|
3193
|
+
}
|
|
3194
|
+
const h = half - ax;
|
|
3195
|
+
return [cy - h, cy + h];
|
|
3196
|
+
}
|
|
3197
|
+
function fillPartitions(n, half, inkByCondition) {
|
|
3198
|
+
const ids = n.affectedBy;
|
|
3199
|
+
if (ids.length === 0) return "";
|
|
3200
|
+
const cx = n.cx;
|
|
3201
|
+
const cy = n.cy;
|
|
3202
|
+
const sliceW = half * 2 / ids.length;
|
|
3203
|
+
return ids.map((id, i) => {
|
|
3204
|
+
const left = cx - half + i * sliceW;
|
|
3205
|
+
const SAMPLES = 8;
|
|
3206
|
+
const top = [];
|
|
3207
|
+
const bottom = [];
|
|
3208
|
+
for (let s = 0; s <= SAMPLES; s++) {
|
|
3209
|
+
const x = left + sliceW * s / SAMPLES;
|
|
3210
|
+
const [yt, yb] = glyphVerticalExtentAt(n.shape, cy, half, x - cx);
|
|
3211
|
+
top.push(`${round6(x)},${round6(yt)}`);
|
|
3212
|
+
bottom.push(`${round6(x)},${round6(yb)}`);
|
|
3213
|
+
}
|
|
3214
|
+
const pts = [...top, ...bottom.reverse()].join(" ");
|
|
3215
|
+
const ink = inkByCondition.get(id) ?? GLYPH_STROKE3;
|
|
3216
|
+
return `<polygon points="${pts}" fill="${ink}" stroke="none"/>`;
|
|
3217
|
+
}).join("");
|
|
3218
|
+
}
|
|
3219
|
+
function carrierDot(n) {
|
|
3220
|
+
if (!n.carrier || n.affectedBy.length > 0) return "";
|
|
3221
|
+
return `<circle cx="${n.cx}" cy="${n.cy}" r="4" fill="${GLYPH_STROKE3}" stroke="none"/>`;
|
|
3222
|
+
}
|
|
3223
|
+
function deceasedSlash(n, half) {
|
|
3224
|
+
if (!n.deceased) return "";
|
|
3225
|
+
const ext = half + 4;
|
|
3226
|
+
return `<line x1="${round6(n.cx - ext)}" y1="${round6(n.cy - ext)}" x2="${round6(n.cx + ext)}" y2="${round6(n.cy + ext)}" stroke="${GLYPH_STROKE3}" stroke-width="2"/>`;
|
|
3227
|
+
}
|
|
3228
|
+
function probandArrow(n, half) {
|
|
3229
|
+
if (n.role === null) return "";
|
|
3230
|
+
const tipX = round6(n.cx - half);
|
|
3231
|
+
const tipY = round6(n.cy + half);
|
|
3232
|
+
const tailX = round6(tipX - 16);
|
|
3233
|
+
const tailY = round6(tipY + 16);
|
|
3234
|
+
const filled = n.role === "proband";
|
|
3235
|
+
const fill = filled ? GLYPH_STROKE3 : "transparent";
|
|
3236
|
+
const shaft = `<line x1="${tailX}" y1="${tailY}" x2="${tipX}" y2="${tipY}" stroke="${GLYPH_STROKE3}" stroke-width="2"/>`;
|
|
3237
|
+
const head = `<polygon points="${tipX},${tipY} ${round6(tipX - 8)},${round6(tipY + 2)} ${round6(tipX - 2)},${round6(tipY + 8)}" fill="${fill}" stroke="${GLYPH_STROKE3}" stroke-width="1.5"/>`;
|
|
3238
|
+
return shaft + head;
|
|
3239
|
+
}
|
|
3240
|
+
function stillbirthMark(n, half) {
|
|
3241
|
+
if (!n.stillbirth) return "";
|
|
3242
|
+
const y = round6(n.cy + half + PED_ADDRESS_FONT);
|
|
3243
|
+
return `<text x="${round6(n.cx - half - 2)}" y="${y}" text-anchor="end" font-family="${FONT_FAMILY}" font-size="${PED_ADDRESS_FONT}" font-weight="bold" fill="${LABEL_FILL4}">SB</text>`;
|
|
3244
|
+
}
|
|
3245
|
+
function nodeSvg2(n, inkByCondition) {
|
|
3246
|
+
const half = n.size / 2;
|
|
3247
|
+
const pieces = [
|
|
3248
|
+
`<title>${xmlEscape(n.title)}</title>`,
|
|
3249
|
+
// Fill partitions sit UNDER the outline so the border stays crisp.
|
|
3250
|
+
fillPartitions(n, half, inkByCondition),
|
|
3251
|
+
glyphOutline(n.shape, n.cx, n.cy, half),
|
|
3252
|
+
carrierDot(n),
|
|
3253
|
+
deceasedSlash(n, half),
|
|
3254
|
+
probandArrow(n, half),
|
|
3255
|
+
stillbirthMark(n, half)
|
|
3256
|
+
];
|
|
3257
|
+
const addressY = round6(n.labelTop + PED_ADDRESS_FONT);
|
|
3258
|
+
pieces.push(
|
|
3259
|
+
`<text x="${n.cx}" y="${addressY}" text-anchor="middle" font-family="${FONT_FAMILY}" font-size="${PED_ADDRESS_FONT}" fill="${LABEL_FILL4}">${xmlEscape(n.addressLabel)}</text>`
|
|
3260
|
+
);
|
|
3261
|
+
if (n.labelLines.length > 0) {
|
|
3262
|
+
const firstBaseline = round6(n.labelTop + PED_LABEL_LINE_H + 10);
|
|
3263
|
+
const tspans = n.labelLines.map((line, i) => `<tspan x="${n.cx}" y="${round6(firstBaseline + i * PED_LABEL_LINE_H)}">${xmlEscape(line)}</tspan>`).join("");
|
|
3264
|
+
pieces.push(
|
|
3265
|
+
`<text text-anchor="middle" font-family="${FONT_FAMILY}" font-size="${PED_LABEL_FONT}" fill="${LABEL_FILL4}">${tspans}</text>`
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
return `<g data-individual-id="p${n.individualId}">${pieces.join("")}</g>`;
|
|
3269
|
+
}
|
|
3270
|
+
function pathData3(points) {
|
|
3271
|
+
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
|
|
3272
|
+
}
|
|
3273
|
+
function elementSvg3(el) {
|
|
3274
|
+
const pts = el.points;
|
|
3275
|
+
const title = `<title>${xmlEscape(el.title)}</title>`;
|
|
3276
|
+
const stroke = `stroke="${EDGE_INK5}" stroke-width="1.5" stroke-opacity="0.75"`;
|
|
3277
|
+
const draw = (offsetY) => {
|
|
3278
|
+
const shifted = pts.map((p) => ({ x: p.x, y: round6(p.y + offsetY) }));
|
|
3279
|
+
if (shifted.length === 2) {
|
|
3280
|
+
return `<line x1="${shifted[0].x}" y1="${shifted[0].y}" x2="${shifted[1].x}" y2="${shifted[1].y}" ${stroke}/>`;
|
|
3281
|
+
}
|
|
3282
|
+
return `<path d="${pathData3(shifted)}" fill="none" ${stroke}/>`;
|
|
3283
|
+
};
|
|
3284
|
+
const body = el.consanguineous && (el.kind === "mating" || el.kind === "mating-elbow") ? draw(-CONSANG_GAP / 2) + draw(CONSANG_GAP / 2) : draw(0);
|
|
3285
|
+
return `<g data-edge-id="${el.edgeId}">${title}${body}</g>`;
|
|
3286
|
+
}
|
|
3287
|
+
function unknownTwinMarks(layout) {
|
|
3288
|
+
return layout.unknownTwinJunctions.map(
|
|
3289
|
+
(p) => `<text x="${round6(p.x + 6)}" y="${round6(p.y + 4)}" font-family="${FONT_FAMILY}" font-size="12" font-weight="bold" fill="${LABEL_FILL4}">?</text>`
|
|
3290
|
+
).join("");
|
|
3291
|
+
}
|
|
3292
|
+
var MINI_ATTRS2 = `fill="transparent" stroke="${GLYPH_STROKE3}" stroke-width="1.5"`;
|
|
3293
|
+
function miniShapeSwatch(shape, x, y) {
|
|
3294
|
+
const cx = round6(x + LEGEND_SWATCH_W / 2);
|
|
3295
|
+
if (shape === "square") return `<rect x="${round6(cx - 6)}" y="${round6(y - 6)}" width="12" height="12" ${MINI_ATTRS2}/>`;
|
|
3296
|
+
if (shape === "circle") return `<circle cx="${cx}" cy="${y}" r="6" ${MINI_ATTRS2}/>`;
|
|
3297
|
+
return `<polygon points="${cx},${round6(y - 7)} ${round6(cx + 7)},${y} ${cx},${round6(y + 7)} ${round6(cx - 7)},${y}" ${MINI_ATTRS2}/>`;
|
|
3298
|
+
}
|
|
3299
|
+
function miniSwatchCircle(filled, ink, x, y) {
|
|
3300
|
+
const cx = round6(x + LEGEND_SWATCH_W / 2);
|
|
3301
|
+
return `<circle cx="${cx}" cy="${y}" r="6" fill="${filled ? ink : "transparent"}" stroke="${GLYPH_STROKE3}" stroke-width="1.5"/>`;
|
|
3302
|
+
}
|
|
3303
|
+
function miniCarrierSwatch(x, y) {
|
|
3304
|
+
const cx = round6(x + LEGEND_SWATCH_W / 2);
|
|
3305
|
+
return `<circle cx="${cx}" cy="${y}" r="6" ${MINI_ATTRS2}/><circle cx="${cx}" cy="${y}" r="2" fill="${GLYPH_STROKE3}" stroke="none"/>`;
|
|
3306
|
+
}
|
|
3307
|
+
function miniDeceasedSwatch(x, y) {
|
|
3308
|
+
const cx = round6(x + LEGEND_SWATCH_W / 2);
|
|
3309
|
+
return `<circle cx="${cx}" cy="${y}" r="6" ${MINI_ATTRS2}/><line x1="${round6(cx - 7)}" y1="${round6(y - 7)}" x2="${round6(cx + 7)}" y2="${round6(y + 7)}" stroke="${GLYPH_STROKE3}" stroke-width="1.5"/>`;
|
|
3310
|
+
}
|
|
3311
|
+
function miniArrowSwatch(filled, x, y) {
|
|
3312
|
+
const cx = round6(x + LEGEND_SWATCH_W / 2);
|
|
3313
|
+
const tipX = round6(cx - 2);
|
|
3314
|
+
const tipY = round6(y + 2);
|
|
3315
|
+
return `<line x1="${round6(tipX - 8)}" y1="${round6(tipY + 8)}" x2="${tipX}" y2="${tipY}" stroke="${GLYPH_STROKE3}" stroke-width="1.5"/><polygon points="${tipX},${tipY} ${round6(tipX - 5)},${round6(tipY + 1)} ${round6(tipX - 1)},${round6(tipY + 5)}" fill="${filled ? GLYPH_STROKE3 : "transparent"}" stroke="${GLYPH_STROKE3}" stroke-width="1"/>`;
|
|
3316
|
+
}
|
|
3317
|
+
function miniConsanguineousSwatch(x, y) {
|
|
3318
|
+
const x1 = round6(x + 2);
|
|
3319
|
+
const x2 = round6(x + LEGEND_SWATCH_W - 2);
|
|
3320
|
+
return `<line x1="${x1}" y1="${round6(y - 1.5)}" x2="${x2}" y2="${round6(y - 1.5)}" stroke="${EDGE_INK5}" stroke-width="1.5"/><line x1="${x1}" y1="${round6(y + 1.5)}" x2="${x2}" y2="${round6(y + 1.5)}" stroke="${EDGE_INK5}" stroke-width="1.5"/>`;
|
|
3321
|
+
}
|
|
3322
|
+
function miniTwinSwatch(zygosity, x, y) {
|
|
3323
|
+
const cx = round6(x + LEGEND_SWATCH_W / 2);
|
|
3324
|
+
const apexY = round6(y - 6);
|
|
3325
|
+
const baseY = round6(y + 6);
|
|
3326
|
+
const left = round6(cx - 6);
|
|
3327
|
+
const right = round6(cx + 6);
|
|
3328
|
+
const stub = `<line x1="${cx}" y1="${apexY}" x2="${cx}" y2="${y}" stroke="${EDGE_INK5}" stroke-width="1.5"/><line x1="${left}" y1="${y}" x2="${right}" y2="${y}" stroke="${EDGE_INK5}" stroke-width="1.5"/><line x1="${left}" y1="${y}" x2="${left}" y2="${baseY}" stroke="${EDGE_INK5}" stroke-width="1.5"/><line x1="${right}" y1="${y}" x2="${right}" y2="${baseY}" stroke="${EDGE_INK5}" stroke-width="1.5"/>`;
|
|
3329
|
+
if (zygosity === "mz") {
|
|
3330
|
+
return stub + `<line x1="${left}" y1="${round6(y + 3)}" x2="${right}" y2="${round6(y + 3)}" stroke="${EDGE_INK5}" stroke-width="1.5"/>`;
|
|
3331
|
+
}
|
|
3332
|
+
if (zygosity === "unknown") {
|
|
3333
|
+
return stub + `<text x="${round6(cx + 8)}" y="${round6(y + 3)}" font-family="${FONT_FAMILY}" font-size="9" fill="${LABEL_FILL4}">?</text>`;
|
|
3334
|
+
}
|
|
3335
|
+
return stub;
|
|
3336
|
+
}
|
|
3337
|
+
function pedigreeLayoutSvg(layout, opts = {}) {
|
|
3338
|
+
const labels = opts.labels ?? PEDIGREE_SVG_LABELS_EN;
|
|
3339
|
+
const inkByCondition = new Map(layout.conditionFills.map((c) => [c.id, c.ink]));
|
|
3340
|
+
const parts = [];
|
|
3341
|
+
for (const el of layout.elements) parts.push(elementSvg3(el));
|
|
3342
|
+
parts.push(unknownTwinMarks(layout));
|
|
3343
|
+
for (const n of layout.nodes) parts.push(nodeSvg2(n, inkByCondition));
|
|
3344
|
+
for (const g of layout.generations) {
|
|
3345
|
+
parts.push(
|
|
3346
|
+
`<text x="${round6(16)}" y="${round6(g.y + PED_LABEL_FONT * 0.32)}" text-anchor="middle" font-family="${FONT_FAMILY}" font-size="${PED_LABEL_FONT}" font-weight="bold" fill="${LABEL_FILL4}">${xmlEscape(g.roman)}</text>`
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
let width = layout.width;
|
|
3350
|
+
let height = layout.height;
|
|
3351
|
+
if (opts.legend !== false && layout.nodes.length > 0) {
|
|
3352
|
+
const entries = [];
|
|
3353
|
+
const shapesUsed = new Set(layout.nodes.map((n) => n.shape));
|
|
3354
|
+
for (const shape of ["square", "circle", "diamond"]) {
|
|
3355
|
+
if (!shapesUsed.has(shape)) continue;
|
|
3356
|
+
entries.push({ swatch: (x, y) => miniShapeSwatch(shape, x, y), label: labels.shapes[shape] });
|
|
3357
|
+
}
|
|
3358
|
+
for (const c of layout.conditionFills) {
|
|
3359
|
+
entries.push({ swatch: (x, y) => miniSwatchCircle(true, c.ink, x, y), label: c.label });
|
|
3360
|
+
}
|
|
3361
|
+
if (layout.nodes.some((n) => n.affectedBy.length === 0)) {
|
|
3362
|
+
entries.push({ swatch: (x, y) => miniSwatchCircle(false, GLYPH_STROKE3, x, y), label: labels.unaffected });
|
|
3363
|
+
}
|
|
3364
|
+
if (layout.nodes.some((n) => n.carrier && n.affectedBy.length === 0)) {
|
|
3365
|
+
entries.push({ swatch: miniCarrierSwatch, label: labels.carrier });
|
|
3366
|
+
}
|
|
3367
|
+
if (layout.nodes.some((n) => n.deceased)) {
|
|
3368
|
+
entries.push({ swatch: miniDeceasedSwatch, label: labels.deceased });
|
|
3369
|
+
}
|
|
3370
|
+
if (layout.nodes.some((n) => n.role === "proband")) {
|
|
3371
|
+
entries.push({ swatch: (x, y) => miniArrowSwatch(true, x, y), label: labels.proband });
|
|
3372
|
+
}
|
|
3373
|
+
if (layout.nodes.some((n) => n.role === "consultand")) {
|
|
3374
|
+
entries.push({ swatch: (x, y) => miniArrowSwatch(false, x, y), label: labels.consultand });
|
|
3375
|
+
}
|
|
3376
|
+
if (layout.nodes.some((n) => n.stillbirth)) {
|
|
3377
|
+
entries.push({ swatch: () => "", label: labels.stillbirth });
|
|
3378
|
+
}
|
|
3379
|
+
if (layout.elements.some((el) => el.consanguineous)) {
|
|
3380
|
+
entries.push({ swatch: miniConsanguineousSwatch, label: labels.consanguineous });
|
|
3381
|
+
}
|
|
3382
|
+
const twinsUsed = new Set(layout.twinZygositiesUsed);
|
|
3383
|
+
for (const z of ZYGOSITIES) {
|
|
3384
|
+
if (!twinsUsed.has(z)) continue;
|
|
3385
|
+
entries.push({ swatch: (x, y) => miniTwinSwatch(z, x, y), label: labels.twins[z] });
|
|
3386
|
+
}
|
|
3387
|
+
if (layout.isolatedIndividualIds.length > 0) {
|
|
3388
|
+
entries.push({ swatch: () => "", label: labels.isolated });
|
|
3389
|
+
}
|
|
3390
|
+
const block = legendBlock(entries, layout.height);
|
|
3391
|
+
if (block.svg !== "") {
|
|
3392
|
+
parts.push(block.svg);
|
|
3393
|
+
width = Math.max(width, block.width);
|
|
3394
|
+
height = block.height;
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
const w = Math.ceil(width);
|
|
3398
|
+
const h = Math.ceil(height);
|
|
3399
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${xmlEscape(labels.ariaLabel)}">` + parts.join("") + `</svg>`;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// src/pedigree/render.ts
|
|
3403
|
+
function pedigreeSvg(input, opts = {}) {
|
|
3404
|
+
const layout = computePedigreeLayout(input, {
|
|
3405
|
+
...opts.maxLabelChars !== void 0 ? { maxLabelChars: opts.maxLabelChars } : {},
|
|
3406
|
+
...opts.titleLabels !== void 0 ? { titleLabels: opts.titleLabels } : {}
|
|
3407
|
+
});
|
|
3408
|
+
const svg = pedigreeLayoutSvg(layout, {
|
|
3409
|
+
...opts.legend === false ? { legend: false } : {},
|
|
3410
|
+
...opts.svgLabels !== void 0 ? { labels: opts.svgLabels } : {}
|
|
3411
|
+
});
|
|
3412
|
+
return { svg, layout };
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
// src/phylo/labels.ts
|
|
3416
|
+
var PHYLO_TITLE_LABELS_EN = {
|
|
3417
|
+
branchLength: "branch length",
|
|
3418
|
+
support: "support",
|
|
3419
|
+
clade: "clade",
|
|
3420
|
+
tip: "tip",
|
|
3421
|
+
root: "root"
|
|
3422
|
+
};
|
|
3423
|
+
var PHYLO_SVG_LABELS_EN = {
|
|
3424
|
+
support: "Support value (bootstrap/posterior)",
|
|
3425
|
+
scaleBar: "Scale: substitutions per site",
|
|
3426
|
+
alignedTip: "Aligned tip (true position dotted)",
|
|
3427
|
+
ariaLabel: {
|
|
3428
|
+
cladogram: "Phylogenetic tree (cladogram)",
|
|
3429
|
+
phylogram: "Phylogenetic tree (phylogram)"
|
|
3430
|
+
}
|
|
3431
|
+
};
|
|
3432
|
+
|
|
3433
|
+
// src/phylo/validate.ts
|
|
3434
|
+
var PhyloValidationError = class extends Error {
|
|
3435
|
+
issues;
|
|
3436
|
+
constructor(issues) {
|
|
3437
|
+
super(`invalid phylo tree: ${issues.map((i) => i.message).join("; ")}`);
|
|
3438
|
+
this.name = "PhyloValidationError";
|
|
3439
|
+
this.issues = issues;
|
|
3440
|
+
}
|
|
3441
|
+
};
|
|
3442
|
+
function sortIssues3(issues) {
|
|
3443
|
+
const unique = /* @__PURE__ */ new Map();
|
|
3444
|
+
for (const issue of issues) unique.set(`${issue.code} ${issue.message}`, issue);
|
|
3445
|
+
return [...unique.values()].sort(
|
|
3446
|
+
(a, b) => a.code !== b.code ? a.code < b.code ? -1 : 1 : a.message < b.message ? -1 : a.message > b.message ? 1 : 0
|
|
3447
|
+
);
|
|
3448
|
+
}
|
|
3449
|
+
function phyloIssues(input) {
|
|
3450
|
+
if (input.nodes.length === 0 && input.edges.length === 0) return [];
|
|
3451
|
+
const issues = [];
|
|
3452
|
+
const push = (code, message) => {
|
|
3453
|
+
issues.push({ code, message });
|
|
3454
|
+
};
|
|
3455
|
+
const nodeById = /* @__PURE__ */ new Map();
|
|
3456
|
+
const dupNodeIds = /* @__PURE__ */ new Set();
|
|
3457
|
+
for (const n of input.nodes) {
|
|
3458
|
+
if (nodeById.has(n.id)) dupNodeIds.add(n.id);
|
|
3459
|
+
else nodeById.set(n.id, n);
|
|
3460
|
+
}
|
|
3461
|
+
for (const id of [...dupNodeIds].sort((a, b) => a - b)) {
|
|
3462
|
+
push("duplicate-id", `duplicate node id ${id}`);
|
|
3463
|
+
}
|
|
3464
|
+
const dupEdgeIds = /* @__PURE__ */ new Set();
|
|
3465
|
+
const seenEdgeIds = /* @__PURE__ */ new Set();
|
|
3466
|
+
for (const e of input.edges) {
|
|
3467
|
+
if (seenEdgeIds.has(e.id)) dupEdgeIds.add(e.id);
|
|
3468
|
+
else seenEdgeIds.add(e.id);
|
|
3469
|
+
}
|
|
3470
|
+
for (const id of [...dupEdgeIds].sort((a, b) => a - b)) {
|
|
3471
|
+
push("duplicate-id", `duplicate edge id ${id}`);
|
|
3472
|
+
}
|
|
3473
|
+
if (issues.length > 0) {
|
|
3474
|
+
return sortIssues3(issues);
|
|
3475
|
+
}
|
|
3476
|
+
if (!nodeById.has(input.rootId)) {
|
|
3477
|
+
push("unknown-root", `rootId ${input.rootId} is not a declared node`);
|
|
3478
|
+
}
|
|
3479
|
+
const incomingByChild = /* @__PURE__ */ new Map();
|
|
3480
|
+
for (const e of [...input.edges].sort((a, b) => a.id - b.id)) {
|
|
3481
|
+
const knownParent = nodeById.has(e.parentId);
|
|
3482
|
+
const knownChild = nodeById.has(e.childId);
|
|
3483
|
+
if (!knownParent) push("unknown-endpoint", `edge ${e.id} parentId ${e.parentId} is not a declared node`);
|
|
3484
|
+
if (!knownChild) push("unknown-endpoint", `edge ${e.id} childId ${e.childId} is not a declared node`);
|
|
3485
|
+
if (e.length !== null && (!Number.isFinite(e.length) || e.length < 0)) {
|
|
3486
|
+
push("negative-length", `edge ${e.id} has non-finite or negative length ${e.length}`);
|
|
3487
|
+
}
|
|
3488
|
+
if (knownParent && knownChild) {
|
|
3489
|
+
const arr = incomingByChild.get(e.childId) ?? [];
|
|
3490
|
+
arr.push(e.parentId);
|
|
3491
|
+
incomingByChild.set(e.childId, arr);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
const childIdsWithEdges = /* @__PURE__ */ new Set();
|
|
3495
|
+
for (const e of input.edges) childIdsWithEdges.add(e.childId);
|
|
3496
|
+
for (const [childId, parents] of [...incomingByChild.entries()].sort((a, b) => a[0] - b[0])) {
|
|
3497
|
+
if (parents.length > 1) {
|
|
3498
|
+
push("multiple-parents", `node ${childId} has ${parents.length} parents`);
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
const hasChildren = /* @__PURE__ */ new Set();
|
|
3502
|
+
for (const e of input.edges) {
|
|
3503
|
+
if (nodeById.has(e.parentId)) hasChildren.add(e.parentId);
|
|
3504
|
+
}
|
|
3505
|
+
for (const n of [...nodeById.values()].sort((a, b) => a.id - b.id)) {
|
|
3506
|
+
if (n.isTip === true && hasChildren.has(n.id)) {
|
|
3507
|
+
push("tip-with-children", `node ${n.id} is declared a tip but has children`);
|
|
3508
|
+
} else if (n.isTip === false && !hasChildren.has(n.id)) {
|
|
3509
|
+
push("tip-with-children", `node ${n.id} is declared internal but has no children`);
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
const GRAPH_BLOCKING = /* @__PURE__ */ new Set([
|
|
3513
|
+
"duplicate-id",
|
|
3514
|
+
"unknown-endpoint",
|
|
3515
|
+
"unknown-root",
|
|
3516
|
+
"multiple-parents"
|
|
3517
|
+
]);
|
|
3518
|
+
if (!issues.some((i) => GRAPH_BLOCKING.has(i.code))) {
|
|
3519
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
3520
|
+
for (const e of [...input.edges].sort((a, b) => a.id - b.id)) {
|
|
3521
|
+
const arr = childrenOf.get(e.parentId) ?? [];
|
|
3522
|
+
arr.push(e.childId);
|
|
3523
|
+
childrenOf.set(e.parentId, arr);
|
|
3524
|
+
}
|
|
3525
|
+
const color = /* @__PURE__ */ new Map();
|
|
3526
|
+
const seenCycles = /* @__PURE__ */ new Set();
|
|
3527
|
+
const dfs = (start) => {
|
|
3528
|
+
const stack = [{ id: start, nextChild: 0 }];
|
|
3529
|
+
color.set(start, 1);
|
|
3530
|
+
while (stack.length > 0) {
|
|
3531
|
+
const frame = stack[stack.length - 1];
|
|
3532
|
+
const children = childrenOf.get(frame.id) ?? [];
|
|
3533
|
+
if (frame.nextChild >= children.length) {
|
|
3534
|
+
color.set(frame.id, 2);
|
|
3535
|
+
stack.pop();
|
|
3536
|
+
continue;
|
|
3537
|
+
}
|
|
3538
|
+
const child = children[frame.nextChild];
|
|
3539
|
+
frame.nextChild += 1;
|
|
3540
|
+
const c = color.get(child) ?? 0;
|
|
3541
|
+
if (c === 1) {
|
|
3542
|
+
const from = stack.findIndex((f) => f.id === child);
|
|
3543
|
+
const cycle = stack.slice(from).map((f) => f.id);
|
|
3544
|
+
const minIdx = cycle.indexOf(Math.min(...cycle));
|
|
3545
|
+
const rotated = [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)];
|
|
3546
|
+
const key = rotated.join(">");
|
|
3547
|
+
if (!seenCycles.has(key)) {
|
|
3548
|
+
seenCycles.add(key);
|
|
3549
|
+
push("cycle", `cycle: ${[...rotated, rotated[0]].join(" \u2192 ")}`);
|
|
3550
|
+
}
|
|
3551
|
+
} else if (c === 0) {
|
|
3552
|
+
color.set(child, 1);
|
|
3553
|
+
stack.push({ id: child, nextChild: 0 });
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
};
|
|
3557
|
+
for (const n of [...nodeById.values()].sort((a, b) => a.id - b.id)) {
|
|
3558
|
+
if ((color.get(n.id) ?? 0) === 0) dfs(n.id);
|
|
3559
|
+
}
|
|
3560
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
3561
|
+
const queue = [input.rootId];
|
|
3562
|
+
while (queue.length > 0) {
|
|
3563
|
+
const id = queue.shift();
|
|
3564
|
+
if (reachable.has(id)) continue;
|
|
3565
|
+
reachable.add(id);
|
|
3566
|
+
for (const childId of childrenOf.get(id) ?? []) queue.push(childId);
|
|
3567
|
+
}
|
|
3568
|
+
for (const n of [...nodeById.values()].sort((a, b) => a.id - b.id)) {
|
|
3569
|
+
if (!reachable.has(n.id)) push("disconnected-node", `node ${n.id} is unreachable from the root`);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
return sortIssues3(issues);
|
|
3573
|
+
}
|
|
3574
|
+
function validatePhylo(input) {
|
|
3575
|
+
const issues = phyloIssues(input);
|
|
3576
|
+
if (issues.length > 0) throw new PhyloValidationError(issues);
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
// src/phylo/layout.ts
|
|
3580
|
+
var PHYLO_LABEL_FONT = 12;
|
|
3581
|
+
var PHYLO_ROW_SLOT = 22;
|
|
3582
|
+
var PHYLO_SUPPORT_FONT = 10;
|
|
3583
|
+
var PHYLO_LABEL_GAP = 8;
|
|
3584
|
+
var PADDING6 = 32;
|
|
3585
|
+
var PHYLO_LEVEL_W = 72;
|
|
3586
|
+
var PHYLO_TIP_R = 2.5;
|
|
3587
|
+
var SUPPORT_GAP = 4;
|
|
3588
|
+
var SCALEBAR_ZONE = 34;
|
|
3589
|
+
var MIN_DRAW_W = PHYLO_LEVEL_W;
|
|
3590
|
+
var PHYLO_ZERO_NUDGE = 1;
|
|
3591
|
+
var PHYLO_CLADEBAR_ID_BASE = 1e6;
|
|
3592
|
+
var PHYLO_BRANCH_ID_BASE = 2e6;
|
|
3593
|
+
var PHYLO_EXTENSION_ID_BASE = 3e6;
|
|
3594
|
+
var PHYLO_ROOTSTUB_ID_BASE = 4e6;
|
|
3595
|
+
var PHYLO_SCALEBAR_ID = 5e6;
|
|
3596
|
+
var round7 = (n) => Math.round(n * 100) / 100;
|
|
3597
|
+
function niceScaleStep(maxLen) {
|
|
3598
|
+
if (!(maxLen > 0)) return 0;
|
|
3599
|
+
const target = maxLen / 4;
|
|
3600
|
+
const exp = Math.floor(Math.log10(target));
|
|
3601
|
+
const pow = Math.pow(10, exp);
|
|
3602
|
+
const tol = target * 1e-9;
|
|
3603
|
+
for (const mult of [5, 2, 1]) {
|
|
3604
|
+
const step = mult * pow;
|
|
3605
|
+
if (step <= target + tol) return step;
|
|
3606
|
+
}
|
|
3607
|
+
return 5 * Math.pow(10, exp - 1);
|
|
3608
|
+
}
|
|
3609
|
+
function trimNumber(n) {
|
|
3610
|
+
if (Number.isInteger(n)) return String(n);
|
|
3611
|
+
return String(Number(n.toPrecision(6))).replace(/0+$/, "").replace(/\.$/, "");
|
|
3612
|
+
}
|
|
3613
|
+
function nodeTitle(node, isTip, isRoot, inLength, labels) {
|
|
3614
|
+
if (node.title !== void 0) return node.title;
|
|
3615
|
+
const role = isRoot ? labels.root : isTip ? labels.tip : labels.clade;
|
|
3616
|
+
const head = node.label === "" ? role : node.label;
|
|
3617
|
+
const parts = [head];
|
|
3618
|
+
if (inLength !== null) parts.push(`${labels.branchLength}: ${trimNumber(inLength)}`);
|
|
3619
|
+
if (!isTip && node.support !== void 0 && node.support !== null) {
|
|
3620
|
+
parts.push(`${labels.support}: ${trimNumber(node.support)}`);
|
|
3621
|
+
}
|
|
3622
|
+
return parts.join(" \xB7 ");
|
|
3623
|
+
}
|
|
3624
|
+
function computePhyloLayout(input, opts = {}) {
|
|
3625
|
+
const mode = opts.mode ?? "cladogram";
|
|
3626
|
+
if (input.nodes.length === 0 && input.edges.length === 0) {
|
|
3627
|
+
return { width: PADDING6 * 2, height: PADDING6 * 2, mode, nodes: [], elements: [], scaleBar: null, showSupport: false };
|
|
3628
|
+
}
|
|
3629
|
+
validatePhylo(input);
|
|
3630
|
+
const titleLabels = opts.titleLabels ?? PHYLO_TITLE_LABELS_EN;
|
|
3631
|
+
const alignTips = opts.alignTips ?? mode === "cladogram";
|
|
3632
|
+
const showSupport = opts.showSupport ?? false;
|
|
3633
|
+
const wantScaleBar = (opts.scaleBar ?? true) && mode === "phylogram";
|
|
3634
|
+
const nodeById = new Map(input.nodes.map((n) => [n.id, n]));
|
|
3635
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
3636
|
+
const inLengthByChild = /* @__PURE__ */ new Map();
|
|
3637
|
+
for (const e of [...input.edges].sort((a, b) => a.id - b.id)) {
|
|
3638
|
+
(childrenOf.get(e.parentId) ?? childrenOf.set(e.parentId, []).get(e.parentId)).push(e.childId);
|
|
3639
|
+
inLengthByChild.set(e.childId, e.length);
|
|
3640
|
+
}
|
|
3641
|
+
const allWork = [];
|
|
3642
|
+
const build = (nodeId, depth, dist) => {
|
|
3643
|
+
const node = nodeById.get(nodeId);
|
|
3644
|
+
const inLength = inLengthByChild.get(nodeId) ?? null;
|
|
3645
|
+
const work = { node, children: [], depth, dist, inLength, cx: 0, cy: 0 };
|
|
3646
|
+
allWork.push(work);
|
|
3647
|
+
for (const childId of childrenOf.get(nodeId) ?? []) {
|
|
3648
|
+
const childLen = inLengthByChild.get(childId);
|
|
3649
|
+
const add = childLen === null || childLen === void 0 ? 0 : Math.max(0, childLen);
|
|
3650
|
+
work.children.push(build(childId, depth + 1, dist + add));
|
|
3651
|
+
}
|
|
3652
|
+
return work;
|
|
3653
|
+
};
|
|
3654
|
+
const root = build(input.rootId, 0, 0);
|
|
3655
|
+
const isTip = (w) => w.children.length === 0;
|
|
3656
|
+
const top = PADDING6;
|
|
3657
|
+
let tipIndex = 0;
|
|
3658
|
+
const assignY = (w) => {
|
|
3659
|
+
if (isTip(w)) {
|
|
3660
|
+
w.cy = top + tipIndex * PHYLO_ROW_SLOT + PHYLO_ROW_SLOT / 2;
|
|
3661
|
+
tipIndex += 1;
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3664
|
+
for (const c of w.children) assignY(c);
|
|
3665
|
+
const ys = w.children.map((c) => c.cy);
|
|
3666
|
+
w.cy = (Math.min(...ys) + Math.max(...ys)) / 2;
|
|
3667
|
+
};
|
|
3668
|
+
assignY(root);
|
|
3669
|
+
const tipCount = tipIndex;
|
|
3670
|
+
const maxDepth = allWork.reduce((m, w) => Math.max(m, w.depth), 0);
|
|
3671
|
+
const maxDist = allWork.reduce((m, w) => Math.max(m, w.dist), 0);
|
|
3672
|
+
const supportNodes = allWork.filter(
|
|
3673
|
+
(w) => !isTip(w) && w.node.support !== void 0 && w.node.support !== null
|
|
3674
|
+
);
|
|
3675
|
+
const effectiveShowSupport = showSupport && supportNodes.length > 0;
|
|
3676
|
+
const supportMargin = effectiveShowSupport ? supportNodes.reduce(
|
|
3677
|
+
(m, w) => Math.max(m, estimateTextWidth(trimNumber(w.node.support), PHYLO_SUPPORT_FONT)),
|
|
3678
|
+
0
|
|
3679
|
+
) + SUPPORT_GAP : 0;
|
|
3680
|
+
const padLeft = PADDING6 + supportMargin;
|
|
3681
|
+
const tipLabelW = allWork.filter(isTip).reduce((m, w) => Math.max(m, estimateTextWidth(clampLabel(w.node.label, opts.maxLabelChars), PHYLO_LABEL_FONT)), 0);
|
|
3682
|
+
const labelReserve = (tipCount > 0 ? PHYLO_LABEL_GAP + tipLabelW : 0) + PADDING6;
|
|
3683
|
+
const phylogramScaled = mode === "phylogram" && maxDist > 0;
|
|
3684
|
+
const drawW = Math.max(MIN_DRAW_W, maxDepth * PHYLO_LEVEL_W);
|
|
3685
|
+
const scale = phylogramScaled ? drawW / maxDist : 0;
|
|
3686
|
+
const xOf = (w) => {
|
|
3687
|
+
if (phylogramScaled) return padLeft + w.dist * scale;
|
|
3688
|
+
return padLeft + w.depth * PHYLO_LEVEL_W;
|
|
3689
|
+
};
|
|
3690
|
+
for (const w of allWork) w.cx = xOf(w);
|
|
3691
|
+
if (phylogramScaled) {
|
|
3692
|
+
const nudge = (w) => {
|
|
3693
|
+
for (const c of w.children) {
|
|
3694
|
+
if (!isTip(c) && c.cx <= w.cx + 1e-6) c.cx = w.cx + PHYLO_ZERO_NUDGE;
|
|
3695
|
+
nudge(c);
|
|
3696
|
+
}
|
|
3697
|
+
};
|
|
3698
|
+
nudge(root);
|
|
3699
|
+
}
|
|
3700
|
+
const tipsAligned = alignTips;
|
|
3701
|
+
const maxTipX = allWork.filter(isTip).reduce((m, w) => Math.max(m, w.cx), padLeft);
|
|
3702
|
+
const alignX = phylogramScaled ? maxTipX : padLeft + maxDepth * PHYLO_LEVEL_W;
|
|
3703
|
+
const tipDrawX = (w) => tipsAligned ? alignX : w.cx;
|
|
3704
|
+
const rightmost = tipsAligned ? alignX : allWork.reduce((m, w) => Math.max(m, w.cx), padLeft);
|
|
3705
|
+
const treeBottom = top + (tipCount > 0 ? tipCount * PHYLO_ROW_SLOT : PHYLO_ROW_SLOT);
|
|
3706
|
+
const scaleBarPx = wantScaleBar && maxDist > 0 ? niceScaleStep(maxDist) * scale : 0;
|
|
3707
|
+
const hasScaleBar = wantScaleBar && maxDist > 0 && scaleBarPx > 0;
|
|
3708
|
+
const width = Math.ceil(rightmost + labelReserve);
|
|
3709
|
+
const height = Math.ceil(treeBottom + (hasScaleBar ? SCALEBAR_ZONE : 0) + PADDING6 / 2);
|
|
3710
|
+
const nodes = [];
|
|
3711
|
+
const elements = [];
|
|
3712
|
+
const branchTitle = (w) => {
|
|
3713
|
+
if (w.inLength !== null) return `${titleLabels.branchLength}: ${trimNumber(w.inLength)}`;
|
|
3714
|
+
return titleLabels.branchLength;
|
|
3715
|
+
};
|
|
3716
|
+
const ROOT_STUB = 14;
|
|
3717
|
+
const rootDrawX = isTip(root) ? tipDrawX(root) : root.cx;
|
|
3718
|
+
elements.push({
|
|
3719
|
+
edgeId: PHYLO_ROOTSTUB_ID_BASE + root.node.id,
|
|
3720
|
+
kind: "root-stub",
|
|
3721
|
+
points: [
|
|
3722
|
+
{ x: round7(Math.max(PADDING6 / 2, rootDrawX - ROOT_STUB)), y: round7(root.cy) },
|
|
3723
|
+
{ x: round7(rootDrawX), y: round7(root.cy) }
|
|
3724
|
+
],
|
|
3725
|
+
dotted: false,
|
|
3726
|
+
length: null,
|
|
3727
|
+
title: titleLabels.root
|
|
3728
|
+
});
|
|
3729
|
+
const emit = (w) => {
|
|
3730
|
+
const tip = isTip(w);
|
|
3731
|
+
const isRoot = w === root;
|
|
3732
|
+
const drawX = tip ? tipDrawX(w) : w.cx;
|
|
3733
|
+
const support = w.node.support === void 0 ? null : w.node.support;
|
|
3734
|
+
const labelLine = tip && w.node.label !== "" ? [clampLabel(w.node.label, opts.maxLabelChars)] : [];
|
|
3735
|
+
nodes.push({
|
|
3736
|
+
nodeId: w.node.id,
|
|
3737
|
+
isTip: tip,
|
|
3738
|
+
cx: round7(drawX),
|
|
3739
|
+
cy: round7(w.cy),
|
|
3740
|
+
support,
|
|
3741
|
+
labelLines: labelLine,
|
|
3742
|
+
labelLeft: round7(drawX + PHYLO_TIP_R + PHYLO_LABEL_GAP),
|
|
3743
|
+
depth: w.depth,
|
|
3744
|
+
title: nodeTitle(w.node, tip, isRoot, w.inLength, titleLabels)
|
|
3745
|
+
});
|
|
3746
|
+
if (tip && tipsAligned && w.cx < alignX - 1e-6) {
|
|
3747
|
+
elements.push({
|
|
3748
|
+
edgeId: PHYLO_EXTENSION_ID_BASE + w.node.id,
|
|
3749
|
+
kind: "extension",
|
|
3750
|
+
points: [
|
|
3751
|
+
{ x: round7(w.cx), y: round7(w.cy) },
|
|
3752
|
+
{ x: round7(alignX), y: round7(w.cy) }
|
|
3753
|
+
],
|
|
3754
|
+
dotted: true,
|
|
3755
|
+
length: null,
|
|
3756
|
+
title: titleLabels.tip
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
if (w.children.length === 0) return;
|
|
3760
|
+
const childYs = w.children.map((c) => c.cy);
|
|
3761
|
+
const barTop = Math.min(...childYs);
|
|
3762
|
+
const barBottom = Math.max(...childYs);
|
|
3763
|
+
if (barBottom - barTop > 1e-6) {
|
|
3764
|
+
elements.push({
|
|
3765
|
+
edgeId: PHYLO_CLADEBAR_ID_BASE + w.node.id,
|
|
3766
|
+
kind: "clade-bar",
|
|
3767
|
+
points: [
|
|
3768
|
+
{ x: round7(w.cx), y: round7(barTop) },
|
|
3769
|
+
{ x: round7(w.cx), y: round7(barBottom) }
|
|
3770
|
+
],
|
|
3771
|
+
dotted: false,
|
|
3772
|
+
length: null,
|
|
3773
|
+
title: isRoot ? titleLabels.root : titleLabels.clade
|
|
3774
|
+
});
|
|
3775
|
+
}
|
|
3776
|
+
for (const c of w.children) {
|
|
3777
|
+
elements.push({
|
|
3778
|
+
edgeId: PHYLO_BRANCH_ID_BASE + c.node.id,
|
|
3779
|
+
kind: "branch",
|
|
3780
|
+
points: [
|
|
3781
|
+
{ x: round7(w.cx), y: round7(c.cy) },
|
|
3782
|
+
{ x: round7(c.cx), y: round7(c.cy) }
|
|
3783
|
+
],
|
|
3784
|
+
dotted: false,
|
|
3785
|
+
length: c.inLength,
|
|
3786
|
+
title: branchTitle(c)
|
|
3787
|
+
});
|
|
3788
|
+
}
|
|
3789
|
+
for (const c of w.children) emit(c);
|
|
3790
|
+
};
|
|
3791
|
+
emit(root);
|
|
3792
|
+
let scaleBar = null;
|
|
3793
|
+
if (hasScaleBar) {
|
|
3794
|
+
const stepLen = niceScaleStep(maxDist);
|
|
3795
|
+
const barX = padLeft;
|
|
3796
|
+
const barY = treeBottom + SCALEBAR_ZONE / 2;
|
|
3797
|
+
scaleBar = {
|
|
3798
|
+
length: stepLen,
|
|
3799
|
+
x: round7(barX),
|
|
3800
|
+
y: round7(barY),
|
|
3801
|
+
pxLength: round7(scaleBarPx),
|
|
3802
|
+
valueLabel: trimNumber(stepLen)
|
|
3803
|
+
};
|
|
3804
|
+
elements.push({
|
|
3805
|
+
edgeId: PHYLO_SCALEBAR_ID,
|
|
3806
|
+
kind: "scale-bar",
|
|
3807
|
+
points: [
|
|
3808
|
+
{ x: round7(barX), y: round7(barY) },
|
|
3809
|
+
{ x: round7(barX + scaleBarPx), y: round7(barY) }
|
|
3810
|
+
],
|
|
3811
|
+
dotted: false,
|
|
3812
|
+
length: stepLen,
|
|
3813
|
+
title: `${titleLabels.branchLength}: ${trimNumber(stepLen)}`
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3816
|
+
return { width, height, mode, nodes, elements, scaleBar, showSupport: effectiveShowSupport };
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
// src/phylo/svg.ts
|
|
3820
|
+
var GLYPH_STROKE4 = "#52525b";
|
|
3821
|
+
var LABEL_FILL5 = "#3f3f46";
|
|
3822
|
+
var EDGE_INK6 = "#71717a";
|
|
3823
|
+
var round8 = (n) => Math.round(n * 100) / 100;
|
|
3824
|
+
function nodeSvg3(n, showSupport) {
|
|
3825
|
+
const pieces = [`<title>${xmlEscape(n.title)}</title>`];
|
|
3826
|
+
pieces.push(`<circle cx="${n.cx}" cy="${n.cy}" r="2.5" fill="${GLYPH_STROKE4}"/>`);
|
|
3827
|
+
if (n.labelLines.length > 0) {
|
|
3828
|
+
pieces.push(
|
|
3829
|
+
`<text x="${n.labelLeft}" y="${round8(n.cy + PHYLO_LABEL_FONT * 0.32)}" font-family="${FONT_FAMILY}" font-size="${PHYLO_LABEL_FONT}" fill="${LABEL_FILL5}">${xmlEscape(n.labelLines[0])}</text>`
|
|
3830
|
+
);
|
|
3831
|
+
}
|
|
3832
|
+
if (showSupport && !n.isTip && n.support !== null) {
|
|
3833
|
+
pieces.push(
|
|
3834
|
+
`<text x="${round8(n.cx - 4)}" y="${round8(n.cy - 3)}" text-anchor="end" font-family="${FONT_FAMILY}" font-size="${PHYLO_SUPPORT_FONT}" fill="${LABEL_FILL5}">${xmlEscape(String(n.support))}</text>`
|
|
3835
|
+
);
|
|
3836
|
+
}
|
|
3837
|
+
return `<g data-node-id="n${n.nodeId}">${pieces.join("")}</g>`;
|
|
3838
|
+
}
|
|
3839
|
+
function elementSvg4(el) {
|
|
3840
|
+
const a = el.points[0];
|
|
3841
|
+
const b = el.points[1];
|
|
3842
|
+
const dash = el.dotted ? ` stroke-dasharray="2,3"` : "";
|
|
3843
|
+
const opacity = el.kind === "extension" ? "0.5" : "0.75";
|
|
3844
|
+
return `<g data-edge-id="${el.edgeId}"><title>${xmlEscape(el.title)}</title><line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${EDGE_INK6}" stroke-width="1.5" stroke-opacity="${opacity}"${dash}/></g>`;
|
|
3845
|
+
}
|
|
3846
|
+
function scaleBarLabelSvg(layout) {
|
|
3847
|
+
const bar = layout.scaleBar;
|
|
3848
|
+
if (bar === null) return "";
|
|
3849
|
+
const tick = (x) => `<line x1="${x}" y1="${round8(bar.y - 3)}" x2="${x}" y2="${round8(bar.y + 3)}" stroke="${EDGE_INK6}" stroke-width="1.5" stroke-opacity="0.75"/>`;
|
|
3850
|
+
return tick(bar.x) + tick(round8(bar.x + bar.pxLength)) + `<text x="${round8(bar.x + bar.pxLength / 2)}" y="${round8(bar.y - 6)}" text-anchor="middle" font-family="${FONT_FAMILY}" font-size="${PHYLO_SUPPORT_FONT}" fill="${LABEL_FILL5}">${xmlEscape(bar.valueLabel)}</text>`;
|
|
3851
|
+
}
|
|
3852
|
+
function supportSwatch(x, y) {
|
|
3853
|
+
const cx = round8(x + LEGEND_SWATCH_W / 2);
|
|
3854
|
+
return `<circle cx="${cx}" cy="${y}" r="2.5" fill="${GLYPH_STROKE4}"/><text x="${round8(cx + 5)}" y="${round8(y + PHYLO_SUPPORT_FONT * 0.32)}" font-family="${FONT_FAMILY}" font-size="${PHYLO_SUPPORT_FONT}" fill="${LABEL_FILL5}">95</text>`;
|
|
3855
|
+
}
|
|
3856
|
+
function scaleSwatch(x, y) {
|
|
3857
|
+
return `<line x1="${round8(x)}" y1="${y}" x2="${round8(x + LEGEND_SWATCH_W)}" y2="${y}" stroke="${EDGE_INK6}" stroke-width="1.5" stroke-opacity="0.75"/>`;
|
|
3858
|
+
}
|
|
3859
|
+
function alignedTipSwatch(x, y) {
|
|
3860
|
+
return `<line x1="${round8(x)}" y1="${y}" x2="${round8(x + LEGEND_SWATCH_W)}" y2="${y}" stroke="${EDGE_INK6}" stroke-width="1.5" stroke-opacity="0.5" stroke-dasharray="2,3"/>`;
|
|
3861
|
+
}
|
|
3862
|
+
function phyloLayoutSvg(layout, opts = {}) {
|
|
3863
|
+
const labels = opts.labels ?? PHYLO_SVG_LABELS_EN;
|
|
3864
|
+
const showSupport = layout.showSupport;
|
|
3865
|
+
const parts = [];
|
|
3866
|
+
for (const el of layout.elements) parts.push(elementSvg4(el));
|
|
3867
|
+
parts.push(scaleBarLabelSvg(layout));
|
|
3868
|
+
for (const n of layout.nodes) parts.push(nodeSvg3(n, showSupport));
|
|
3869
|
+
let width = layout.width;
|
|
3870
|
+
let height = layout.height;
|
|
3871
|
+
if (opts.legend !== false && layout.nodes.length > 0) {
|
|
3872
|
+
const entries = [];
|
|
3873
|
+
if (showSupport) entries.push({ swatch: supportSwatch, label: labels.support });
|
|
3874
|
+
if (layout.scaleBar !== null) entries.push({ swatch: scaleSwatch, label: labels.scaleBar });
|
|
3875
|
+
if (layout.elements.some((e) => e.kind === "extension")) {
|
|
3876
|
+
entries.push({ swatch: alignedTipSwatch, label: labels.alignedTip });
|
|
3877
|
+
}
|
|
3878
|
+
const block = legendBlock(entries, layout.height);
|
|
3879
|
+
if (block.svg !== "") {
|
|
3880
|
+
parts.push(block.svg);
|
|
3881
|
+
width = Math.max(width, block.width);
|
|
3882
|
+
height = block.height;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
const w = Math.ceil(width);
|
|
3886
|
+
const h = Math.ceil(height);
|
|
3887
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${xmlEscape(labels.ariaLabel[layout.mode])}">` + parts.join("") + `</svg>`;
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
// src/phylo/render.ts
|
|
3891
|
+
function phyloSvg(input, opts = {}) {
|
|
3892
|
+
const layout = computePhyloLayout(input, {
|
|
3893
|
+
...opts.mode !== void 0 ? { mode: opts.mode } : {},
|
|
3894
|
+
...opts.alignTips !== void 0 ? { alignTips: opts.alignTips } : {},
|
|
3895
|
+
...opts.showSupport !== void 0 ? { showSupport: opts.showSupport } : {},
|
|
3896
|
+
...opts.scaleBar !== void 0 ? { scaleBar: opts.scaleBar } : {},
|
|
3897
|
+
...opts.maxLabelChars !== void 0 ? { maxLabelChars: opts.maxLabelChars } : {},
|
|
3898
|
+
...opts.titleLabels !== void 0 ? { titleLabels: opts.titleLabels } : {}
|
|
3899
|
+
});
|
|
3900
|
+
const svg = phyloLayoutSvg(layout, {
|
|
3901
|
+
...opts.legend === false ? { legend: false } : {},
|
|
3902
|
+
...opts.svgLabels !== void 0 ? { labels: opts.svgLabels } : {}
|
|
3903
|
+
});
|
|
3904
|
+
return { svg, layout };
|
|
3905
|
+
}
|
|
3906
|
+
|
|
1409
3907
|
exports.CHAR_W = CHAR_W;
|
|
3908
|
+
exports.CODE_FONT = CODE_FONT;
|
|
1410
3909
|
exports.ECOMAP_LABELS_EN = ECOMAP_LABELS_EN;
|
|
1411
3910
|
exports.EDGE_FONT = EDGE_FONT;
|
|
1412
3911
|
exports.EDGE_STROKE = EDGE_STROKE;
|
|
3912
|
+
exports.FAULT_TREE_EVENT_KINDS = FAULT_TREE_EVENT_KINDS;
|
|
3913
|
+
exports.FAULT_TREE_SVG_LABELS_EN = FAULT_TREE_SVG_LABELS_EN;
|
|
3914
|
+
exports.FAULT_TREE_TITLE_LABELS_EN = FAULT_TREE_TITLE_LABELS_EN;
|
|
3915
|
+
exports.FISHBONE_LABELS_EN = FISHBONE_LABELS_EN;
|
|
1413
3916
|
exports.FONT_FAMILY = FONT_FAMILY;
|
|
3917
|
+
exports.FT_BUS_ID_BASE = FT_BUS_ID_BASE;
|
|
3918
|
+
exports.FT_CONDITION_ID_BASE = FT_CONDITION_ID_BASE;
|
|
3919
|
+
exports.FT_DROP_ID_BASE = FT_DROP_ID_BASE;
|
|
3920
|
+
exports.FT_LABEL_FONT = FT_LABEL_FONT;
|
|
3921
|
+
exports.FT_LABEL_LINE_H = FT_LABEL_LINE_H;
|
|
3922
|
+
exports.FT_RISER_ID_BASE = FT_RISER_ID_BASE;
|
|
3923
|
+
exports.FT_STEM_ID_BASE = FT_STEM_ID_BASE;
|
|
3924
|
+
exports.FaultTreeValidationError = FaultTreeValidationError;
|
|
3925
|
+
exports.FishboneValidationError = FishboneValidationError;
|
|
3926
|
+
exports.GATE_TYPES = GATE_TYPES;
|
|
1414
3927
|
exports.GENOGRAM_SVG_LABELS_EN = GENOGRAM_SVG_LABELS_EN;
|
|
1415
3928
|
exports.GENOGRAM_TITLE_LABELS_EN = GENOGRAM_TITLE_LABELS_EN;
|
|
1416
3929
|
exports.KINSHIP_EN = KINSHIP_EN;
|
|
1417
3930
|
exports.LABEL_FONT = LABEL_FONT;
|
|
3931
|
+
exports.LABEL_GAP = LABEL_GAP;
|
|
1418
3932
|
exports.LABEL_LINE_H = LABEL_LINE_H;
|
|
3933
|
+
exports.LEGEND_FONT = LEGEND_FONT;
|
|
3934
|
+
exports.LEGEND_GAP = LEGEND_GAP;
|
|
3935
|
+
exports.LEGEND_PAD = LEGEND_PAD;
|
|
3936
|
+
exports.LEGEND_ROW_H = LEGEND_ROW_H;
|
|
3937
|
+
exports.LEGEND_SWATCH_W = LEGEND_SWATCH_W;
|
|
3938
|
+
exports.LIFE_STATUSES = LIFE_STATUSES;
|
|
3939
|
+
exports.MAX_CONDITIONS_PER_INDIVIDUAL = MAX_CONDITIONS_PER_INDIVIDUAL;
|
|
1419
3940
|
exports.NODE_SIZE = NODE_SIZE;
|
|
1420
3941
|
exports.PARENT_REL_ID_BASE = PARENT_REL_ID_BASE;
|
|
3942
|
+
exports.PEDIGREE_SVG_LABELS_EN = PEDIGREE_SVG_LABELS_EN;
|
|
3943
|
+
exports.PEDIGREE_TITLE_LABELS_EN = PEDIGREE_TITLE_LABELS_EN;
|
|
3944
|
+
exports.PED_ADDRESS_FONT = PED_ADDRESS_FONT;
|
|
3945
|
+
exports.PED_CONDITION_FILLS = PED_CONDITION_FILLS;
|
|
3946
|
+
exports.PED_DESCENT_ID_BASE = PED_DESCENT_ID_BASE;
|
|
3947
|
+
exports.PED_GLYPH = PED_GLYPH;
|
|
3948
|
+
exports.PED_LABEL_FONT = PED_LABEL_FONT;
|
|
3949
|
+
exports.PED_LABEL_GAP = PED_LABEL_GAP;
|
|
3950
|
+
exports.PED_LABEL_LINE_H = PED_LABEL_LINE_H;
|
|
3951
|
+
exports.PED_MATING_ID_BASE = PED_MATING_ID_BASE;
|
|
3952
|
+
exports.PED_RISER_ID_BASE = PED_RISER_ID_BASE;
|
|
3953
|
+
exports.PED_SIBBAR_ID_BASE = PED_SIBBAR_ID_BASE;
|
|
3954
|
+
exports.PED_TWINBAR_ID_BASE = PED_TWINBAR_ID_BASE;
|
|
3955
|
+
exports.PHYLO_BRANCH_ID_BASE = PHYLO_BRANCH_ID_BASE;
|
|
3956
|
+
exports.PHYLO_CLADEBAR_ID_BASE = PHYLO_CLADEBAR_ID_BASE;
|
|
3957
|
+
exports.PHYLO_EXTENSION_ID_BASE = PHYLO_EXTENSION_ID_BASE;
|
|
3958
|
+
exports.PHYLO_LABEL_FONT = PHYLO_LABEL_FONT;
|
|
3959
|
+
exports.PHYLO_LABEL_GAP = PHYLO_LABEL_GAP;
|
|
3960
|
+
exports.PHYLO_ROOTSTUB_ID_BASE = PHYLO_ROOTSTUB_ID_BASE;
|
|
3961
|
+
exports.PHYLO_ROW_SLOT = PHYLO_ROW_SLOT;
|
|
3962
|
+
exports.PHYLO_SCALEBAR_ID = PHYLO_SCALEBAR_ID;
|
|
3963
|
+
exports.PHYLO_SUPPORT_FONT = PHYLO_SUPPORT_FONT;
|
|
3964
|
+
exports.PHYLO_SVG_LABELS_EN = PHYLO_SVG_LABELS_EN;
|
|
3965
|
+
exports.PHYLO_TITLE_LABELS_EN = PHYLO_TITLE_LABELS_EN;
|
|
1421
3966
|
exports.PROMOTED_REL_ID_BASE = PROMOTED_REL_ID_BASE;
|
|
3967
|
+
exports.PedigreeValidationError = PedigreeValidationError;
|
|
3968
|
+
exports.PhyloValidationError = PhyloValidationError;
|
|
1422
3969
|
exports.QUALITY_LEXICON_EN = QUALITY_LEXICON_EN;
|
|
1423
3970
|
exports.UNION_NOTATION = UNION_NOTATION;
|
|
1424
3971
|
exports.UNION_REL_ID_BASE = UNION_REL_ID_BASE;
|
|
1425
3972
|
exports.UNION_STATUSES = UNION_STATUSES;
|
|
1426
3973
|
exports.clampLabel = clampLabel;
|
|
1427
3974
|
exports.classifyRelationshipType = classifyRelationshipType;
|
|
3975
|
+
exports.computeFaultTreeLayout = computeFaultTreeLayout;
|
|
1428
3976
|
exports.computeGenogramLayout = computeGenogramLayout;
|
|
3977
|
+
exports.computePedigreeLayout = computePedigreeLayout;
|
|
3978
|
+
exports.computePhyloLayout = computePhyloLayout;
|
|
1429
3979
|
exports.ecomapSvg = ecomapSvg;
|
|
1430
3980
|
exports.estimateTextWidth = estimateTextWidth;
|
|
3981
|
+
exports.faultTreeIssues = faultTreeIssues;
|
|
3982
|
+
exports.faultTreeLayoutSvg = faultTreeLayoutSvg;
|
|
3983
|
+
exports.faultTreeSvg = faultTreeSvg;
|
|
3984
|
+
exports.fishboneSvg = fishboneSvg;
|
|
1431
3985
|
exports.genogramLayoutSvg = genogramLayoutSvg;
|
|
1432
3986
|
exports.genogramSvg = genogramSvg;
|
|
1433
3987
|
exports.latestUnionPerPair = latestUnionPerPair;
|
|
3988
|
+
exports.legendBlock = legendBlock;
|
|
3989
|
+
exports.niceScaleStep = niceScaleStep;
|
|
1434
3990
|
exports.normalizeText = normalizeText;
|
|
1435
3991
|
exports.pathData = pathData;
|
|
3992
|
+
exports.pedigreeIssues = pedigreeIssues;
|
|
3993
|
+
exports.pedigreeLayoutSvg = pedigreeLayoutSvg;
|
|
3994
|
+
exports.pedigreeSvg = pedigreeSvg;
|
|
3995
|
+
exports.phyloIssues = phyloIssues;
|
|
3996
|
+
exports.phyloLayoutSvg = phyloLayoutSvg;
|
|
3997
|
+
exports.phyloSvg = phyloSvg;
|
|
1436
3998
|
exports.qualityLineStyle = qualityLineStyle;
|
|
1437
3999
|
exports.relationshipTypeTokens = relationshipTypeTokens;
|
|
4000
|
+
exports.romanNumeral = romanNumeral;
|
|
4001
|
+
exports.validateFaultTree = validateFaultTree;
|
|
4002
|
+
exports.validatePedigree = validatePedigree;
|
|
4003
|
+
exports.validatePhylo = validatePhylo;
|
|
1438
4004
|
exports.wrapLabel = wrapLabel;
|
|
4005
|
+
exports.wrapLabelBalanced = wrapLabelBalanced;
|
|
1439
4006
|
exports.xmlEscape = xmlEscape;
|
|
1440
4007
|
//# sourceMappingURL=index.cjs.map
|
|
1441
4008
|
//# sourceMappingURL=index.cjs.map
|