@gene-code/core 0.0.1
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 +41 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +542 -0
- package/dist/src/dsl.d.ts +2 -0
- package/dist/src/modules/geneDiagram.d.ts +26 -0
- package/dist/src/modules/pedigreeDiagram.d.ts +21 -0
- package/dist/src/registry.d.ts +5 -0
- package/dist/src/render.d.ts +7 -0
- package/dist/src/types.d.ts +59 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# DSL
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
geneDiagram
|
|
5
|
+
gene <NAME>
|
|
6
|
+
length <N> # total track length in coordinate units; optional (else inferred from max position)
|
|
7
|
+
exon <start> <end> [label]
|
|
8
|
+
variant <label> <pos> <class>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Variant classes → colors** (standard palette):
|
|
12
|
+
|
|
13
|
+
class | color | meaning |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| missense | green | amino-acid substitution |
|
|
16
|
+
| nonsense | red | premature stop |
|
|
17
|
+
| frameshift | purple | indel shifting reading frame |
|
|
18
|
+
| splice | orange | splice-site |
|
|
19
|
+
| synonymous | gray | silent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
- Coordinate units - protein/aa
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Real worls examples
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
geneDiagram
|
|
29
|
+
gene TP53
|
|
30
|
+
length 393
|
|
31
|
+
exon 94 312 DNA-binding
|
|
32
|
+
variant R175H 175 missense
|
|
33
|
+
variant R248Q 248 missense
|
|
34
|
+
variant R273H 273 missense
|
|
35
|
+
variant R213* 213 nonsense
|
|
36
|
+
```
|
|
37
|
+
## SVG layout
|
|
38
|
+
|
|
39
|
+
- width 800, height ~200, padding 40
|
|
40
|
+
- pos → padding + (pos/length) * (800 - 2*padding)
|
|
41
|
+
-
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { splitRows } from "./src/dsl";
|
|
2
|
+
import type { SVGString } from "./src/types";
|
|
3
|
+
export { splitRows };
|
|
4
|
+
/**
|
|
5
|
+
* Parses a DSL string into its diagram AST by dispatching on the first row's
|
|
6
|
+
* diagram keyword. Throws if the keyword is missing or unknown.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parse(dsl: string): unknown;
|
|
9
|
+
/**
|
|
10
|
+
* Renders a DSL string to an SVG. Returns null for empty, unknown, or
|
|
11
|
+
* malformed input (e.g. a partially-typed diagram) so callers can keep the
|
|
12
|
+
* previous output rather than handling thrown errors.
|
|
13
|
+
*/
|
|
14
|
+
export declare function render(dsl: string): SVGString | null;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// src/dsl.ts
|
|
2
|
+
function splitRows(text) {
|
|
3
|
+
return text.split(`
|
|
4
|
+
`).map((row) => row.trim()).filter((row) => row.length > 0);
|
|
5
|
+
}
|
|
6
|
+
function tokenize(text) {
|
|
7
|
+
return text.split(/\s+/);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/render.ts
|
|
11
|
+
var WIDTH_PX = 800;
|
|
12
|
+
var MARGIN_X = 10;
|
|
13
|
+
var MARGIN_Y = 14;
|
|
14
|
+
var INNER_WIDTH = WIDTH_PX - MARGIN_X * 2;
|
|
15
|
+
function renderNode(node) {
|
|
16
|
+
switch (node.type) {
|
|
17
|
+
case "line":
|
|
18
|
+
return `<line x1="${node.x1}" x2="${node.x2}" y1="${node.y1}" y2="${node.y2}" stroke="black" />`;
|
|
19
|
+
case "rect":
|
|
20
|
+
return `<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${node.fill ?? "lightgray"}" stroke="${node.stroke ?? "black"}" />`;
|
|
21
|
+
case "text":
|
|
22
|
+
return `<text x="${node.x}" y="${node.y}" text-anchor="${node.anchor}" alignment-baseline="${node.baseline}" font-size="10">${node.text}</text>`;
|
|
23
|
+
case "circle":
|
|
24
|
+
return `<circle cx="${node.x}" cy="${node.y}" r="${node.radius}" fill="${node.color}" stroke="${node.stroke ?? "none"}" />`;
|
|
25
|
+
case "path":
|
|
26
|
+
return `<path d="${node.d}" fill="${node.fill ?? "none"}" stroke="${node.stroke ?? "none"}" />`;
|
|
27
|
+
case "group":
|
|
28
|
+
return `<g transform="translate(${node.x},${node.y})">
|
|
29
|
+
${node.children.map(renderNode).join("")}
|
|
30
|
+
</g>`;
|
|
31
|
+
default:
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function render(layout) {
|
|
36
|
+
const svg = `<svg viewBox="0 0 ${layout.width} ${layout.height}" width="100%" style="max-width:${layout.width}px" xmlns="http://www.w3.org/2000/svg">
|
|
37
|
+
${layout.nodes.map(renderNode).join("")}
|
|
38
|
+
</svg>`;
|
|
39
|
+
return svg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/modules/geneDiagram.ts
|
|
43
|
+
var Y_OFFSET_PX = 12;
|
|
44
|
+
var FIRST_VARIANT_GAP_PX = 15;
|
|
45
|
+
var BLOCK_HEIGHT_PX = 10;
|
|
46
|
+
var LOLLIPOP_RADIUS_PX = 3;
|
|
47
|
+
var ELBOW_RADIUS_PX = 3;
|
|
48
|
+
var FONT_SIZE_PX = 10;
|
|
49
|
+
var DOMAIN_LABEL_GAP_PX = BLOCK_HEIGHT_PX / 2 + MARGIN_Y - 4;
|
|
50
|
+
function stemPath(x, y0, headX, y1) {
|
|
51
|
+
const dx = headX - x;
|
|
52
|
+
if (Math.abs(dx) < 0.5) {
|
|
53
|
+
return `M ${x} ${y0} L ${x} ${y1}`;
|
|
54
|
+
}
|
|
55
|
+
const dir = Math.sign(dx);
|
|
56
|
+
const jogY = (y0 + y1) / 2;
|
|
57
|
+
const r = Math.min(ELBOW_RADIUS_PX, Math.abs(dx) / 2, Math.abs(y0 - y1) / 4);
|
|
58
|
+
return [
|
|
59
|
+
`M ${x} ${y0}`,
|
|
60
|
+
`L ${x} ${jogY + r}`,
|
|
61
|
+
`Q ${x} ${jogY} ${x + dir * r} ${jogY}`,
|
|
62
|
+
`L ${headX - dir * r} ${jogY}`,
|
|
63
|
+
`Q ${headX} ${jogY} ${headX} ${jogY - r}`,
|
|
64
|
+
`L ${headX} ${y1}`
|
|
65
|
+
].join(" ");
|
|
66
|
+
}
|
|
67
|
+
var VARIANT_COLORS = {
|
|
68
|
+
missense: "#d1495b",
|
|
69
|
+
nonsense: "#5b7c99",
|
|
70
|
+
frameshift: "#e0955b",
|
|
71
|
+
splice: "#5a9367",
|
|
72
|
+
synonymous: "#9a9a9a"
|
|
73
|
+
};
|
|
74
|
+
var DOMAIN_FILL = "#e4e0d8";
|
|
75
|
+
var DOMAIN_STROKE = "#8c8577";
|
|
76
|
+
var STEM_COLOR = "#5c5c5c";
|
|
77
|
+
var HEAD_RING = "#fbfbfb";
|
|
78
|
+
function parseDomain(words) {
|
|
79
|
+
const [start, end, ...rest] = words;
|
|
80
|
+
return {
|
|
81
|
+
start: Number(start),
|
|
82
|
+
end: Number(end),
|
|
83
|
+
...rest.length ? { label: rest.join(" ") } : {}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function parseVariant(words) {
|
|
87
|
+
const [label, pos, varClass] = words;
|
|
88
|
+
if (!label || !pos || !varClass) {
|
|
89
|
+
throw new Error(`Invalid variant row: ${words.join(" ")}`);
|
|
90
|
+
}
|
|
91
|
+
if (Number.isNaN(Number(pos))) {
|
|
92
|
+
throw new Error(`Invalid variant position: ${pos}`);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
label,
|
|
96
|
+
pos: Number(pos),
|
|
97
|
+
class: varClass
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
var CHAR_WIDTH_PX = 6;
|
|
101
|
+
var LABEL_GAP_PX = LOLLIPOP_RADIUS_PX + 2;
|
|
102
|
+
var HEAD_DODGE_PX = 2 * LOLLIPOP_RADIUS_PX + 1;
|
|
103
|
+
function labelWidth(v) {
|
|
104
|
+
return LABEL_GAP_PX + v.label.length * CHAR_WIDTH_PX;
|
|
105
|
+
}
|
|
106
|
+
function packVariants(variants, xScale) {
|
|
107
|
+
const sorted = [...variants].sort((a, b) => a.pos - b.pos);
|
|
108
|
+
const placed = [];
|
|
109
|
+
let prevPos = Number.NaN;
|
|
110
|
+
let prevHeadX = Number.NEGATIVE_INFINITY;
|
|
111
|
+
for (const variant of sorted) {
|
|
112
|
+
const x = xScale(variant.pos);
|
|
113
|
+
const headX = variant.pos === prevPos ? prevHeadX : Math.max(x, prevHeadX + HEAD_DODGE_PX);
|
|
114
|
+
prevPos = variant.pos;
|
|
115
|
+
prevHeadX = headX;
|
|
116
|
+
placed.push({ variant, x, headX, level: 0 });
|
|
117
|
+
}
|
|
118
|
+
for (let i = placed.length - 1;i >= 0; i--) {
|
|
119
|
+
const p = placed[i];
|
|
120
|
+
const labelEnd = p.headX + labelWidth(p.variant);
|
|
121
|
+
let level = 0;
|
|
122
|
+
for (let j = i + 1;j < placed.length; j++) {
|
|
123
|
+
const q = placed[j];
|
|
124
|
+
const samePos = q.variant.pos === p.variant.pos;
|
|
125
|
+
if (samePos || q.headX <= labelEnd) {
|
|
126
|
+
level = Math.max(level, q.level + 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
p.level = level;
|
|
130
|
+
}
|
|
131
|
+
return placed;
|
|
132
|
+
}
|
|
133
|
+
function parse(rows) {
|
|
134
|
+
const diagram = { type: "geneDiagram", domains: [], variants: [] };
|
|
135
|
+
if (rows.length === 0) {
|
|
136
|
+
throw new Error("Empty DSL input");
|
|
137
|
+
}
|
|
138
|
+
for (const row of rows) {
|
|
139
|
+
const [keyword, ...rest] = tokenize(row);
|
|
140
|
+
switch (keyword) {
|
|
141
|
+
case "geneDiagram": {
|
|
142
|
+
diagram.type = "geneDiagram";
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "length": {
|
|
146
|
+
diagram.length = Number(rest[0]);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "gene": {
|
|
150
|
+
diagram.gene = rest.join(" ");
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case "domain": {
|
|
154
|
+
diagram.domains.push(parseDomain(rest));
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "variant": {
|
|
158
|
+
diagram.variants.push(parseVariant(rest));
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
default: {
|
|
162
|
+
throw new Error(`Unknown keyword: ${keyword}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!diagram.length || Number.isNaN(diagram.length)) {
|
|
167
|
+
throw new Error("geneDiagram requires a `length` (protein length in amino acids)");
|
|
168
|
+
}
|
|
169
|
+
return diagram;
|
|
170
|
+
}
|
|
171
|
+
function layout(ast) {
|
|
172
|
+
const nodes = [];
|
|
173
|
+
const xDomain = [1, ast.length];
|
|
174
|
+
const xScale = (x) => MARGIN_X + INNER_WIDTH * (x - xDomain[0]) / (xDomain[1] - xDomain[0]);
|
|
175
|
+
const placedVariants = packVariants(ast.variants, xScale);
|
|
176
|
+
const maxVariantsStack = Math.max(1, ...placedVariants.map((p) => p.level + 1));
|
|
177
|
+
const stackHeight = FIRST_VARIANT_GAP_PX + maxVariantsStack * Y_OFFSET_PX;
|
|
178
|
+
const backboneY = MARGIN_Y + stackHeight;
|
|
179
|
+
nodes.push({
|
|
180
|
+
type: "line",
|
|
181
|
+
x1: xScale(xDomain[0]),
|
|
182
|
+
x2: xScale(xDomain[1]),
|
|
183
|
+
y1: backboneY,
|
|
184
|
+
y2: backboneY
|
|
185
|
+
});
|
|
186
|
+
nodes.push({
|
|
187
|
+
type: "text",
|
|
188
|
+
x: xScale(xDomain[0]),
|
|
189
|
+
y: backboneY + DOMAIN_LABEL_GAP_PX,
|
|
190
|
+
text: String(xDomain[0]),
|
|
191
|
+
anchor: "start",
|
|
192
|
+
baseline: "hanging"
|
|
193
|
+
});
|
|
194
|
+
nodes.push({
|
|
195
|
+
type: "text",
|
|
196
|
+
x: xScale(xDomain[1]),
|
|
197
|
+
y: backboneY + DOMAIN_LABEL_GAP_PX,
|
|
198
|
+
text: String(xDomain[1]),
|
|
199
|
+
anchor: "end",
|
|
200
|
+
baseline: "hanging"
|
|
201
|
+
});
|
|
202
|
+
for (const domain of ast.domains) {
|
|
203
|
+
const x = xScale(domain.start);
|
|
204
|
+
const width = xScale(domain.end) - xScale(domain.start);
|
|
205
|
+
nodes.push({
|
|
206
|
+
type: "group",
|
|
207
|
+
x,
|
|
208
|
+
y: backboneY,
|
|
209
|
+
children: [
|
|
210
|
+
{
|
|
211
|
+
type: "rect",
|
|
212
|
+
x: 0,
|
|
213
|
+
y: -BLOCK_HEIGHT_PX / 2,
|
|
214
|
+
width,
|
|
215
|
+
height: BLOCK_HEIGHT_PX,
|
|
216
|
+
fill: DOMAIN_FILL,
|
|
217
|
+
stroke: DOMAIN_STROKE
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
type: "text",
|
|
221
|
+
x: width / 2,
|
|
222
|
+
y: DOMAIN_LABEL_GAP_PX,
|
|
223
|
+
text: domain.label || "",
|
|
224
|
+
anchor: "middle",
|
|
225
|
+
baseline: "hanging"
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const levelY = (level) => backboneY - FIRST_VARIANT_GAP_PX - (level + 1) * Y_OFFSET_PX;
|
|
231
|
+
const columns = new Map;
|
|
232
|
+
for (const p of placedVariants) {
|
|
233
|
+
const col = columns.get(p.x);
|
|
234
|
+
if (col)
|
|
235
|
+
col.push(p);
|
|
236
|
+
else
|
|
237
|
+
columns.set(p.x, [p]);
|
|
238
|
+
}
|
|
239
|
+
const domainTopY = backboneY - BLOCK_HEIGHT_PX / 2;
|
|
240
|
+
const inDomain = (pos) => ast.domains.some((d) => pos >= d.start && pos <= d.end);
|
|
241
|
+
for (const [x, column] of columns) {
|
|
242
|
+
const headX = column[0].headX;
|
|
243
|
+
const topLevel = Math.max(...column.map((p) => p.level));
|
|
244
|
+
const anchorY = inDomain(column[0].variant.pos) ? domainTopY : backboneY;
|
|
245
|
+
nodes.push({
|
|
246
|
+
type: "path",
|
|
247
|
+
d: stemPath(x, anchorY, headX, levelY(topLevel)),
|
|
248
|
+
stroke: STEM_COLOR
|
|
249
|
+
});
|
|
250
|
+
for (const { variant, level } of column) {
|
|
251
|
+
const headY = levelY(level);
|
|
252
|
+
nodes.push({
|
|
253
|
+
type: "group",
|
|
254
|
+
x: 0,
|
|
255
|
+
y: 0,
|
|
256
|
+
children: [
|
|
257
|
+
{
|
|
258
|
+
type: "circle",
|
|
259
|
+
x: headX,
|
|
260
|
+
y: headY,
|
|
261
|
+
color: VARIANT_COLORS[variant.class],
|
|
262
|
+
radius: LOLLIPOP_RADIUS_PX,
|
|
263
|
+
stroke: HEAD_RING
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
type: "text",
|
|
267
|
+
x: headX + LABEL_GAP_PX,
|
|
268
|
+
y: headY + LOLLIPOP_RADIUS_PX,
|
|
269
|
+
text: variant.label
|
|
270
|
+
}
|
|
271
|
+
]
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const belowBackbone = DOMAIN_LABEL_GAP_PX + FONT_SIZE_PX;
|
|
276
|
+
return {
|
|
277
|
+
width: WIDTH_PX,
|
|
278
|
+
height: backboneY + belowBackbone + MARGIN_Y,
|
|
279
|
+
nodes
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
var geneDiagram = {
|
|
283
|
+
keyword: "geneDiagram",
|
|
284
|
+
parse,
|
|
285
|
+
layout
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// src/modules/pedigreeDiagram.ts
|
|
289
|
+
function coupleId(id1, id2) {
|
|
290
|
+
return [id1, id2].sort().join("-");
|
|
291
|
+
}
|
|
292
|
+
function parse2(rows) {
|
|
293
|
+
const diagram = {};
|
|
294
|
+
if (rows.length === 0) {
|
|
295
|
+
throw new Error("Empty DSL input");
|
|
296
|
+
}
|
|
297
|
+
for (const row of rows) {
|
|
298
|
+
const [keyword, ...rest] = tokenize(row);
|
|
299
|
+
switch (keyword) {
|
|
300
|
+
case "pedigreeDiagram": {
|
|
301
|
+
diagram.type = "pedigreeDiagram";
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
case "couple": {
|
|
305
|
+
const [id1, id2] = rest;
|
|
306
|
+
if (!id1 || !id2) {
|
|
307
|
+
throw new Error(`Invalid couple definition: ${row}`);
|
|
308
|
+
}
|
|
309
|
+
if (!diagram.couples) {
|
|
310
|
+
diagram.couples = [{ id1, id2 }];
|
|
311
|
+
} else {
|
|
312
|
+
diagram.couples.push({ id1, id2 });
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case "node": {
|
|
317
|
+
const [id, sex, phenotype, genotype, childOf] = rest;
|
|
318
|
+
if (!id || !isSex(sex) || !isPhenotype(phenotype) || !isGenotype(genotype)) {
|
|
319
|
+
throw new Error(`Invalid node definition: ${row}`);
|
|
320
|
+
}
|
|
321
|
+
if (!diagram.nodes) {
|
|
322
|
+
diagram.nodes = [{ id, sex, phenotype, genotype, childOf }];
|
|
323
|
+
} else {
|
|
324
|
+
diagram.nodes.push({ id, sex, phenotype, genotype, childOf });
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
default:
|
|
329
|
+
throw new Error(`Unknown keyword: ${keyword}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return diagram;
|
|
333
|
+
}
|
|
334
|
+
function isSex(value) {
|
|
335
|
+
return value === "male" || value === "female";
|
|
336
|
+
}
|
|
337
|
+
function isPhenotype(value) {
|
|
338
|
+
return value === "affected" || value === "unaffected" || value === "unknown";
|
|
339
|
+
}
|
|
340
|
+
function isGenotype(value) {
|
|
341
|
+
return value === "carrier" || value === "noncarrier" || value === "unknown";
|
|
342
|
+
}
|
|
343
|
+
var ROW_HEIGHT_PX = 60;
|
|
344
|
+
var NODE_GAP_PX = 40;
|
|
345
|
+
var SYMBOL_PX = 24;
|
|
346
|
+
function layout2(diagram) {
|
|
347
|
+
const nodes = [];
|
|
348
|
+
const byId = new Map(diagram.nodes.map((n) => [n.id, n]));
|
|
349
|
+
const coupleById = new Map(diagram.couples.map((c) => [coupleId(c.id1, c.id2), c]));
|
|
350
|
+
const genMemo = new Map;
|
|
351
|
+
const gen = (id) => {
|
|
352
|
+
const cached = genMemo.get(id);
|
|
353
|
+
if (cached !== undefined)
|
|
354
|
+
return cached;
|
|
355
|
+
const node = byId.get(id);
|
|
356
|
+
if (!node?.childOf) {
|
|
357
|
+
genMemo.set(id, 0);
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
const parents = coupleById.get(node.childOf);
|
|
361
|
+
if (!parents) {
|
|
362
|
+
throw new Error(`Node ${id} references unknown couple: ${node.childOf}`);
|
|
363
|
+
}
|
|
364
|
+
const g = 1 + Math.max(gen(parents.id1), gen(parents.id2));
|
|
365
|
+
genMemo.set(id, g);
|
|
366
|
+
return g;
|
|
367
|
+
};
|
|
368
|
+
const row = new Map;
|
|
369
|
+
for (const n of diagram.nodes)
|
|
370
|
+
row.set(n.id, gen(n.id));
|
|
371
|
+
for (const c of diagram.couples) {
|
|
372
|
+
const r = Math.max(row.get(c.id1) ?? 0, row.get(c.id2) ?? 0);
|
|
373
|
+
row.set(c.id1, r);
|
|
374
|
+
row.set(c.id2, r);
|
|
375
|
+
}
|
|
376
|
+
const childrenOf = new Map;
|
|
377
|
+
for (const n of diagram.nodes) {
|
|
378
|
+
if (!n.childOf)
|
|
379
|
+
continue;
|
|
380
|
+
(childrenOf.get(n.childOf) ?? childrenOf.set(n.childOf, []).get(n.childOf)).push(n.id);
|
|
381
|
+
}
|
|
382
|
+
const coupleByPartner = new Map;
|
|
383
|
+
for (const c of diagram.couples) {
|
|
384
|
+
coupleByPartner.set(c.id1, c);
|
|
385
|
+
coupleByPartner.set(c.id2, c);
|
|
386
|
+
}
|
|
387
|
+
const x = new Map;
|
|
388
|
+
let cursor = MARGIN_X;
|
|
389
|
+
const slot = () => {
|
|
390
|
+
const at = cursor;
|
|
391
|
+
cursor += SYMBOL_PX + NODE_GAP_PX;
|
|
392
|
+
return at;
|
|
393
|
+
};
|
|
394
|
+
const placed = new Set;
|
|
395
|
+
const placeNode = (id) => {
|
|
396
|
+
if (x.has(id))
|
|
397
|
+
return x.get(id);
|
|
398
|
+
placed.add(id);
|
|
399
|
+
const couple = coupleByPartner.get(id);
|
|
400
|
+
if (couple)
|
|
401
|
+
return placeCouple(couple);
|
|
402
|
+
const at = slot();
|
|
403
|
+
x.set(id, at);
|
|
404
|
+
return at;
|
|
405
|
+
};
|
|
406
|
+
const placeCouple = (c) => {
|
|
407
|
+
const key = coupleId(c.id1, c.id2);
|
|
408
|
+
if (x.has(key))
|
|
409
|
+
return x.get(key);
|
|
410
|
+
x.set(key, MARGIN_X);
|
|
411
|
+
const kids = childrenOf.get(key) ?? [];
|
|
412
|
+
const kidXs = kids.map(placeNode);
|
|
413
|
+
const half = (SYMBOL_PX + NODE_GAP_PX) / 2;
|
|
414
|
+
let mid;
|
|
415
|
+
if (kidXs.length > 0) {
|
|
416
|
+
mid = (Math.min(...kidXs) + Math.max(...kidXs)) / 2;
|
|
417
|
+
x.set(c.id1, mid - half);
|
|
418
|
+
x.set(c.id2, mid + half);
|
|
419
|
+
} else {
|
|
420
|
+
const p1x = slot();
|
|
421
|
+
const p2x = slot();
|
|
422
|
+
x.set(c.id1, p1x);
|
|
423
|
+
x.set(c.id2, p2x);
|
|
424
|
+
mid = (p1x + p2x) / 2;
|
|
425
|
+
}
|
|
426
|
+
x.set(key, mid);
|
|
427
|
+
return mid;
|
|
428
|
+
};
|
|
429
|
+
for (const c of diagram.couples) {
|
|
430
|
+
if ((row.get(c.id1) ?? 0) === Math.min(row.get(c.id1) ?? 0, row.get(c.id2) ?? 0)) {
|
|
431
|
+
placeCouple(c);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const n of diagram.nodes)
|
|
435
|
+
if (!placed.has(n.id))
|
|
436
|
+
placeNode(n.id);
|
|
437
|
+
const pos = new Map;
|
|
438
|
+
for (const n of diagram.nodes) {
|
|
439
|
+
pos.set(n.id, {
|
|
440
|
+
x: x.get(n.id) ?? MARGIN_X,
|
|
441
|
+
y: MARGIN_Y + (row.get(n.id) ?? 0) * ROW_HEIGHT_PX
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
for (const n of diagram.nodes) {
|
|
445
|
+
const p = pos.get(n.id);
|
|
446
|
+
nodes.push(symbol(n, p.x, p.y));
|
|
447
|
+
}
|
|
448
|
+
const SIB_BAR_DROP_PX = ROW_HEIGHT_PX / 2;
|
|
449
|
+
for (const c of diagram.couples) {
|
|
450
|
+
const a = pos.get(c.id1);
|
|
451
|
+
const b = pos.get(c.id2);
|
|
452
|
+
const [left, right] = a.x <= b.x ? [a, b] : [b, a];
|
|
453
|
+
const marriageY = left.y + SYMBOL_PX / 2;
|
|
454
|
+
nodes.push({
|
|
455
|
+
type: "line",
|
|
456
|
+
x1: left.x + SYMBOL_PX,
|
|
457
|
+
y1: marriageY,
|
|
458
|
+
x2: right.x,
|
|
459
|
+
y2: marriageY
|
|
460
|
+
});
|
|
461
|
+
const kids = childrenOf.get(coupleId(c.id1, c.id2)) ?? [];
|
|
462
|
+
if (kids.length === 0)
|
|
463
|
+
continue;
|
|
464
|
+
const midX = (left.x + SYMBOL_PX + right.x) / 2;
|
|
465
|
+
const barY = marriageY + SIB_BAR_DROP_PX;
|
|
466
|
+
const kidCenters = kids.map((kid) => pos.get(kid).x + SYMBOL_PX / 2);
|
|
467
|
+
nodes.push({ type: "line", x1: midX, y1: marriageY, x2: midX, y2: barY });
|
|
468
|
+
nodes.push({
|
|
469
|
+
type: "line",
|
|
470
|
+
x1: Math.min(midX, ...kidCenters),
|
|
471
|
+
y1: barY,
|
|
472
|
+
x2: Math.max(midX, ...kidCenters),
|
|
473
|
+
y2: barY
|
|
474
|
+
});
|
|
475
|
+
for (const kid of kids) {
|
|
476
|
+
const k = pos.get(kid);
|
|
477
|
+
nodes.push({ type: "line", x1: k.x + SYMBOL_PX / 2, y1: barY, x2: k.x + SYMBOL_PX / 2, y2: k.y });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const maxRow = Math.max(0, ...row.values());
|
|
481
|
+
return {
|
|
482
|
+
width: WIDTH_PX,
|
|
483
|
+
height: MARGIN_Y * 2 + maxRow * ROW_HEIGHT_PX + SYMBOL_PX,
|
|
484
|
+
nodes
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function symbol(n, x, y) {
|
|
488
|
+
const affected = n.phenotype === "affected";
|
|
489
|
+
const carrier = n.genotype === "carrier";
|
|
490
|
+
const base = affected ? "black" : "white";
|
|
491
|
+
const r = SYMBOL_PX / 2;
|
|
492
|
+
const children = n.sex === "male" ? [{ type: "rect", x: 0, y: 0, width: SYMBOL_PX, height: SYMBOL_PX, fill: base }] : [{ type: "circle", x: r, y: r, radius: r, color: base, stroke: "black" }];
|
|
493
|
+
if (carrier && !affected) {
|
|
494
|
+
children.push(n.sex === "male" ? { type: "rect", x: 0, y: 0, width: r, height: SYMBOL_PX, fill: "black" } : { type: "path", d: `M ${r} 0 A ${r} ${r} 0 0 0 ${r} ${SYMBOL_PX} Z`, fill: "black", stroke: "black" });
|
|
495
|
+
}
|
|
496
|
+
return { type: "group", x, y, children };
|
|
497
|
+
}
|
|
498
|
+
var pedigreeDiagram = {
|
|
499
|
+
keyword: "pedigreeDiagram",
|
|
500
|
+
parse: parse2,
|
|
501
|
+
layout: layout2
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// src/registry.ts
|
|
505
|
+
var registry = {
|
|
506
|
+
geneDiagram,
|
|
507
|
+
pedigreeDiagram
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// index.ts
|
|
511
|
+
function parse3(dsl) {
|
|
512
|
+
const rows = splitRows(dsl);
|
|
513
|
+
const diagramKeyword = rows[0];
|
|
514
|
+
const module = diagramKeyword ? registry[diagramKeyword] : undefined;
|
|
515
|
+
if (!module) {
|
|
516
|
+
throw new Error(`Unknown diagram type: ${diagramKeyword ?? "(empty)"}`);
|
|
517
|
+
}
|
|
518
|
+
return module.parse(rows);
|
|
519
|
+
}
|
|
520
|
+
function render2(dsl) {
|
|
521
|
+
const rows = splitRows(dsl);
|
|
522
|
+
const diagramKeyword = rows[0];
|
|
523
|
+
if (!diagramKeyword) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
const module = registry[diagramKeyword];
|
|
527
|
+
if (!module) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
const diagram = module.parse(rows);
|
|
532
|
+
const layout3 = module.layout(diagram);
|
|
533
|
+
return render(layout3);
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
export {
|
|
539
|
+
splitRows,
|
|
540
|
+
render2 as render,
|
|
541
|
+
parse3 as parse
|
|
542
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DiagramModule, Layout } from "../types";
|
|
2
|
+
export type GeneDiagram = {
|
|
3
|
+
type: "geneDiagram";
|
|
4
|
+
gene: string;
|
|
5
|
+
/** Protein length in amino acids. Defines the right end of the backbone. */
|
|
6
|
+
length: number;
|
|
7
|
+
domains: Domain[];
|
|
8
|
+
variants: Variant[];
|
|
9
|
+
};
|
|
10
|
+
export interface Domain {
|
|
11
|
+
/** First residue of the domain (amino-acid position, 1-based). */
|
|
12
|
+
start: number;
|
|
13
|
+
/** Last residue of the domain (amino-acid position, 1-based). */
|
|
14
|
+
end: number;
|
|
15
|
+
label?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface Variant {
|
|
18
|
+
label: string;
|
|
19
|
+
/** Residue number the variant affects (amino-acid position, 1-based). */
|
|
20
|
+
pos: number;
|
|
21
|
+
class: VariantClass;
|
|
22
|
+
}
|
|
23
|
+
export type VariantClass = "missense" | "nonsense" | "frameshift" | "splice" | "synonymous";
|
|
24
|
+
export declare function parse(rows: string[]): GeneDiagram;
|
|
25
|
+
export declare function layout(ast: GeneDiagram): Layout;
|
|
26
|
+
export declare const geneDiagram: DiagramModule<GeneDiagram>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DiagramModule, Layout } from "../types";
|
|
2
|
+
export interface PedigreeDiagram {
|
|
3
|
+
type: "pedigreeDiagram";
|
|
4
|
+
couples: Couple[];
|
|
5
|
+
nodes: PedigreeNode[];
|
|
6
|
+
}
|
|
7
|
+
interface Couple {
|
|
8
|
+
id1: string;
|
|
9
|
+
id2: string;
|
|
10
|
+
}
|
|
11
|
+
interface PedigreeNode {
|
|
12
|
+
id: string;
|
|
13
|
+
sex: "male" | "female";
|
|
14
|
+
phenotype: "affected" | "unaffected" | "unknown";
|
|
15
|
+
genotype: "carrier" | "noncarrier" | "unknown";
|
|
16
|
+
childOf?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function parse(rows: string[]): PedigreeDiagram;
|
|
19
|
+
export declare function layout(diagram: PedigreeDiagram): Layout;
|
|
20
|
+
export declare const pedigreeDiagram: DiagramModule<PedigreeDiagram>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { GeneDiagram } from "./modules/geneDiagram";
|
|
2
|
+
import type { PedigreeDiagram } from "./modules/pedigreeDiagram";
|
|
3
|
+
import type { DiagramModule } from "./types";
|
|
4
|
+
export type DiagramType = GeneDiagram["type"] | PedigreeDiagram["type"];
|
|
5
|
+
export declare const registry: Record<DiagramType, DiagramModule<unknown>>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Layout, LayoutNode, SVGString } from "./types";
|
|
2
|
+
export declare const WIDTH_PX = 800;
|
|
3
|
+
export declare const MARGIN_X = 10;
|
|
4
|
+
export declare const MARGIN_Y = 14;
|
|
5
|
+
export declare const INNER_WIDTH: number;
|
|
6
|
+
export declare function renderNode(node: LayoutNode): string;
|
|
7
|
+
export declare function render(layout: Layout): SVGString;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { GeneDiagram } from "./modules/geneDiagram";
|
|
2
|
+
export type Diagram = GeneDiagram;
|
|
3
|
+
export type SVGString = string;
|
|
4
|
+
export type Layout = {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
nodes: LayoutNode[];
|
|
8
|
+
};
|
|
9
|
+
export type LayoutNode = Line | Rect | Text | Circle | Path | Group;
|
|
10
|
+
type Path = {
|
|
11
|
+
type: "path";
|
|
12
|
+
d: string;
|
|
13
|
+
fill?: string;
|
|
14
|
+
stroke?: string;
|
|
15
|
+
};
|
|
16
|
+
type Circle = {
|
|
17
|
+
type: "circle";
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
radius: number;
|
|
21
|
+
color: string;
|
|
22
|
+
stroke?: string;
|
|
23
|
+
};
|
|
24
|
+
type Text = {
|
|
25
|
+
type: "text";
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
text: string;
|
|
29
|
+
baseline?: "hanging" | "base";
|
|
30
|
+
anchor?: "start" | "middle" | "end";
|
|
31
|
+
};
|
|
32
|
+
type Rect = {
|
|
33
|
+
type: "rect";
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
fill?: string;
|
|
39
|
+
stroke?: string;
|
|
40
|
+
};
|
|
41
|
+
type Line = {
|
|
42
|
+
type: "line";
|
|
43
|
+
x1: number;
|
|
44
|
+
x2: number;
|
|
45
|
+
y1: number;
|
|
46
|
+
y2: number;
|
|
47
|
+
};
|
|
48
|
+
type Group = {
|
|
49
|
+
type: "group";
|
|
50
|
+
x: number;
|
|
51
|
+
y: number;
|
|
52
|
+
children: LayoutNode[];
|
|
53
|
+
};
|
|
54
|
+
export interface DiagramModule<AST> {
|
|
55
|
+
keyword: string;
|
|
56
|
+
parse(rows: string[]): AST;
|
|
57
|
+
layout(ast: AST): Layout;
|
|
58
|
+
}
|
|
59
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gene-code/core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Core DSL parser and SVG renderer for gene-code diagrams.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/neznayer/gene-code.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "bun build ./index.ts --outdir dist --target node --format esm && tsc -p tsconfig.build.json",
|
|
27
|
+
"prepublishOnly": "bun run build"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"typescript": "^5"
|
|
31
|
+
}
|
|
32
|
+
}
|