@tekkare/romulus 0.1.254 → 0.1.255

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.253",
7
+ "version": "0.1.254",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,354 @@
1
+ <!--
2
+ Ce a quoi un sujet se relie : les signes que l'article lui attribue, les
3
+ changements genetiques qu'il rapporte, et l'article lui-meme.
4
+
5
+ Reprend PatientsConnectionsGraph de Discovery (cytoscape + fcose). Un signe
6
+ explicitement ecarte, ou un gene teste puis ecarte, se dessine en pointille,
7
+ jamais en trait plein : un trait plein affirmerait ce que l'article nie.
8
+ -->
9
+ <template>
10
+ <div class="oip_list gap-8">
11
+ <div class="oip_row v-center space-between gap-16 wrap">
12
+ <div class="oip_row v-center gap-8">
13
+ <span class="graph_label">{{ fr ? "Disposition" : "Layout" }}</span>
14
+ <select v-model="layoutName" class="graph_select">
15
+ <option value="fcose">{{ fr ? "Force" : "Force" }}</option>
16
+ <option value="concentric">{{ fr ? "Concentrique" : "Concentric" }}</option>
17
+ <option value="circle">{{ fr ? "Cercle" : "Circle" }}</option>
18
+ <option value="grid">{{ fr ? "Grille" : "Grid" }}</option>
19
+ </select>
20
+ <button type="button" class="graph_fit" @click="fit">{{ fr ? "Recentrer" : "Fit" }}</button>
21
+ </div>
22
+ <div class="graph_legend">
23
+ <button
24
+ v-for="legend in legendEntries"
25
+ :key="legend.type"
26
+ type="button"
27
+ class="graph_leg"
28
+ :class="{ 'graph_leg--dim': hiddenTypes.has(legend.type) }"
29
+ :aria-pressed="!hiddenTypes.has(legend.type)"
30
+ :title="hiddenTypes.has(legend.type) ? `${fr ? 'Afficher' : 'Show'} ${legend.label}` : `${fr ? 'Masquer' : 'Hide'} ${legend.label}`"
31
+ @click="toggleType(legend.type)"
32
+ >
33
+ <span class="graph_dot" :style="{ background: colors[legend.type] }" />
34
+ {{ legend.label }}
35
+ </button>
36
+ </div>
37
+ </div>
38
+ <div ref="container" class="graph_container" />
39
+ </div>
40
+ </template>
41
+
42
+ <script setup lang="ts">
43
+ import type { PatientProfile } from "../../oip-patients/types";
44
+
45
+ type NodeType = "subject" | "paper" | "phenotype" | "gene" | "variant";
46
+
47
+ const props = withDefaults(defineProps<{ data: PatientProfile; lang?: "fr" | "en" }>(), { lang: "fr" });
48
+
49
+ const fr = computed(() => props.lang !== "en");
50
+
51
+ /**
52
+ * Lues dans les jetons plutot qu'ecrites ici : cytoscape veut une valeur
53
+ * concrete qu'il ne sait pas resoudre lui-meme, et la palette suit le theme.
54
+ */
55
+ function token(name: string, fallback: string): string {
56
+ if (typeof window === "undefined") return fallback;
57
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
58
+ }
59
+
60
+ const colors = ref<Record<NodeType, string>>({
61
+ subject: "#1dacfc",
62
+ paper: "#07816f",
63
+ phenotype: "#fda929",
64
+ gene: "#a17def",
65
+ variant: "#f55a8d",
66
+ });
67
+
68
+ function readColors() {
69
+ colors.value = {
70
+ subject: token("--chart-cat-1", colors.value.subject),
71
+ paper: token("--color-primary", colors.value.paper),
72
+ phenotype: token("--chart-cat-3", colors.value.phenotype),
73
+ gene: token("--chart-cat-4", colors.value.gene),
74
+ variant: token("--chart-cat-5", colors.value.variant),
75
+ };
76
+ }
77
+
78
+ const LEGEND_LABELS = computed<Record<NodeType, string>>(() => ({
79
+ subject: fr.value ? "Ce sujet" : "This subject",
80
+ paper: fr.value ? "Article" : "Paper",
81
+ phenotype: fr.value ? "Signe" : "Sign",
82
+ gene: fr.value ? "Gène" : "Gene",
83
+ variant: "Variant",
84
+ }));
85
+
86
+ const container = ref<HTMLElement | null>(null);
87
+ const layoutName = ref("fcose");
88
+ const hiddenTypes = ref<Set<NodeType>>(new Set());
89
+ let cy: any = null;
90
+ let observer: ResizeObserver | null = null;
91
+
92
+ function toggleType(type: NodeType) {
93
+ const next = new Set(hiddenTypes.value);
94
+ if (next.has(type)) next.delete(type);
95
+ else next.add(type);
96
+ hiddenTypes.value = next;
97
+ }
98
+
99
+ function fit() {
100
+ cy?.fit(undefined, 40);
101
+ }
102
+
103
+ const elements = computed(() => {
104
+ const record = props.data.record;
105
+ const subjectId = props.data.record_id;
106
+ const edgeLabel = (frText: string, enText: string) => (fr.value ? frText : enText);
107
+ const items: any[] = [{ data: { id: subjectId, label: props.data.label || subjectId, nodeType: "subject", size: 46 } }];
108
+
109
+ if (props.data.pmid) {
110
+ items.push({ data: { id: `pmid:${props.data.pmid}`, label: props.data.pmid, nodeType: "paper", size: 34 } });
111
+ items.push({ data: { source: subjectId, target: `pmid:${props.data.pmid}`, label: edgeLabel("décrit dans", "reported in"), dashed: false } });
112
+ }
113
+
114
+ for (const sign of record?.phenotypes ?? []) {
115
+ const id = sign.hpo_id || sign.label;
116
+ if (!id) continue;
117
+ items.push({ data: { id, label: sign.label || id, nodeType: "phenotype", size: 26 } });
118
+ items.push({
119
+ data: {
120
+ source: subjectId,
121
+ target: id,
122
+ label: sign.present === false ? edgeLabel("absent", "not present") : edgeLabel("présente", "has"),
123
+ dashed: sign.present === false,
124
+ },
125
+ });
126
+ }
127
+
128
+ for (const gene of record?.genotype ?? []) {
129
+ if (!gene.gene_hgnc) continue;
130
+ const rejected = gene.interpretation_status === "REJECTED";
131
+ items.push({ data: { id: `gene:${gene.gene_hgnc}`, label: gene.gene_hgnc, nodeType: "gene", size: 32 } });
132
+ items.push({ data: { source: subjectId, target: `gene:${gene.gene_hgnc}`, label: edgeLabel("gène", "gene"), dashed: rejected } });
133
+
134
+ const change = gene.cdna || gene.protein;
135
+ if (change) {
136
+ const id = `variant:${gene.gene_hgnc}:${change}`;
137
+ items.push({ data: { id, label: change, nodeType: "variant", size: 26 } });
138
+ items.push({ data: { source: `gene:${gene.gene_hgnc}`, target: id, label: gene.zygosity || edgeLabel("variant", "change"), dashed: rejected } });
139
+ }
140
+ }
141
+ return items;
142
+ });
143
+
144
+ /** Seuls les types presents, calcules avant le masquage : un type masque garde sa pastille. */
145
+ const legendEntries = computed(() => {
146
+ const present = new Set(elements.value.filter((item) => item.data.id).map((item) => item.data.nodeType as NodeType));
147
+ return (Object.keys(LEGEND_LABELS.value) as NodeType[])
148
+ .filter((type) => present.has(type))
149
+ .map((type) => ({ type, label: LEGEND_LABELS.value[type] }));
150
+ });
151
+
152
+ /**
153
+ * Masquer par type sans toucher a la disposition : les positions restent, le
154
+ * retour remet tout en place. Une arete part avec ses extremites.
155
+ */
156
+ function applyFilter() {
157
+ if (!cy) return;
158
+ cy.batch(() => {
159
+ cy.elements().show();
160
+ const hidden = cy.nodes().filter((node: any) => hiddenTypes.value.has(node.data("nodeType")));
161
+ hidden.connectedEdges().hide();
162
+ hidden.hide();
163
+ });
164
+ }
165
+
166
+ const style = () => {
167
+ const text = token("--text-primary", "#0f172a");
168
+ const muted = token("--text-muted", "#64748b");
169
+ const line = token("--border-strong", "#94a3b8");
170
+ const lineSoft = token("--border-default", "#cbd5e1");
171
+ return [
172
+ {
173
+ selector: "node",
174
+ style: {
175
+ label: "data(label)",
176
+ "text-valign": "center",
177
+ "text-halign": "center",
178
+ "font-size": "10px",
179
+ "font-family": "Inter, sans-serif",
180
+ "text-margin-y": -12,
181
+ width: "data(size)",
182
+ height: "data(size)",
183
+ color: text,
184
+ "font-weight": 600,
185
+ },
186
+ },
187
+ ...(Object.entries(colors.value) as [NodeType, string][]).map(([type, color]) => ({
188
+ selector: `node[nodeType="${type}"]`,
189
+ style: { "background-color": color },
190
+ })),
191
+ {
192
+ selector: "edge",
193
+ style: {
194
+ width: 1.5,
195
+ "line-color": line,
196
+ "curve-style": "bezier",
197
+ "font-size": "8px",
198
+ color: muted,
199
+ label: "data(label)",
200
+ "text-rotation": "autorotate",
201
+ },
202
+ },
203
+ { selector: "edge[?dashed]", style: { "line-style": "dashed", "line-color": lineSoft } },
204
+ ];
205
+ };
206
+
207
+ /**
208
+ * Sans arete, une disposition de force n'a rien a resoudre et empile les noeuds
209
+ * hors champ : la grille est le repli honnete.
210
+ */
211
+ function layoutOptions(hasEdges: boolean) {
212
+ const shared = { padding: 40, stop: () => fit() };
213
+ if (!hasEdges) return { ...shared, name: "grid", avoidOverlap: true };
214
+ if (layoutName.value === "concentric") {
215
+ return { ...shared, name: "concentric", concentric: (node: any) => node.degree(), levelWidth: () => 2, minNodeSpacing: 60 };
216
+ }
217
+ if (layoutName.value === "circle" || layoutName.value === "grid") {
218
+ return { ...shared, name: layoutName.value, spacingFactor: 1.5 };
219
+ }
220
+ return {
221
+ ...shared,
222
+ name: "fcose",
223
+ quality: "proof",
224
+ animate: true,
225
+ animationDuration: 500,
226
+ nodeDimensionsIncludeLabels: true,
227
+ nodeRepulsion: () => 20000,
228
+ idealEdgeLength: () => 150,
229
+ gravity: 0.3,
230
+ };
231
+ }
232
+
233
+ function relayout() {
234
+ if (!cy) return;
235
+ cy.layout(layoutOptions(cy.edges(":visible").length > 0)).run();
236
+ }
237
+
238
+ let fcoseRegistered = false;
239
+
240
+ async function draw() {
241
+ if (!container.value) return;
242
+ const cytoscape = (await import("cytoscape")).default;
243
+ if (!fcoseRegistered) {
244
+ cytoscape.use((await import("cytoscape-fcose")).default);
245
+ fcoseRegistered = true;
246
+ }
247
+ readColors();
248
+ cy?.destroy();
249
+
250
+ const els = elements.value;
251
+ cy = cytoscape({
252
+ container: container.value,
253
+ elements: els,
254
+ style: style() as any,
255
+ layout: layoutOptions(els.some((element) => !element.data.id)) as any,
256
+ minZoom: 0.2,
257
+ maxZoom: 3,
258
+ wheelSensitivity: 0.3,
259
+ });
260
+ applyFilter();
261
+ cy.one("layoutstop", fit);
262
+ }
263
+
264
+ onMounted(async () => {
265
+ await draw();
266
+ // Cytoscape mesure son canevas une fois : le rail et la barre laterale
267
+ // changent la largeur apres le montage.
268
+ if (container.value && typeof ResizeObserver !== "undefined") {
269
+ observer = new ResizeObserver(() => cy?.resize());
270
+ observer.observe(container.value);
271
+ }
272
+ });
273
+
274
+ watch(() => props.data.record_id, draw);
275
+ watch(locale, draw);
276
+ watch(layoutName, relayout);
277
+ watch(hiddenTypes, applyFilter);
278
+
279
+ onBeforeUnmount(() => {
280
+ observer?.disconnect();
281
+ cy?.destroy();
282
+ });
283
+ </script>
284
+
285
+ <style scoped>
286
+ .graph_container {
287
+ width: 100%;
288
+ max-width: 100%;
289
+ height: 440px;
290
+ overflow: hidden;
291
+ }
292
+
293
+ .graph_label {
294
+ font-size: 13px;
295
+ color: var(--text-muted);
296
+ }
297
+
298
+ .graph_select {
299
+ padding: 2px 6px;
300
+ font-size: 12px;
301
+ color: var(--text-primary);
302
+ background: transparent;
303
+ border: 1px solid var(--border-default);
304
+ border-radius: 6px;
305
+ }
306
+
307
+ .graph_fit {
308
+ padding: 0;
309
+ font-size: 13px;
310
+ color: var(--primary);
311
+ text-decoration: underline;
312
+ background: none;
313
+ border: 0;
314
+ cursor: pointer;
315
+ }
316
+
317
+ .graph_legend {
318
+ display: flex;
319
+ flex-wrap: wrap;
320
+ gap: 8px;
321
+ font-size: 12px;
322
+ }
323
+
324
+ .graph_leg {
325
+ display: flex;
326
+ align-items: center;
327
+ gap: 4px;
328
+ padding: 3px 8px;
329
+ font-size: 12px;
330
+ color: var(--text-primary);
331
+ background: none;
332
+ border: 0;
333
+ border-radius: 999px;
334
+ cursor: pointer;
335
+ transition:
336
+ background 0.15s,
337
+ opacity 0.15s;
338
+ }
339
+
340
+ .graph_leg:hover {
341
+ background: var(--bg-surface-2);
342
+ }
343
+
344
+ .graph_leg--dim {
345
+ opacity: 0.4;
346
+ }
347
+
348
+ .graph_dot {
349
+ display: inline-block;
350
+ width: 10px;
351
+ height: 10px;
352
+ border-radius: 50%;
353
+ }
354
+ </style>