@tekkare/romulus 0.1.254 → 0.1.256

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.
@@ -0,0 +1,732 @@
1
+ <!--
2
+ Vue de ressource `PatientProfile` : la fiche d'un patient de la litterature,
3
+ ou d'un groupe de patients, telle qu'OIP Case Atlas la montre. Sa page
4
+ /patients/[id] la monte, le tiroir d'un projet aussi, dans n'importe quelle
5
+ app du portail.
6
+
7
+ Reprend les onglets « Summary » et « Publications » de la fiche Discovery
8
+ (/omics/patients/[id]). Les onglets Pathologie et Genes lisaient les index
9
+ maladies et genes, que l'app n'interroge pas. L'onglet ouvert est donne par
10
+ `tab` et remonte par `update:tab` : la page l'ecrit dans son URL, le tiroir
11
+ le garde pour lui.
12
+
13
+ `id` : le `record_id` (« 41698193:p1 », celui de la page) ou l'`_id` (celui
14
+ qu'un projet range). La fiche remonte son document par `record` : la page
15
+ en tire son titre d'onglet et ses alertes.
16
+ -->
17
+ <template>
18
+ <div class="oip-patients">
19
+ <RmPageHeader
20
+ :have-return="!!backTo"
21
+ :back-to="backTo"
22
+ :eyebrow="profile ? patientLabelOf(RECORD_TYPE_LABELS, profile.record_type, lang) : fr ? 'Cas de maladies rares' : 'Rare disease cases'"
23
+ :tabs="profile ? headerTabs : []"
24
+ :tabs-label="fr ? 'Sections de la fiche' : 'Record sections'"
25
+ @tab="setTab"
26
+ >
27
+ <template #title>
28
+ <span class="record_title">{{ profile ? diseaseName || (fr ? "Pathologie non précisée" : "Condition not stated") : "" }}</span>
29
+ <slot name="title-link" />
30
+ </template>
31
+ <!-- Ranger la fiche dans le projet actif, par le bouton que l'app prete (cf. useOipResourceHooks). -->
32
+ <template v-if="hooks.projectButton && profile?._id" #actions>
33
+ <component :is="hooks.projectButton" :resource-id="profile._id" type="PatientProfile" :label="diseaseName" />
34
+ </template>
35
+ <div v-if="profile" class="oip_list gap-8">
36
+ <p class="record_subline">{{ subline }}</p>
37
+ <div v-if="headerTags.length" class="oip_row gap-8 wrap">
38
+ <RmTag v-for="tag in headerTags" :key="tag.label" :variant="tag.variant">{{ tag.label }}</RmTag>
39
+ </div>
40
+ </div>
41
+ </RmPageHeader>
42
+
43
+ <RmLoadingState v-if="loading" :lines="6" />
44
+
45
+ <RmErrorState
46
+ v-else-if="error"
47
+ icon="CloudWarning"
48
+ :title="fr ? 'Fiche indisponible' : 'Record unavailable'"
49
+ :text="fr ? 'L\'index des patients n\'a pas répondu.' : 'The patient index did not answer.'"
50
+ :action-label="fr ? 'Réessayer' : 'Retry'"
51
+ @action="reload()"
52
+ />
53
+
54
+ <RmEmptyState
55
+ v-else-if="!profile"
56
+ icon="Prohibit"
57
+ :title="fr ? 'Fiche introuvable' : 'Record not found'"
58
+ :text="fr ? `Aucune fiche ne porte l'identifiant ${id}.` : `No record has the id ${id}.`"
59
+ />
60
+
61
+ <div v-else-if="activeTab === 'publications'" class="oip_row gap-16 record_layout">
62
+ <RmChaptersNav v-if="publicationChapters.length > 1" :chapters="publicationChapters" />
63
+ <div class="oip_list gap-24 record_content">
64
+ <!-- L'article d'ou la fiche a ete lue, avec ses auteurs et son type. -->
65
+ <div id="source-paper">
66
+ <OipPatientsPublications
67
+ v-if="profile.pmid"
68
+ :lang="lang"
69
+ :title="fr ? 'Article source' : 'Source paper'"
70
+ :query="{ terms: { PubMed_ID: [String(profile.pmid)] } }"
71
+ />
72
+ <RmEmptyState v-else icon="Prohibit" :title="fr ? 'Aucun PMID sur cette fiche' : 'No PMID on this record'" />
73
+ </div>
74
+ <!-- Ce qui a ete publie d'autre sur les genes de la fiche : la meme
75
+ relation que les pages genes de Discovery. -->
76
+ <div v-if="geneSymbols.length" id="gene-papers">
77
+ <OipPatientsPublications
78
+ :lang="lang"
79
+ :title="fr ? `Articles sur ${geneSymbols.length > 1 ? 'ces gènes' : 'ce gène'} (${geneSymbols.join(', ')})` : `Papers on ${geneSymbols.length > 1 ? 'these genes' : 'this gene'} (${geneSymbols.join(', ')})`"
80
+ :query="geneQuery"
81
+ />
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div v-else class="oip_row gap-16 record_layout">
87
+ <RmChaptersNav :chapters="chapters" />
88
+
89
+ <div class="oip_list gap-16 record_content">
90
+ <RmSectionCard v-if="summary" id="in-short">
91
+ <template #header>
92
+ <div class="oip_row v-center gap-8">
93
+ <RmIcon name="FileText" :size="18" />
94
+ <h2 class="record_section_title">{{ fr ? "En bref" : "In short" }}</h2>
95
+ </div>
96
+ </template>
97
+ <p class="record_text">{{ summary }}</p>
98
+ </RmSectionCard>
99
+
100
+ <RmSectionCard id="recorded">
101
+ <template #header>
102
+ <div class="oip_row v-center gap-8">
103
+ <RmIcon name="Clipboard" :size="18" />
104
+ <h2 class="record_section_title">{{ fr ? "Ce que l'article rapporte" : "What the paper recorded" }}</h2>
105
+ </div>
106
+ </template>
107
+ <div class="oip_list gap-16">
108
+ <dl class="record_facts">
109
+ <div v-for="fact in facts" :key="fact.title" class="oip_row v-start gap-8">
110
+ <RmIcon :name="fact.icon" :size="16" class="record_fact_icon" />
111
+ <div>
112
+ <dt class="record_fact_label">{{ fact.title }}</dt>
113
+ <dd class="record_fact_value">{{ fact.note ? `${fact.value} · ${fact.note}` : fact.value }}</dd>
114
+ </div>
115
+ </div>
116
+ </dl>
117
+ <p v-if="hasInferredAge" class="record_muted">
118
+ {{
119
+ fr
120
+ ? "* Âge placé dans une tranche, faute d'un âge exact dans l'article."
121
+ : "* We placed this age in a band rather than reading an exact age from the paper."
122
+ }}
123
+ </p>
124
+ </div>
125
+ </RmSectionCard>
126
+
127
+ <RmSectionCard v-if="geneGroups.length" id="genetic-findings">
128
+ <template #header>
129
+ <div class="oip_row v-center gap-8">
130
+ <RmIcon name="Dna" :size="18" />
131
+ <h2 class="record_section_title">{{ fr ? "Résultats génétiques" : "Genetic findings" }}</h2>
132
+ </div>
133
+ </template>
134
+ <!-- Le gene responsable et les genes testes puis ecartes ne se
135
+ lisent pas dans une meme liste : chaque groupe dit son role. -->
136
+ <div class="oip_list gap-16">
137
+ <div v-for="group in geneGroups" :key="group.key" class="oip_list gap-8">
138
+ <p class="record_gene_role" :class="`record_gene_role--${group.key.toLowerCase()}`">{{ group.title }}</p>
139
+ <div v-for="(gene, i) in group.genes" :key="i" class="oip_row v-center gap-8 wrap">
140
+ <span class="record_mono record_gene">{{ gene.gene_hgnc || (fr ? "gène non nommé" : "unnamed gene") }}</span>
141
+ <span v-if="gene.cdna" class="record_mono">{{ gene.cdna }}</span>
142
+ <span v-if="gene.protein" class="record_mono record_muted">{{ gene.protein }}</span>
143
+ <span v-if="gene.zygosity" class="record_muted">· {{ gene.zygosity }}</span>
144
+ <span v-if="gene.gene_source === 'paper'" class="record_muted">
145
+ {{ fr ? "gène déduit de l'article" : "gene inferred from the paper" }}
146
+ </span>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ </RmSectionCard>
151
+
152
+ <RmSectionCard v-if="presentSigns.length || ruledOut.length" id="signs">
153
+ <template #header>
154
+ <div class="oip_row v-center gap-8">
155
+ <RmIcon name="FirstAidKit" :size="18" />
156
+ <h2 class="record_section_title">{{ signsTitle }}</h2>
157
+ <RmTag v-if="presentSigns.length" variant="gray">{{ presentSigns.length }}</RmTag>
158
+ </div>
159
+ </template>
160
+ <div class="oip_list gap-16">
161
+ <!-- Les signes d'un groupe sont ceux du groupe, pas d'un membre. -->
162
+ <p v-if="isCohort" class="record_muted">
163
+ {{
164
+ fr
165
+ ? "Rapportés pour le groupe entier. L'article ne dit pas quel membre présentait quel signe : ils ne se comptent pas par personne."
166
+ : "Reported for the group as a whole. The paper does not say which members had which, so they cannot be counted per person."
167
+ }}
168
+ </p>
169
+ <div v-if="presentSigns.length" class="oip_row gap-8 wrap">
170
+ <RmTag v-for="sign in presentSigns" :key="sign" variant="gray">{{ sign }}</RmTag>
171
+ </div>
172
+ <template v-if="ruledOut.length">
173
+ <p class="record_subtitle">{{ fr ? "Explicitement écartés" : "Specifically ruled out" }}</p>
174
+ <div class="oip_row gap-8 wrap">
175
+ <RmTag v-for="sign in ruledOut" :key="sign" variant="transparent">{{ sign }}</RmTag>
176
+ </div>
177
+ </template>
178
+ </div>
179
+ </RmSectionCard>
180
+
181
+ <RmSectionCard v-if="timeline.length" id="timeline">
182
+ <template #header>
183
+ <div class="oip_row v-center gap-8">
184
+ <RmIcon name="ListBullets" :size="18" />
185
+ <h2 class="record_section_title">{{ fr ? "Chronologie" : "What happened, in order" }}</h2>
186
+ </div>
187
+ </template>
188
+ <ol class="record_timeline">
189
+ <li v-for="(step, i) in timeline" :key="i" class="record_timeline_step">
190
+ <span class="record_timeline_dot" />
191
+ <span class="record_mono record_timeline_when">{{ step.age?.text || "?" }}</span>
192
+ <span class="record_text">{{ step.event }}</span>
193
+ </li>
194
+ </ol>
195
+ </RmSectionCard>
196
+
197
+ <RmSectionCard v-if="hasGraph" id="connections">
198
+ <template #header>
199
+ <div class="oip_row v-center gap-8 wrap">
200
+ <RmIcon name="Graph" :size="18" />
201
+ <h2 class="record_section_title">{{ graphTitle }}</h2>
202
+ <span class="record_muted">
203
+ ·
204
+ {{
205
+ isCohort
206
+ ? fr
207
+ ? "signes, changements génétiques et article du groupe"
208
+ : "the group's signs, gene changes and the paper"
209
+ : fr
210
+ ? "ses signes, ses changements génétiques et l'article"
211
+ : "their signs, gene changes and the paper"
212
+ }}
213
+ </span>
214
+ </div>
215
+ </template>
216
+ <ClientOnly>
217
+ <OipPatientsConnectionsGraph :data="profile" :lang="lang" />
218
+ </ClientOnly>
219
+ </RmSectionCard>
220
+
221
+ <RmSectionCard id="source">
222
+ <template #header>
223
+ <div class="oip_row v-center gap-8">
224
+ <RmIcon name="BookOpen" :size="18" />
225
+ <h2 class="record_section_title">{{ fr ? "Source" : "Where this came from" }}</h2>
226
+ </div>
227
+ </template>
228
+ <div class="oip_list gap-8">
229
+ <p class="record_paper_title">{{ profile.title || (fr ? "Titre non disponible" : "Title not available") }}</p>
230
+ <p class="record_muted">
231
+ {{ profile.journal }}<template v-if="profile.year"> · {{ profile.year }}</template>
232
+ </p>
233
+ <a v-if="pubmedUrl" class="record_link" :href="pubmedUrl" target="_blank" rel="noopener">
234
+ {{ fr ? "Lire sur PubMed" : "Read on PubMed" }}
235
+ <RmIcon name="ArrowSquareOut" :size="12" />
236
+ </a>
237
+
238
+ <RmCallout v-if="clusterNote" tone="warning" icon="Warning" :text="clusterNote" />
239
+
240
+ <div v-if="samePaper.length" class="oip_list gap-8 record_siblings">
241
+ <p class="record_subtitle">
242
+ {{
243
+ isCohort
244
+ ? fr
245
+ ? "Également décrits dans le même article"
246
+ : "Others described in the same paper"
247
+ : fr
248
+ ? "Autres patients du même article"
249
+ : "Other patients in the same paper"
250
+ }}
251
+ </p>
252
+ <div class="oip_row gap-16 wrap">
253
+ <!-- Dans une page, le lien navigue ; dans le tiroir d'un projet, il empile la fiche (cf. useOipResourceNav). -->
254
+ <a
255
+ v-for="sibling in samePaper"
256
+ :key="sibling.record_id"
257
+ :href="nav.href('PatientProfile', sibling.record_id)"
258
+ class="record_link"
259
+ @click="openSibling($event, sibling.record_id)"
260
+ >
261
+ {{ siblingLabel(sibling) }}
262
+ </a>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </RmSectionCard>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ </template>
271
+
272
+ <script setup lang="ts">
273
+ import type { AgeStatement, PatientProfile } from "../../oip-patients/types";
274
+ import {
275
+ COHORT_SETTING_LABELS,
276
+ GENE_ROLE_LABELS,
277
+ OUTCOME_LABELS,
278
+ RECORD_TYPE_LABELS,
279
+ SEX_LABELS,
280
+ patientDiseaseName,
281
+ patientLabel,
282
+ patientLabelOf,
283
+ } from "../../oip-patients/labels";
284
+ import { OIP_PATIENT_INDEXES } from "../../oip-patients/indexes";
285
+ import { PATIENT_KIND } from "../../oip-patients/kinds";
286
+ import { useCaseRecord } from "../../oip-patients/useCaseRecord";
287
+ import { useOipResourceHooks } from "../../composables/useOipResourceHooks";
288
+ import { useOipResourceNav } from "../../composables/useOipResourceNav";
289
+
290
+ type RmTagVariant = "gray" | "blue" | "green" | "orange" | "amber";
291
+
292
+ const props = withDefaults(
293
+ defineProps<{
294
+ /** `record_id` du cas, ou son `_id` (le resourceId d'un projet). */
295
+ id: string;
296
+ type?: string;
297
+ /** Onglet ouvert : « summary » (defaut) ou « publications ». */
298
+ tab?: string;
299
+ /** Retour de la page vers sa liste. Absent (tiroir) : pas de fleche. */
300
+ backTo?: string;
301
+ highlight?: string[];
302
+ lang?: "fr" | "en";
303
+ }>(),
304
+ { type: "PatientProfile", tab: "", backTo: "", highlight: () => [], lang: "fr" },
305
+ );
306
+
307
+ const emit = defineEmits<{
308
+ "update:tab": [tab: string];
309
+ record: [profile: PatientProfile | null];
310
+ }>();
311
+
312
+ const lang = computed(() => props.lang);
313
+ const fr = computed(() => lang.value === "fr");
314
+ const hooks = useOipResourceHooks();
315
+ const nav = useOipResourceNav();
316
+
317
+ /** L'onglet ouvert : celui de la page, sinon le dernier choisi. */
318
+ const activeTab = ref(props.tab === "publications" ? "publications" : "summary");
319
+ watch(
320
+ () => props.tab,
321
+ (tab) => (activeTab.value = tab === "publications" ? "publications" : "summary"),
322
+ );
323
+ function setTab(key: string) {
324
+ activeTab.value = key === "publications" ? "publications" : "summary";
325
+ emit("update:tab", activeTab.value);
326
+ }
327
+ const headerTabs = computed(() =>
328
+ [
329
+ { key: "summary", label: fr.value ? "Résumé" : "Summary" },
330
+ { key: "publications", label: "Publications" },
331
+ ].map((tab) => ({ ...tab, active: tab.key === activeTab.value })),
332
+ );
333
+
334
+ const recordId = computed(() => props.id);
335
+ const { profile: doc, samePaper: siblings, cluster, loading, error, reload } = useCaseRecord(
336
+ OIP_PATIENT_INDEXES.PATIENT_PROFILES,
337
+ recordId,
338
+ PATIENT_KIND.source!,
339
+ );
340
+ const profile = computed(() => doc.value as PatientProfile | null);
341
+ const samePaper = computed(() => siblings.value as PatientProfile[]);
342
+ watch(profile, (value) => emit("record", value));
343
+
344
+ /** Ctrl/Cmd-clic garde le vrai lien, pour un nouvel onglet. */
345
+ function openSibling(event: MouseEvent, recordId: string) {
346
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
347
+ event.preventDefault();
348
+ nav.open("PatientProfile", recordId);
349
+ }
350
+
351
+ const record = computed(() => profile.value?.record);
352
+ const isCohort = computed(() => profile.value?.record_type === "cohort");
353
+ const diseaseName = computed(() => (profile.value ? patientDiseaseName(profile.value) : ""));
354
+ const pubmedUrl = computed(() => (profile.value?.pmid ? `https://pubmed.ncbi.nlm.nih.gov/${profile.value.pmid}/` : undefined));
355
+ const summary = computed(() => record.value?.summary || profile.value?.summary || "");
356
+
357
+ const number = (n: number) => new Intl.NumberFormat(fr.value ? "fr-FR" : "en-US").format(n);
358
+
359
+ /** Qui, et quand : « Le patient · femme · 2026 », comme sur la carte. */
360
+ const subline = computed(() => {
361
+ const p = profile.value;
362
+ if (!p) return "";
363
+ const parts: string[] = [];
364
+ if (isCohort.value) {
365
+ parts.push(
366
+ p.n_patients
367
+ ? fr.value
368
+ ? `Groupe de ${number(p.n_patients)} patients`
369
+ : `Group of ${number(p.n_patients)} patients`
370
+ : patientLabelOf(RECORD_TYPE_LABELS, "cohort", lang.value),
371
+ );
372
+ } else {
373
+ parts.push(patientLabel(p.label));
374
+ if (p.sex) parts.push(patientLabelOf(SEX_LABELS, p.sex, lang.value).toLowerCase());
375
+ }
376
+ if (p.year) parts.push(String(p.year));
377
+ return parts.join(" · ");
378
+ });
379
+
380
+ /** Maladies Orphanet, pays et issue : ce qui situe la fiche avant de la lire. */
381
+ const headerTags = computed(() => {
382
+ const p = profile.value;
383
+ if (!p) return [];
384
+ const tags: { label: string; variant: RmTagVariant }[] = [];
385
+ (p.source_orpha ?? []).forEach((code, i) => {
386
+ tags.push({ label: p.condition_name_orpha?.[i] || `ORPHA:${code}`, variant: "blue" });
387
+ });
388
+ const countries = p.cohort_country_names?.length ? p.cohort_country_names : p.country ? [p.country] : [];
389
+ countries.forEach((country) => tags.push({ label: country, variant: "gray" }));
390
+ if (p.outcome && p.outcome !== "unknown") {
391
+ tags.push({
392
+ label: patientLabelOf(OUTCOME_LABELS, p.outcome, lang.value),
393
+ variant: p.outcome === "alive" ? "green" : p.outcome === "died" ? "orange" : "gray",
394
+ });
395
+ }
396
+ if (p.subject === "animal" && p.species) tags.push({ label: p.species, variant: "amber" });
397
+ return tags;
398
+ });
399
+
400
+ const sexIcon = computed(() => {
401
+ if (profile.value?.sex === "male") return "GenderMale";
402
+ if (profile.value?.sex === "female") return "GenderFemale";
403
+ return "GenderIntersex";
404
+ });
405
+
406
+ /**
407
+ * Les faits a lire d'un coup d'oeil, sans ce que l'article n'a pas dit. Un
408
+ * groupe repond a d'autres questions qu'une personne : combien, comment il a
409
+ * ete constitue, sur quelle duree, jamais « quel sexe ».
410
+ */
411
+ const facts = computed(() => {
412
+ const p = profile.value as PatientProfile | null;
413
+ const r = record.value;
414
+ if (!p) return [];
415
+ const rows: { icon: string; title: string; value: string; note?: string }[] = [];
416
+ const add = (icon: string, title: string, value?: string | number | null, note?: string) => {
417
+ if (value !== null && value !== undefined && value !== "" && value !== "unknown") rows.push({ icon, title, value: String(value), note });
418
+ };
419
+ const addAge = (icon: string, title: string, age?: AgeStatement) => {
420
+ if (!age?.text) return;
421
+ const inferred = age.precision === "class_derived" || age.precision === "assumed_unit";
422
+ add(icon, title, age.text + (inferred ? " *" : ""), age.class_label || undefined);
423
+ };
424
+ const t = (frText: string, enText: string) => (fr.value ? frText : enText);
425
+
426
+ if (isCohort.value) {
427
+ const forGroup = t("donné pour le groupe", "stated for the group");
428
+ add("UsersThree", t("Personnes dans le groupe", "People in this group"), p.n_patients ? number(p.n_patients) : null);
429
+ if (p.cohort_n_female != null || p.cohort_n_male != null) {
430
+ add(
431
+ "User",
432
+ t("Femmes et hommes", "Women and men"),
433
+ fr.value
434
+ ? `${p.cohort_n_female ?? "?"} femmes · ${p.cohort_n_male ?? "?"} hommes`
435
+ : `${p.cohort_n_female ?? "?"} female · ${p.cohort_n_male ?? "?"} male`,
436
+ );
437
+ }
438
+ if (p.cohort_n_died != null) add("Pulse", t("Décès pendant l'étude", "Died during the study"), p.cohort_n_died);
439
+ if (p.cohort_n_alive != null) add("Pulse", t("En vie au dernier suivi", "Alive at last report"), p.cohort_n_alive);
440
+ add("Funnel", t("Recrutement", "How they were found"), p.cohort_ascertainment);
441
+ add("Buildings", t("Cadre de l'étude", "Where they were studied"), patientLabelOf(COHORT_SETTING_LABELS, p.cohort_setting, lang.value));
442
+ add("ListBullets", t("Critères d'inclusion", "Who was included"), p.cohort_inclusion);
443
+ add("Clock", t("Durée du suivi", "Followed for"), p.cohort_follow_up);
444
+ add("Sun", t("Âge aux premiers signes", "Age when signs began"), r?.cohort?.age_onset_stat, forGroup);
445
+ add("Clock", t("Âge au diagnostic", "Age at diagnosis"), r?.cohort?.age_diagnosis_stat, forGroup);
446
+ add("Clock", t("Âge lors de l'étude", "Age when studied"), r?.cohort?.age_at_study_stat, forGroup);
447
+ return rows;
448
+ }
449
+
450
+ add(sexIcon.value, t("Sexe", "Sex"), p.sex && p.sex !== "unknown" ? patientLabelOf(SEX_LABELS, p.sex, lang.value) : null);
451
+ addAge("Sun", t("Premiers signes", "First signs"), r?.age_onset);
452
+ addAge("Clock", t("Diagnostic", "Diagnosed"), r?.age_diagnosis);
453
+ addAge("Clock", t("Âge à la description", "Age when reported"), r?.age_report);
454
+ addAge("Clock", t("Âge au décès", "Age at death"), r?.age_death);
455
+ add("Pulse", t("Issue", "Outcome"), p.outcome && p.outcome !== "unknown" ? patientLabelOf(OUTCOME_LABELS, p.outcome, lang.value) : null);
456
+ add("User", t("Origine", "Background"), p.ethnicity);
457
+ if (r?.consanguineous === true) add("Users", t("Parents", "Parents"), t("apparentés", "related"));
458
+ if (r?.consanguineous === false) add("Users", t("Parents", "Parents"), t("non apparentés", "not related"));
459
+ add("Users", t("Antécédents familiaux", "Family history"), r?.family_history);
460
+ if (p.subject === "animal") add("Prohibit", t("Sujet", "Subject"), p.species);
461
+ return rows;
462
+ });
463
+
464
+ const hasInferredAge = computed(() => facts.value.some((fact) => fact.value.endsWith("*")));
465
+
466
+ /** Les genes ranges par le role que l'article leur donne, dans l'ordre du vocabulaire. */
467
+ const geneGroups = computed(() => {
468
+ const rows = record.value?.genotype ?? [];
469
+ if (!rows.length) return [];
470
+ return Object.keys(GENE_ROLE_LABELS)
471
+ .map((key) => ({
472
+ key,
473
+ title: patientLabelOf(GENE_ROLE_LABELS, key, lang.value),
474
+ genes: rows.filter((g) => (g.interpretation_status || "UNKNOWN_STATUS") === key),
475
+ }))
476
+ .filter((group) => group.genes.length);
477
+ });
478
+
479
+ const presentSigns = computed(() =>
480
+ (record.value?.phenotypes ?? [])
481
+ .filter((s) => s.present !== false)
482
+ .map((s) => s.label || s.hpo_id || "")
483
+ .filter(Boolean),
484
+ );
485
+
486
+ /** Signes ecartes et diagnostics ecartes : les deux disent « ce n'est pas ca ». */
487
+ const ruledOut = computed(() =>
488
+ [
489
+ ...(record.value?.phenotypes ?? []).filter((s) => s.present === false).map((s) => s.label || s.hpo_id || ""),
490
+ ...(record.value?.ruled_out ?? []).map((r) => r.disease || ""),
491
+ ].filter(Boolean),
492
+ );
493
+
494
+ const timeline = computed(() => (record.value?.timeline ?? []).filter((step) => step.event));
495
+
496
+ const signsTitle = computed(() =>
497
+ isCohort.value
498
+ ? fr.value
499
+ ? "Signes rapportés dans le groupe"
500
+ : "Signs and symptoms reported across the group"
501
+ : fr.value
502
+ ? "Signes et symptômes"
503
+ : "Signs and symptoms",
504
+ );
505
+
506
+ /** Un groupe deja decrit ailleurs : le lien est deduit, jamais confirme. */
507
+ const clusterNote = computed(() => {
508
+ const others = ((cluster.value?.pmids ?? []) as string[]).filter((pmid) => pmid !== profile.value?.pmid);
509
+ if (!others.length) return "";
510
+ const plural = others.length > 1;
511
+ return fr.value
512
+ ? `Probablement le même groupe que dans ${others.length} autre${plural ? "s" : ""} publication${plural ? "s" : ""} : leurs effectifs ne s'additionnent pas.`
513
+ : `Likely the same group as ${others.length} other publication${plural ? "s" : ""}: counts across them cannot be added together.`;
514
+ });
515
+
516
+ function siblingLabel(sibling: PatientProfile) {
517
+ if (sibling.record_type === "cohort") {
518
+ return sibling.n_patients
519
+ ? fr.value
520
+ ? `Groupe de ${number(sibling.n_patients)} patients`
521
+ : `Group of ${number(sibling.n_patients)} patients`
522
+ : patientLabelOf(RECORD_TYPE_LABELS, "cohort", lang.value);
523
+ }
524
+ return patientLabel(sibling.label) || sibling.record_id;
525
+ }
526
+
527
+ /** Chaque symbole que la fiche nomme, pour les articles sur ses genes. */
528
+ const geneSymbols = computed(() => [
529
+ ...new Set(
530
+ [
531
+ ...(record.value?.genotype ?? []).map((g) => g.gene_hgnc),
532
+ ...(profile.value?.causative_genes ?? []),
533
+ ...(profile.value?.genes ?? []),
534
+ ].filter(Boolean) as string[],
535
+ ),
536
+ ]);
537
+
538
+ const geneQuery = computed(() => ({
539
+ bool: {
540
+ must: [
541
+ { nested: { path: "genomics", query: { terms: { "genomics.gene_symbol.keyword": geneSymbols.value } } } },
542
+ { term: { delete: false } },
543
+ ],
544
+ },
545
+ }));
546
+
547
+ const publicationChapters = computed(() => [
548
+ { label: fr.value ? "Article source" : "Source paper", value: "source-paper" },
549
+ ...(geneSymbols.value.length ? [{ label: fr.value ? "Sur ces gènes" : "On these genes", value: "gene-papers" }] : []),
550
+ ]);
551
+
552
+ /** Deux noeuds, le sujet et son article, ne montrent rien : le graphe attend des signes ou des genes. */
553
+ const hasGraph = computed(() => (record.value?.phenotypes?.length ?? 0) + (record.value?.genotype?.length ?? 0) > 0);
554
+ const graphTitle = computed(() =>
555
+ isCohort.value
556
+ ? fr.value
557
+ ? "Comment ce groupe se relie"
558
+ : "How this group connects"
559
+ : fr.value
560
+ ? "Comment ce patient se relie"
561
+ : "How this patient connects",
562
+ );
563
+
564
+ /** Seules les sections rendues : un chapitre qui ne mene nulle part est un lien mort. */
565
+ const chapters = computed(() => {
566
+ const list: { label: string; value: string }[] = [];
567
+ if (summary.value) list.push({ label: fr.value ? "En bref" : "In short", value: "in-short" });
568
+ list.push({ label: fr.value ? "Ce que l'article rapporte" : "What the paper recorded", value: "recorded" });
569
+ if (geneGroups.value.length) list.push({ label: fr.value ? "Résultats génétiques" : "Genetic findings", value: "genetic-findings" });
570
+ if (presentSigns.value.length || ruledOut.value.length) list.push({ label: fr.value ? "Signes" : "Signs", value: "signs" });
571
+ if (timeline.value.length) list.push({ label: fr.value ? "Chronologie" : "Timeline", value: "timeline" });
572
+ if (hasGraph.value) list.push({ label: graphTitle.value, value: "connections" });
573
+ list.push({ label: "Source", value: "source" });
574
+ return list;
575
+ });
576
+
577
+ </script>
578
+
579
+ <style scoped>
580
+ .record_layout {
581
+ align-items: flex-start;
582
+ width: 100%;
583
+ }
584
+
585
+ /* Sans min-width, l'item flex ne descend pas sous son enfant le plus large. */
586
+ .record_content {
587
+ flex: 1 1 auto;
588
+ min-width: 0;
589
+ }
590
+
591
+ .record_subline {
592
+ margin: 0;
593
+ color: var(--text-secondary);
594
+ font-size: 14px;
595
+ }
596
+
597
+ .record_title {
598
+ display: block;
599
+ }
600
+
601
+ .record_section_title {
602
+ margin: 0;
603
+ font-size: 16px;
604
+ font-weight: 700;
605
+ }
606
+
607
+ .record_text {
608
+ margin: 0;
609
+ font-size: 14px;
610
+ line-height: 1.55;
611
+ color: var(--text-primary);
612
+ }
613
+
614
+ .record_muted {
615
+ margin: 0;
616
+ font-size: 13px;
617
+ color: var(--text-muted);
618
+ }
619
+
620
+ .record_subtitle {
621
+ margin: 0;
622
+ font-size: 13px;
623
+ font-weight: 600;
624
+ color: var(--text-secondary);
625
+ }
626
+
627
+ .record_facts {
628
+ display: grid;
629
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
630
+ gap: 20px 24px;
631
+ margin: 0;
632
+ }
633
+
634
+ .record_fact_icon {
635
+ flex-shrink: 0;
636
+ margin-top: 2px;
637
+ color: var(--text-muted);
638
+ }
639
+
640
+ .record_fact_label {
641
+ font-size: 12px;
642
+ color: var(--text-muted);
643
+ }
644
+
645
+ .record_fact_value {
646
+ margin: 2px 0 0;
647
+ font-size: 14px;
648
+ color: var(--text-primary);
649
+ }
650
+
651
+ .record_mono {
652
+ font-family: var(--font-mono, ui-monospace, monospace);
653
+ font-size: 13px;
654
+ }
655
+
656
+ .record_gene {
657
+ font-weight: 700;
658
+ }
659
+
660
+ .record_gene_role {
661
+ margin: 0;
662
+ font-size: 13px;
663
+ font-weight: 600;
664
+ }
665
+
666
+ .record_gene_role--causative {
667
+ color: var(--primary);
668
+ }
669
+
670
+ .record_gene_role--rejected {
671
+ color: var(--text-muted);
672
+ }
673
+
674
+ /* Un rail relie les points : l'ordre se lit comme une suite, pas comme une liste. */
675
+ .record_timeline {
676
+ list-style: none;
677
+ margin: 0;
678
+ padding: 0;
679
+ }
680
+
681
+ .record_timeline_step {
682
+ position: relative;
683
+ display: flex;
684
+ align-items: flex-start;
685
+ gap: 12px;
686
+ padding-bottom: 12px;
687
+ }
688
+
689
+ .record_timeline_step:not(:last-child)::before {
690
+ content: "";
691
+ position: absolute;
692
+ left: 4px;
693
+ top: 14px;
694
+ bottom: 0;
695
+ width: 1px;
696
+ background: var(--border-default);
697
+ }
698
+
699
+ .record_timeline_dot {
700
+ flex-shrink: 0;
701
+ width: 9px;
702
+ height: 9px;
703
+ margin-top: 5px;
704
+ border-radius: 50%;
705
+ background: var(--primary);
706
+ }
707
+
708
+ .record_timeline_when {
709
+ flex-shrink: 0;
710
+ min-width: 90px;
711
+ }
712
+
713
+ .record_paper_title {
714
+ margin: 0;
715
+ font-size: 14px;
716
+ font-weight: 600;
717
+ }
718
+
719
+ .record_link {
720
+ display: inline-flex;
721
+ align-items: center;
722
+ gap: 4px;
723
+ width: fit-content;
724
+ font-size: 13px;
725
+ color: var(--primary);
726
+ text-decoration: underline;
727
+ }
728
+
729
+ .record_siblings {
730
+ margin-top: 8px;
731
+ }
732
+ </style>