/* Eno.Health Console — Knowledge base: Exposome registry + mappings. Exposome factors are grouped under Exposome Systems (parent: null), mirroring Phenotype Systems. An Administrator manages the definitions and the four mapping surfaces: wearable/sensor signals, phenotypes, genetics and biomarkers. */ const X = window.ENO; function exposomeDomainPill(domain) { return h(Pill, { tone: X.exposomeDomainTone(domain), soft: true }, X.exposomeDomainLabel(domain)); } /* Owns the editable copy of the exposome registry and the mapping API. */ function useExposomeData() { const [factors, setFactors] = useState(() => X.EXPOSOME_FACTORS.map((e) => ({ ...e, signals: [...e.signals], phenotypes: [...e.phenotypes], genes: [...e.genes], biomarkers: [...e.biomarkers], refs: (e.refs || []).map((r) => ({ ...r })), }))); const [genes, setGenes] = useState(() => X.EXPOSOME_GENES.map((g) => ({ ...g }))); const toast = useToast(); const api = { patch: (id, patch) => setFactors((xs) => xs.map((e) => e.id === id ? { ...e, ...patch } : e)), toggle: (id, key, val) => setFactors((xs) => xs.map((e) => e.id === id ? { ...e, [key]: e[key].includes(val) ? e[key].filter((v) => v !== val) : [...e[key], val] } : e)), add: (factor) => { setFactors((xs) => [factor, ...xs]); toast((factor.parent ? "Exposome factor" : "Exposome system") + " “" + factor.label + "” created", "ok"); }, remove: (id) => { const f = factors.find((e) => e.id === id); const kids = factors.filter((e) => e.parent === id); // promote any children to top-level systems, then drop the factor setFactors((xs) => xs.filter((e) => e.id !== id).map((e) => e.parent === id ? { ...e, parent: null } : e)); const kidNote = kids.length ? " · " + kids.length + " factor" + (kids.length === 1 ? "" : "s") + " promoted to top-level systems" : ""; toast("“" + (f ? f.label : id) + "” deleted" + kidNote); }, addGene: (g) => { setGenes((xs) => [g, ...xs]); toast("Gene " + g.symbol + " added to registry", "ok"); }, }; return { factors, genes, api }; } /* ======================================================================== */ /* EXPOSOME — main view */ /* ======================================================================== */ function ExposomeView() { const { factors, genes, api } = useExposomeData(); const toast = useToast(); const [q, setQ] = useState(""); const [domain, setDomain] = useState("all"); const [sel, setSel] = useState(null); const [creating, setCreating] = useState(null); // { parent } seed for new factor/system const [removing, setRemoving] = useState(null); const [ctx, setCtx] = useState(null); // { pos, factor } right-click menu const [exporting, setExporting] = useState(false); const [collapsed, setCollapsed] = useState(() => new Set()); const toggleCollapse = (id) => setCollapsed((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const childrenOf = (id) => factors.filter((e) => e.parent === id); const matches = (e) => (domain === "all" || e.domain === domain) && (!q || (e.label + " " + e.category + " " + e.id).toLowerCase().includes(q.toLowerCase())); // Display order: each system followed by its visible children (unless collapsed). const visible = []; factors.filter((e) => !e.parent).forEach((top) => { const kids = childrenOf(top.id); const topMatch = matches(top); const kidMatch = kids.filter(matches); if (topMatch || kidMatch.length) { const isCollapsed = collapsed.has(top.id) && !q; visible.push({ e: top, depth: 0, collapsed: isCollapsed }); if (!isCollapsed) (topMatch ? kids : kidMatch).forEach((k) => visible.push({ e: k, depth: 1 })); } }); const systemCount = factors.filter((e) => !e.parent).length; const factorCount = factors.filter((e) => e.parent).length; const selFactor = factors.find((e) => e.id === sel); const mappedSensors = factors.filter((e) => e.parent && e.signals.length).length; const mappedBio = factors.filter((e) => e.parent && e.biomarkers.length).length; const head = ["Exposome factor", "Domain", "Category", "Sensors", "Phenotypes", "Genes", "Biomarkers", "Status"]; const mapCell = (n) => n ? h("span", { className: "mono" }, n) : h("span", { className: "dim" }, "0"); return h("div", { className: "view" }, h(PageHead, { title: "Exposome", sub: "The environmental exposures Eno tracks alongside the genome — grouped into systems and mapped to sensors, phenotypes, genes and biomarkers" }), h("div", { className: "ref-banner" }, h(Icon, { name: "subtree", size: 16, className: "dim" }), h("div", null, "Exposome factors are grouped under ", h("strong", null, "Exposome Systems"), " — e.g. ", h("strong", null, "Particulate Matter"), " under ", h("strong", null, "Air Quality"), ". Each factor is mapped to the ", h("strong", null, "sensor signals"), " that observe it, the ", h("strong", null, "phenotypes"), " it modulates, the ", h("strong", null, "genes"), " that govern susceptibility, and the ", h("strong", null, "biomarkers"), " that register its effect.")), h("div", { className: "stat-grid stat-grid-4" }, h(Card, { pad: false }, h(Stat, { label: "Exposome systems", value: systemCount, icon: "subtree" })), h(Card, { pad: false }, h(Stat, { label: "Exposome factors", value: factorCount, icon: "globe" })), h(Card, { pad: false }, h(Stat, { label: "Sensor-mapped", value: mappedSensors, tone: "ok", icon: "activity" })), h(Card, { pad: false }, h(Stat, { label: "Biomarker-mapped", value: mappedBio, icon: "database" }))), h(Card, { pad: false, title: X.EXPOSOME_DB.name, subtitle: "Curated by " + X.EXPOSOME_DB.curatedBy + " · v" + X.EXPOSOME_DB.version, actions: h(React.Fragment, null, h(Button, { variant: "default", size: "sm", icon: "download", onClick: () => setExporting(true) }, "Export"), h(Button, { variant: "primary", size: "sm", icon: "plus", onClick: () => setCreating({ parent: "" }) }, "New exposome")) }, h("div", { className: "toolbar" }, h("div", { className: "search" }, h(Icon, { name: "search", size: 16 }), h("input", { className: "search-input", placeholder: "Search factor, category or identifier…", value: q, onChange: (e) => setQ(e.target.value) })), h("span", { className: "toolbar-count" }, visible.length, " of ", factors.length)), h("div", { className: "table-wrap flush" }, h("table", { className: "table" }, h("thead", null, h("tr", null, head.map((c, i) => h("th", { key: c, className: i >= 3 && i <= 6 ? "ta-c" : "" }, c)))), h("tbody", null, visible.map(({ e, depth, collapsed: col }) => { const kids = childrenOf(e.id); return h("tr", { key: e.id, className: "row-click" + (depth ? " pheno-child" : ""), onClick: () => setSel(e.id), onContextMenu: (ev) => { ev.preventDefault(); setCtx({ pos: { x: ev.clientX, y: ev.clientY }, factor: e }); } }, h("td", null, h("div", { className: "pheno-cell" + (depth ? " is-sub" : "") }, depth ? h("span", { className: "pheno-branch", "aria-hidden": "true" }) : null, h("div", { className: "pheno-cell-main" }, h("span", { className: "pheno-name" }, !depth && kids.length ? h("button", { className: "pheno-disclose", title: col ? "Expand" : "Collapse", onClick: (ev) => { ev.stopPropagation(); toggleCollapse(e.id); } }, h(Icon, { name: col ? "chevronR" : "chevron", size: 14 })) : null, e.label, !depth && kids.length ? h("span", { className: "pheno-subbadge" }, kids.length, " factor", kids.length === 1 ? "" : "s") : null), e.desc ? h("span", { className: "pheno-desc" }, e.desc) : null))), h("td", null, exposomeDomainPill(e.domain)), h("td", { className: "dim" }, e.category), h("td", { className: "ta-c" }, depth ? mapCell(e.signals.length) : h("span", { className: "dim" }, "—")), h("td", { className: "ta-c" }, depth ? mapCell(e.phenotypes.length) : h("span", { className: "dim" }, "—")), h("td", { className: "ta-c" }, depth ? mapCell(e.genes.length) : h("span", { className: "dim" }, "—")), h("td", { className: "ta-c" }, depth ? mapCell(e.biomarkers.length) : h("span", { className: "dim" }, "—")), h("td", null, e.status === "active" ? h(Pill, { tone: "ok", soft: true }, "active") : h(Pill, { tone: "neutral", soft: true }, "draft"))); }), !visible.length && h("tr", null, h("td", { colSpan: 8 }, h(Empty, { icon: "scan", title: "No exposome factors", sub: "Adjust your search or domain filter." }))))))), h(ExposomeDrawer, { factor: selFactor, factors, genes, api, onClose: () => setSel(null), onDelete: (e) => { setSel(null); setRemoving(e); } }), ctx && h(window.ContextMenu, { pos: ctx.pos, onClose: () => setCtx(null), items: [ { label: ctx.factor.status === "active" ? "Unpublish" : "Publish", icon: ctx.factor.status === "active" ? "clock" : "check", onClick: () => { const pub = ctx.factor.status !== "active"; api.patch(ctx.factor.id, { status: pub ? "active" : "draft" }); toast("“" + ctx.factor.label + "” " + (pub ? "published — now live" : "unpublished — moved to draft"), "ok"); } }, { label: "Export…", icon: "download", onClick: () => toast("Exporting “" + ctx.factor.label + "” definition (mock)") }, { sep: true }, { label: ctx.factor.parent ? "Delete factor" : "Delete system", icon: "trash", danger: true, onClick: () => { setSel(null); setRemoving(ctx.factor); } }, ] }), h(NewExposomeModal, { seed: creating, existing: factors, onClose: () => setCreating(null), onCreate: (e) => { api.add(e); setCreating(null); setSel(e.id); } }), h(ExportExposomeModal, { open: exporting, factors, onClose: () => setExporting(false) }), h(DeleteExposomeModal, { factor: removing, children: removing ? childrenOf(removing.id) : [], onClose: () => setRemoving(null), onConfirm: () => { api.remove(removing.id); setRemoving(null); } })); } /* ======================================================================== */ /* DRAWER — definition + four mapping surfaces */ /* ======================================================================== */ function ExposomeDrawer({ factor, factors, genes, api, onClose, onDelete }) { const toast = useToast(); const [customGene, setCustomGene] = useState(""); if (!factor) return h(Drawer, { open: false, onClose }); const f = factor; const isSystem = !f.parent; const hasChildren = factors.some((e) => e.parent === f.id); const systemOpts = factors.filter((e) => !e.parent && e.id !== f.id); const addCustomGene = () => { const sym = customGene.trim().toUpperCase(); if (!sym) return; if (!genes.find((g) => g.id === sym)) api.addGene({ id: sym, symbol: sym, name: "Custom gene", category: "Custom" }); api.toggle(f.id, "genes", sym); setCustomGene(""); }; const mappingSections = h(React.Fragment, null, // 1 — sensor / wearable signals h("div", { className: "refsec" }, h("p", { className: "refsec-title" }, "Wearable / sensor signal mappings"), h("p", { className: "field-hint", style: { margin: "-3px 0 4px" } }, "Device streams that observe this exposure in real time."), h("div", { className: "chip-set" }, X.SIGNAL_TYPES.map((s) => { const on = f.signals.includes(s.key); return h("button", { key: s.key, type: "button", className: "chip-tog" + (on ? " on" : ""), onClick: () => api.toggle(f.id, "signals", s.key) }, h(Icon, { name: on ? "check" : "plus", size: 13 }), s.label, h("span", { className: "dim", style: { fontSize: "11px" } }, s.unit)); }))), // 2 — phenotypes h("div", { className: "refsec" }, h("div", { className: "refsec-head" }, h("p", { className: "refsec-title" }, "Phenotype mappings"), h("span", { className: "toolbar-count" }, f.phenotypes.length, " linked")), h("p", { className: "field-hint", style: { margin: "-3px 0 7px" } }, "Functional-Medicine phenotypes this exposure modulates."), h(window.LinkCombo, { all: X.FM_PHENOTYPES.map((p) => ({ id: p.id, label: p.label, meta: p.parent ? X.phenoLabel(p.parent) : "Phenotype System", search: p.label + " " + p.id + " " + (p.parent ? X.phenoLabel(p.parent) : "") })), selected: f.phenotypes, placeholder: "Search phenotype to map…", emptyText: "No phenotypes mapped yet.", noMatchText: "No matching phenotype", onAdd: (id) => api.toggle(f.id, "phenotypes", id), onRemove: (id) => api.toggle(f.id, "phenotypes", id) })), // 3 — genetics h("div", { className: "refsec" }, h("div", { className: "refsec-head" }, h("p", { className: "refsec-title" }, "Genetics mappings"), h("span", { className: "toolbar-count" }, f.genes.length, " linked")), h("p", { className: "field-hint", style: { margin: "-3px 0 7px" } }, "Genes whose variants alter susceptibility to this exposure."), h(window.LinkCombo, { all: genes.map((g) => ({ id: g.id, label: g.symbol, meta: g.name + " · " + g.category, search: g.symbol + " " + g.name + " " + g.category })), selected: f.genes, mono: true, placeholder: "Search gene by symbol or name…", emptyText: "No genes mapped yet.", noMatchText: "No matching gene", onAdd: (id) => api.toggle(f.id, "genes", id), onRemove: (id) => api.toggle(f.id, "genes", id) }), h("div", { className: "link-add" }, h("input", { className: "input mono", placeholder: "Custom gene symbol, e.g. NRF2", value: customGene, onChange: (e) => setCustomGene(e.target.value) }), h(Button, { variant: "default", icon: "plus", disabled: !customGene.trim(), onClick: addCustomGene }, "Add custom"))), // 4 — biomarkers h("div", { className: "refsec" }, h("div", { className: "refsec-head" }, h("p", { className: "refsec-title" }, "Biomarker mappings"), h("span", { className: "toolbar-count" }, f.biomarkers.length, " linked")), h("p", { className: "field-hint", style: { margin: "-3px 0 7px" } }, "Lab biomarkers that register this exposure's biological effect."), h(window.LinkCombo, { all: X.BIOMARKERS.map((b) => ({ id: b.id, label: b.name, meta: (b.specimen || "") + (b.unit ? " · " + b.unit : ""), search: b.name + " " + (b.specimen || "") + " " + (b.unit || "") })), selected: f.biomarkers, placeholder: "Search biomarker to map…", emptyText: "No biomarkers mapped yet.", noMatchText: "No matching biomarker", onAdd: (id) => api.toggle(f.id, "biomarkers", id), onRemove: (id) => api.toggle(f.id, "biomarkers", id) }))); return h(Drawer, { open: true, onClose, width: 760, title: f.label, sub: h(React.Fragment, null, isSystem ? h(Pill, { tone: "neutral", soft: true }, "System") : exposomeDomainPill(f.domain), h("span", { className: "dim" }, isSystem ? "Exposome System" : f.category, f.unit && f.unit !== "—" ? h(React.Fragment, null, " · ", h(Mono, null, f.unit)) : null)), footer: h("div", { className: "drawer-actions" }, h(Button, { variant: "primary", icon: "check", onClick: () => { toast((isSystem ? "Exposome system" : "Exposome factor") + " saved", "ok"); onClose(); } }, "Save changes"), h(Button, { variant: "danger-ghost", icon: "trash", onClick: () => onDelete(f) }, isSystem ? "Delete system" : "Delete factor")) }, h("div", { className: "refbox" }, isSystem && hasChildren ? h("div", { className: "ref-banner" }, h(Icon, { name: "subtree", size: 16, className: "dim" }), h("div", null, h("strong", null, "“" + f.label + "”"), " is an Exposome System grouping ", h("strong", null, factors.filter((e) => e.parent === f.id).length, " factor", factors.filter((e) => e.parent === f.id).length === 1 ? "" : "s"), ". Mappings are normally managed on the individual factors beneath it.")) : null, !isSystem && h("div", { className: "td-stats", style: { border: "1px solid var(--line)", borderRadius: "var(--r)", overflow: "hidden" } }, h("div", { className: "td-stat" }, h("span", { className: "td-num" }, f.signals.length), h("span", { className: "dim" }, "sensor signals")), h("div", { className: "td-stat" }, h("span", { className: "td-num" }, f.phenotypes.length), h("span", { className: "dim" }, "phenotypes")), h("div", { className: "td-stat" }, h("span", { className: "td-num" }, f.genes.length), h("span", { className: "dim" }, "genes")), h("div", { className: "td-stat" }, h("span", { className: "td-num" }, f.biomarkers.length), h("span", { className: "dim" }, "biomarkers"))), // definition h("div", { className: "form-grid", style: { display: "grid", gridTemplateColumns: isSystem ? "1fr 1fr" : "1fr 1fr 1fr", gap: "14px" } }, h(Field, { label: "Domain" }, h(Select, { value: f.domain, onChange: (v) => api.patch(f.id, { domain: v }), options: X.EXPOSOME_DOMAINS.map((d) => ({ value: d.id, label: d.label })) })), h(Field, { label: "Category" }, h(Select, { value: f.category, onChange: (v) => api.patch(f.id, { category: v }), options: X.EXPOSOME_CATEGORIES.includes(f.category) ? X.EXPOSOME_CATEGORIES : [f.category, ...X.EXPOSOME_CATEGORIES] })), !isSystem && h(Field, { label: "Unit of measure" }, h("input", { className: "input mono", value: f.unit, onChange: (e) => api.patch(f.id, { unit: e.target.value }) }))), h(Field, { label: "Exposome system", hint: hasChildren ? "This factor has its own factors, so it stays a top-level system." : "Optionally group this factor under a system (e.g. Particulate Matter under Air Quality)" }, h(Select, { value: hasChildren ? "" : (f.parent || ""), disabled: hasChildren, onChange: (v) => api.patch(f.id, { parent: v || null }), options: [{ value: "", label: "None — top-level Exposome System" }].concat(systemOpts.map((s) => ({ value: s.id, label: s.label }))) })), h(Field, { label: "Definition", hint: "What this exposure is and how it acts on the body" }, h("textarea", { className: "input", rows: 3, value: f.desc, style: { resize: "vertical", lineHeight: 1.5, fontFamily: "inherit" }, onChange: (e) => api.patch(f.id, { desc: e.target.value }) })), mappingSections, // references f.refs && f.refs.length ? h("div", { className: "refsec" }, h("p", { className: "refsec-title" }, "References"), h("div", { className: "link-list" }, f.refs.map((r, i) => h("a", { className: "link-item", key: i, href: r.url, target: "_blank", rel: "noreferrer", style: { textDecoration: "none" } }, h("div", { className: "link-item-main" }, h("span", { className: "link-t" }, r.title), h("span", { className: "link-s" }, r.url)), h(Icon, { name: "external", size: 14, className: "dim" }))))) : null)); } /* ======================================================================== */ /* MODALS — new factor/system · delete */ /* ======================================================================== */ function NewExposomeModal({ seed, existing, onClose, onCreate }) { const open = !!seed; // seed.parent === null → creating a System; "" → creating a factor (parent chosen below) const [label, setLabel] = useState(""); const [parent, setParent] = useState(""); const [domain, setDomain] = useState("external-specific"); const [category, setCategory] = useState(""); const [unit, setUnit] = useState(""); const [desc, setDesc] = useState(""); const [status, setStatus] = useState("draft"); const seedSystem = seed && seed.parent === null; useEffect(() => { if (open) { setLabel(""); setParent(seedSystem ? "" : ""); setDomain("external-specific"); setCategory("Air quality"); setUnit(""); setDesc(""); setStatus("draft"); } }, [open]); if (!open) return null; const systemOpts = existing.filter((e) => !e.parent); const dup = existing.some((e) => e.label.trim().toLowerCase() === label.trim().toLowerCase()); const slug = (seedSystem ? "exs_" : "ef_") + label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "").slice(0, 24); const valid = label.trim() && !dup; const submit = () => { if (!valid) return; onCreate({ id: slug, parent: seedSystem ? null : (parent || null), label: label.trim(), domain, category: category.trim() || (seedSystem ? "System" : "Uncategorized"), unit: seedSystem ? "—" : (unit.trim() || "—"), status, desc: desc.trim(), signals: [], phenotypes: [], genes: [], biomarkers: [], refs: [] }); }; return h(Modal, { open, onClose, title: seedSystem ? "New exposome system" : "New exposome factor", width: 580, footer: h(React.Fragment, null, h(Button, { variant: "ghost", onClick: onClose }, "Cancel"), h(Button, { variant: "primary", icon: "plus", disabled: !valid, onClick: submit }, seedSystem ? "Create system" : "Create factor")) }, h("div", { className: "mform" }, h(Field, { label: seedSystem ? "System name" : "Factor name", hint: dup ? "An entry with this name already exists." : (seedSystem ? "A grouping of exposures, e.g. “Air Quality”" : "The exposure, e.g. “Ambient Ozone”") }, h("input", { className: "input", autoFocus: true, value: label, placeholder: seedSystem ? "e.g. Air Quality" : "e.g. Ambient Ozone", onChange: (e) => setLabel(e.target.value) })), !seedSystem && h(Field, { label: "Exposome system", hint: "Group this factor under an existing system (optional)" }, h(Select, { value: parent, onChange: setParent, options: [{ value: "", label: "None — create as top-level system" }].concat(systemOpts.map((s) => ({ value: s.id, label: s.label }))) })), h("div", { className: "mform-2" }, h(Field, { label: "Domain" }, h(Select, { value: domain, onChange: setDomain, options: X.EXPOSOME_DOMAINS.map((d) => ({ value: d.id, label: d.label })) })), h(Field, { label: "Category" }, h(Select, { value: category, onChange: setCategory, options: X.EXPOSOME_CATEGORIES }))), !seedSystem && h("div", { className: "mform-2" }, h(Field, { label: "Unit of measure" }, h("input", { className: "input mono", value: unit, placeholder: "e.g. µg/m³", onChange: (e) => setUnit(e.target.value) })), h(Field, { label: "Status" }, h(Select, { value: status, onChange: setStatus, options: [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }] }))), seedSystem && h(Field, { label: "Status" }, h(Select, { value: status, onChange: setStatus, options: [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }] })), h(Field, { label: "Definition" }, h("textarea", { className: "input", rows: 3, value: desc, placeholder: seedSystem ? "Describe what this system groups…" : "Describe the exposure and how it affects the body…", style: { resize: "vertical", lineHeight: 1.5, fontFamily: "inherit" }, onChange: (e) => setDesc(e.target.value) })), label.trim() && h("p", { className: "mform-note" }, h(Icon, { name: "hash", size: 14 }), "Identifier: ", h("span", { className: "mono" }, slug)), !seedSystem && h("p", { className: "mform-note" }, h(Icon, { name: "sparkles", size: 14 }), "Map sensors, phenotypes, genes and biomarkers in the next step — the factor opens for editing right after you create it."))); } function DeleteExposomeModal({ factor, children, onClose, onConfirm }) { if (!factor) return null; const isSystem = !factor.parent; const kids = children || []; const total = factor.signals.length + factor.phenotypes.length + factor.genes.length + factor.biomarkers.length; return h(Modal, { open: true, onClose, title: isSystem ? "Delete exposome system" : "Delete exposome factor", width: 540, footer: h(React.Fragment, null, h(Button, { variant: "ghost", onClick: onClose }, "Cancel"), h(Button, { variant: "danger", icon: "trash", onClick: onConfirm }, isSystem ? "Delete system" : "Delete factor")) }, h("div", { className: "mform" }, kids.length ? h("div", { className: "warn-banner soft" }, h(Icon, { name: "subtree", size: 16 }), h("div", null, h("strong", null, "“" + factor.label + "”"), " is an Exposome System with ", h("strong", null, kids.length, " factor", kids.length === 1 ? "" : "s"), " (", kids.map((k) => k.label).join(", "), "). ", kids.length === 1 ? "It" : "They", " will be promoted to top-level systems.")) : null, h("p", { style: { margin: 0 } }, "Delete the ", isSystem ? "exposome system" : "exposome factor", " ", h("strong", null, "“" + factor.label + "”"), "?"), total ? h("div", { className: "warn-banner" }, h(Icon, { name: "alert", size: 16 }), h("div", null, "This removes ", h("strong", null, total, " mapping", total === 1 ? "" : "s"), " to sensors, phenotypes, genes and biomarkers. The mapped entities themselves are not affected.")) : h("p", { className: "mform-note" }, h(Icon, { name: "info", size: 14 }), "This entry has no mappings of its own, so nothing else is affected."))); } /* ======================================================================== */ /* EXPORT — exposome registry to CSV / Excel */ /* ======================================================================== */ const EXPO_EXPORT_COLS = ["Exposome factor", "Identifier", "Type", "Domain", "Category", "Unit", "Status", "Sensor signals", "Phenotypes", "Genes", "Biomarkers"]; function expoSignalLabels(keys) { return keys.map((k) => { const s = (X.SIGNAL_TYPES || []).find((t) => t.key === k); return s ? s.label : k; }); } function expoRow(e) { return [e.label, e.id, e.parent ? "Factor" : "System", X.exposomeDomainLabel(e.domain), e.category, e.unit, e.status, expoSignalLabels(e.signals).join("; "), e.phenotypes.map((p) => X.phenoLabel(p)).join("; "), e.genes.map((g) => X.geneSymbol(g)).join("; "), e.biomarkers.map((b) => X.bmName(b)).join("; ")]; } function expoDownloadBlob(name, mime, data) { const url = URL.createObjectURL(new Blob([data], { type: mime })); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); } function expoExportCSV(factors) { const esc = (v) => { const s = String(v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const lines = [EXPO_EXPORT_COLS.map(esc).join(","), ...factors.map((e) => expoRow(e).map(esc).join(","))]; expoDownloadBlob("eno-exposome.csv", "text/csv;charset=utf-8", "\uFEFF" + lines.join("\r\n")); } function expoExportXLS(factors) { const esc = (v) => String(v).replace(/&/g, "&").replace(//g, ">"); const th = EXPO_EXPORT_COLS.map((c) => "" + esc(c) + "").join(""); const trs = factors.map((e) => "" + expoRow(e).map((v) => "" + esc(v) + "").join("") + "").join(""); const html = '' + th + "" + trs + "
"; expoDownloadBlob("eno-exposome.xls", "application/vnd.ms-excel", html); } function ExportExposomeModal({ open, factors, onClose }) { const toast = useToast(); const [fmt, setFmt] = useState("csv"); useEffect(() => { if (open) setFmt("csv"); }, [open]); const run = () => { if (fmt === "csv") expoExportCSV(factors); else expoExportXLS(factors); toast("Exported " + factors.length + " exposome entries · " + fmt.toUpperCase(), "ok"); onClose(); }; const opt = (key, icon, t, s) => h("button", { type: "button", className: "fmt-opt" + (fmt === key ? " on" : ""), onClick: () => setFmt(key) }, h(Icon, { name: icon, size: 18 }), h("div", null, h("div", { className: "fmt-opt-t" }, t), h("div", { className: "fmt-opt-s" }, s))); return h(Modal, { open, onClose, title: "Export exposome", width: 500, footer: h(React.Fragment, null, h(Button, { variant: "ghost", onClick: onClose }, "Cancel"), h(Button, { variant: "primary", icon: "download", onClick: run }, "Export " + fmt.toUpperCase())) }, h("div", { className: "mform" }, h(Field, { label: "Format" }, h("div", { className: "fmt-opts" }, opt("csv", "sheet", "CSV", "Comma-separated · opens anywhere"), opt("xls", "sheet", "Excel", "Formatted .xls workbook"))), h(Field, { label: "Includes " + factors.length + " exposome entries · " + EXPO_EXPORT_COLS.length + " columns" }, h("div", { className: "export-cols" }, EXPO_EXPORT_COLS.map((c) => h("span", { className: "tag", key: c }, c)))))); } Object.assign(window, { ExposomeView });