const $ = (s) => document.querySelector(s); const el = (t, c, txt) => { const n = document.createElement(t); if (c) n.className = c; if (txt != null) n.textContent = txt; return n; }; const CATS = { diagnosis: { label: "Diagnosis", placeholder: "e.g. Type 2 diabetes mellitus" }, medication: { label: "Medication", placeholder: "e.g. atorvastatin" }, lab: { label: "Lab", placeholder: "e.g. hemoglobin a1c" }, procedure: { label: "Procedure", placeholder: "e.g. colonoscopy" }, phenotype: { label: "Phenotype", placeholder: "e.g. heart failure" }, }; const state = { category: "phenotype", query: "", results: [], annotations: {}, groupFam: false, systems: {}, systemCatalog: {}, model: null, models: [], // Paging over one fetched result set. `seenKeys` records the // rows actually shown, so a submit covers only opened pages; // `gradesDirty` means grades exist that were never submitted. page: 1, seenKeys: new Set(), gradesDirty: false, mergeDupes: false, // Query planner: "search" | "plan". `plan` outlives searches // on purpose — the user works through its chips one at a time. mode: "search", plan: null, planEditing: null, planCurrent: null, ranked: true, // Search bar job: false = words against descriptions, // true = exact match on the code column. lookup: false, // reqToken: a fetch captures it on start and checks it before // writing, so a late reply can never repaint a screen that has // moved on. resultsUrl: the request that actually produced the // rows on screen, which is what a screen snapshot must replay. reqToken: 0, resultsUrl: null, detailPid: null, cot: "", cotTokens: null, planToken: 0, screens: {}, lastCategory: null }; // What kind of library entry a phenotype result is, from the title suffix. // Mirrors scripts/lib/phenotype_kind.py: MAP entries are probability models, // Phecode entries are bare concept groups, VADC/gwPheWAS/MVP and suffix-less // titles are study variables, every other suffixed source is a curated list. const KIND_LABELS = { list: "Code list", map: "ML model", phecode: "Phecode group", study: "Study variable" }; function phenotypeKind(title) { const m = /\(([^()]+)\)\s*$/.exec(title || ""); if (!m) return "study"; const s = m[1].trim().toLowerCase(); if (s === "map") return "map"; if (s === "phecode") return "phecode"; if (["vadc", "gwphewas", "mvp", "mvp data core"].includes(s)) return "study"; return "list"; } async function apiGet(path) { const r = await fetch(path); if (!r.ok) throw new Error(((await r.json().catch(() => ({}))).detail) || r.statusText); return r.json(); } // Operational grading definitions, shared by the code table and phenotype // cards so every reviewer labels against the same scale. Unsure has no button // and so no tooltip; About states what a blank row means. const GRADE_HELP = { relevant: "An exact match for your search.", related: "A nearby concept, not an exact match.", not_relevant: "Reviewed and judged incorrect for this search.", }; // One grade per result; order fixed so every surface lists them the same way. // Unsure is not a button: any row left ungraded is recorded as unsure when // the annotations are submitted, and the submit notice says how many. const GRADES = [ ["relevant", "Relevant"], ["related", "Related"], ["not_relevant", "Not relevant"], ]; // Column tooltips. const COL_HELP = { "Select": "Select for your list. Kept across searches.", "Grade": "Pick one judgement. Click it again to clear.", "Rank": "Position in the results. 1 is closest.", "Code Type": "The coding system, such as ICD-10, RxNorm, NDC, LOINC, or CPT.", "Code": "The code itself. Open any row for its description, mappings, and related codes.", "Description": "What the code means.", "Relevance": "Match closeness. Higher is closer.", }; function cleanDisplayText(value) { return /^rules-based(?:\s|\()/i.test(value) ? "Rule-based" : value; } const isPheno = () => state.category === "phenotype"; // -- tooltips -------------------------------------------------------------- // Every explanation in this app hangs off a `title`, and the browser waits // roughly a second before showing one, which is long enough that a reader // gives up and asks what the abbreviation meant. Same text, same trigger, // shown in 120ms. // // The attribute is moved to `data-tip` for the duration of the hover and put // back on leave, because leaving it in place would draw the native tooltip on // top of this one, and removing it for good would take the element's // accessible name with it. const TIP_DELAY = 120; const tipBox = el("div", "tip hidden"); let tipTimer = null, tipHost = null; function hideTip() { clearTimeout(tipTimer); tipBox.classList.add("hidden"); if (tipHost && tipHost.dataset.tip) { tipHost.setAttribute("title", tipHost.dataset.tip); delete tipHost.dataset.tip; } tipHost = null; } function showTip(host) { const text = host.dataset.tip; if (!text || host !== tipHost) return; tipBox.textContent = text; tipBox.classList.remove("hidden"); const anchor = host.getBoundingClientRect(); const box = tipBox.getBoundingClientRect(); const left = Math.max(8, Math.min(anchor.left + anchor.width / 2 - box.width / 2, window.innerWidth - box.width - 8)); // Below by default, above when there is no room, so it never covers the // thing being explained. const below = anchor.bottom + 8; tipBox.style.left = `${left}px`; tipBox.style.top = `${below + box.height > window.innerHeight - 8 ? Math.max(8, anchor.top - box.height - 8) : below}px`; } document.addEventListener("mouseover", (e) => { const host = e.target.closest ? e.target.closest("[title]") : null; if (!host || host === tipHost) return; hideTip(); host.dataset.tip = host.getAttribute("title"); host.removeAttribute("title"); tipHost = host; tipTimer = setTimeout(() => showTip(host), TIP_DELAY); }); document.addEventListener("mouseout", (e) => { if (tipHost && !tipHost.contains(e.relatedTarget)) hideTip(); }); document.addEventListener("mousedown", hideTip); window.addEventListener("scroll", hideTip, true); document.body.appendChild(tipBox); // -- code basket: collect codes/phenotypes across searches & categories ----- // (the VA "curate a working code list" workflow; persists in localStorage). const BASKET_KEY = "encode_basket_v1"; let basket = (() => { try { return JSON.parse(localStorage.getItem(BASKET_KEY)) || {}; } catch (_) { return {}; } })(); const basketKey = (it) => `${it.kind}:${it.category}:${it.code}`; const inBasket = (it) => basketKey(it) in basket; function saveBasket() { localStorage.setItem(BASKET_KEY, JSON.stringify(basket)); updateBasketCount(); } function toggleBasket(it) { const k = basketKey(it); if (k in basket) delete basket[k]; else basket[k] = { ...it, query: state.query, added: new Date().toISOString() }; saveBasket(); } function updateBasketCount() { const b = $("#basket-count"); if (b) b.textContent = String(Object.keys(basket).length); } // A merged face row passes its duplicates as followers, so one click collects // the whole group. function collectBtn(it, compact, followers) { const b = el("button", "collect-btn" + (compact ? " collect-icon" : "")); b.type = "button"; const mark = el("span", "collect-mark"); const label = compact ? null : el("span", "collect-label"); mark.setAttribute("aria-hidden", "true"); b.appendChild(mark); if (label) b.appendChild(label); const sync = () => { const on = inBasket(it); b.classList.toggle("on", on); b.setAttribute("aria-pressed", String(on)); b.setAttribute("aria-label", on ? "Remove from collection" : "Select for collection"); mark.textContent = on ? "✓" : "+"; if (label) label.textContent = on ? "Selected" : "Select"; b.title = on ? "Remove from collected codes" : "Select for collected codes"; }; b.onclick = (e) => { e.stopPropagation(); toggleBasket(it); const on = inBasket(it); (followers || []).forEach((f) => { if (inBasket(f) !== on) toggleBasket(f); }); if (followers && followers.length) { $("#results").querySelectorAll(".collect-btn") .forEach((c) => { if (c.syncCollect) c.syncCollect(); }); } else sync(); }; b.syncCollect = sync; // group-level "Select all" refreshes rows through this sync(); return b; } // -- embedding model picker ------------------------------------------------ // The backend registry lists models, supported categories, and availability. // The default entry keeps the UI usable with older backends. const MODEL_KEY = "encode_model_v1"; const FALLBACK_CATALOG = { default: "bge_ft_va", models: [{ id: "bge_ft_va", label: "BGE-FT-VA", categories: Object.keys(CATS), available: true, unavailable_reason: "" }], }; async function loadModels() { let catalog; try { catalog = await apiGet("/api/models"); } catch (_) { catalog = FALLBACK_CATALOG; } state.models = catalog.models || []; state.defaultModel = catalog.default; const saved = localStorage.getItem(MODEL_KEY); state.model = modelUsable(modelById(saved), state.category) ? saved : state.defaultModel; renderModelPicker(); } const modelById = (id) => state.models.find((m) => m.id === id) || null; const modelUsable = (m, cat) => !!m && m.available && (m.categories || []).includes(cat); // Show models that can serve the selected category. function renderModelPicker() { const sel = $("#model"); if (!sel) return; sel.innerHTML = ""; const options = state.models.filter((m) => modelUsable(m, state.category)); if (!modelUsable(modelById(state.model), state.category)) { state.model = (options.find((m) => m.id === state.defaultModel) || options[0] || {}).id || null; } options.forEach((m) => { const o = el("option", null, m.label); o.value = m.id; o.selected = m.id === state.model; sel.appendChild(o); }); if (!options.length) { const o = el("option", null, "No model available"); o.disabled = true; o.selected = true; sel.appendChild(o); } } // -- pre-search code-system restriction (sidebar) -------------------------- // The only code-system control. It restricts what the search runs over, so // asking for ICD-9 returns a full page of ICD-9 rather than however few placed // in a mixed top 50 — which is what the 2/26 testing feedback asked for. There // used to be a second, post-search chip row that merely hid rows; two controls // named almost the same thing and doing different things is what the 8/7 // feedback flagged, so the chips are gone and this is the one place to choose. // // Every system starts selected: the default is the whole vocabulary, and a // checkbox the user never touches must not narrow anything. Deselecting is // what restricts. The last remaining box is disabled rather than allowed to // reach zero, because a search over no code system has no meaningful result. // // Keyed by mode as well as category, so a restriction set while searching // Diagnosis does not silently steer an Agent chip that happens to search // diagnoses too, and vice versa. const systemsKey = () => `${state.mode === "plan" ? "plan" : "search"}:${state.category}`; function chosenSystems(catalog) { const key = systemsKey(); if (!state.systems[key]) { state.systems[key] = new Set(catalog.map((s) => s.code_type)); // all on } return state.systems[key]; } async function updateSystemFilters() { const box = $("#system-filters"); // In Agent mode there is no category until a chip has run, and a filter // with no referent is worse than an absent one. if (isPheno() || (state.mode === "plan" && !state.query)) { box.classList.add("hidden"); return; } const cat = state.category; if (state.systemCatalog[cat] === undefined) { try { state.systemCatalog[cat] = (await apiGet(`/api/code/systems?category=${cat}`)).systems; } catch (_) { state.systemCatalog[cat] = null; } // older backend: hide } const catalog = state.systemCatalog[cat]; if (cat !== state.category) return; // user switched while loading if (!catalog || catalog.length <= 1) { box.classList.add("hidden"); return; } const host = $("#systems"); host.innerHTML = ""; const chosen = chosenSystems(catalog); catalog.forEach((s) => { const row = el("label", "filter-opt"); const cb = el("input"); cb.type = "checkbox"; cb.checked = chosen.has(s.code_type); // Last one standing cannot be turned off. const only = cb.checked && chosen.size === 1; cb.disabled = only; row.classList.toggle("filter-opt-locked", only); if (only) { row.title = `Searching ${systemLabel(s.code_type)} only. ` + "At least one code system has to stay selected, so this one cannot be " + "turned off until another is turned back on."; } cb.onchange = () => { cb.checked ? chosen.add(s.code_type) : chosen.delete(s.code_type); updateSystemFilters(); // redraw so the lock moves with the state if (state.query) runSearch(); }; row.appendChild(cb); const name = el("span", null, systemLabel(s.code_type)); // The lock explanation wins on a locked row: why the box will not move is // more urgent than what the abbreviation stands for. if (!only) withGlossary(name, systemLabel(s.code_type)); row.appendChild(name); host.appendChild(row); }); box.classList.remove("hidden"); } // -- category / mode switching -------------------------------------------- // Labels, placeholder, filters and pickers for the current category. Separate // from applyCategory() because restoring a saved screen needs the chrome // updated without the clearing that would wipe what is being restored. function applyCategoryChrome() { const meta = CATS[state.category]; renderModelPicker(); // Caption and placeholder are set by updateCodeLookup below, which knows // whether the bar is searching or looking up; setting them here too would // flash the search wording onto a bar that is in lookup mode. $("#search-for").textContent = `Search for ${meta.label}`; $("#query").placeholder = meta.placeholder; updateSystemFilters(); updateCodeLookup(); renderRecentTerms(); } // -- exact code lookup ---------------------------------------------------- // A switch on the one search bar, not a second box: the page already has a // place to type, and two inputs asking for different things is the confusion // the 8/7 feedback flagged about the old two-row code filters. // // Phenotypes are titles rather than codes, so the switch is only offered for // the four code categories, and it follows the same visibility rule the // code-system filters do. Switching to Phenotype turns it off, because there // is no code column there to look in. const LOOKUP_PLACEHOLDER = { diagnosis: "e.g. N18.9", medication: "e.g. 800154999", lab: "e.g. 800019207", procedure: "e.g. 90999" }; function updateCodeLookup() { const box = $("#code-lookup"); const on = !isPheno() && !(state.mode === "plan" && !state.query); box.classList.toggle("hidden", !on); if (!on && state.lookup) setLookupMode(false); else applyLookupChrome(); } // The caption and placeholder are the whole signal that the bar changed job, // so they are rewritten together and nowhere else. function applyLookupChrome() { if (isPheno()) return; const meta = CATS[state.category]; $("#search-for").textContent = state.lookup ? `Look up a ${meta.label} code` : `Search for ${meta.label}`; $("#query").placeholder = state.lookup ? (LOOKUP_PLACEHOLDER[state.category] || "code") : meta.placeholder; $("#search").textContent = state.lookup ? "Look up" : "Search"; $("#query").classList.toggle("query-code", state.lookup); } function setLookupMode(on) { state.lookup = on; $("#lookup-mode").checked = on; applyLookupChrome(); } async function runLookup() { if (isPheno()) return; if (!(await confirmLeaveGrades())) return; let code = $("#query").value.trim(); if (!code) { // Same as an empty search: the placeholder is an example, so pressing the // button on an empty bar runs it. The bar is filled in first, so what ran // is on screen afterwards. code = ($("#query").placeholder || "").replace(/^e\.g\.\s*/i, "").trim(); if (!code) return; $("#query").value = code; } state.query = code; state.annotations = {}; state.gradesDirty = false; syncCaptions(); closeDetailPage(); $("#empty").innerHTML = ""; $("#status").textContent = "Looking up…"; $("#results").innerHTML = ""; $("#submit-row").classList.add("hidden"); $("#export").classList.add("hidden"); $("#submit-msg").textContent = ""; const t0 = performance.now(); // Captured before the await, checked after: the lab index is ~30s cold, and // a reply landing after the user has switched category or searched again // must not repaint the screen that has moved on. const token = ++state.reqToken; const url = `/api/code/lookup?category=${state.category}&code=${encodeURIComponent(code)}&k=${$("#k").value}`; try { const data = searchCache.get(url) || await apiGet(url); if (token !== state.reqToken) return; if (!data.count) { $("#status").innerHTML = `No ${CATS[state.category].label.toLowerCase()} code near ${escHtml(code)} in the index.`; afterResults(0); return; } // Cached under its own lookup URL, so a screen restore replays the lookup // rather than falling through to a semantic search of the digits. cacheSearch(url, data); state.resultsUrl = url; renderCodes(data, (performance.now() - t0) / 1000); } catch (e) { if (token !== state.reqToken) return; $("#status").textContent = `Lookup failed: ${e.message}`; afterResults(0); } } function applyCategory() { closeDetailPage(); applyCategoryChrome(); clearResults(); renderEmpty(); syncCaptions(); } function clearResults() { state.results = []; state.annotations = {}; state.gradesDirty = false; // Anything still in flight was asked for by the screen being cleared, so // its reply must not land here; a bumped token makes it check and drop. state.reqToken += 1; state.resultsUrl = null; $("#results").innerHTML = ""; $("#status").textContent = ""; $("#submit-row").classList.add("hidden"); $("#export").classList.add("hidden"); $("#submit-msg").textContent = ""; } // Every tab keeps its own screen: Agent, and each search category separately. // Diagnosis and Medication are as different from one another as Search is from // Agent, so leaving one and coming back should find it as it was. What is // stored is the request that actually produced the rows on screen — a lookup // banks its lookup URL, not the search URL its query string would build, // which used to bring back a semantic-text match of the digits — plus the // lookup flag, so the bar comes back in the job it left in. The rows come // back from the result cache; only a cache eviction costs a backend call. const screenKey = () => (state.mode === "plan" ? "plan" : `search:${state.category}`); function snapshotScreen() { state.screens[screenKey()] = state.query && state.resultsUrl ? { query: state.query, url: state.resultsUrl, lookup: state.lookup } : null; if (state.mode !== "plan") state.lastCategory = state.category; } function restoreScreen() { const snap = state.screens[screenKey()]; if (!snap) { syncCaptions(); return false; } $("#query").value = snap.query; state.query = snap.query; if (!isPheno()) setLookupMode(!!snap.lookup); const data = searchCache.get(snap.url); if (data) { state.resultsUrl = snap.url; (state.category === "phenotype" ? renderPhenotypes : renderCodes)(data, 0, true); } else { // Evicted from the cache: re-run the same request rather than leaving a // filled query bar over an empty, mismatched surface. state.lookup ? runLookup() : runSearch(); } syncCaptions(); return true; } // Return the result surface to empty. Results always belong to the context // that produced them -- a plan, or a mode -- so leaving that context has to // take them with it, or a stale table sits under an unrelated screen. function resetSearchSurface() { state.planCurrent = null; state.query = ""; $("#query").value = ""; closeDetailPage(); clearResults(); renderEmpty(); syncCaptions(); } // The "Search for X" / "Describe the cohort…" captions are prompts for an // empty screen. Once there is a result set or a plan to look at they are just // noise above it, so they retire until the surface is empty again. function syncCaptions() { $("#search-for").classList.toggle("caption-off", !!state.query); $("#plan-for").classList.toggle("caption-off", !!state.plan); } function renderEmpty() { const box = $("#empty"); if (state.mode === "plan" || state.results.length || state.query) { box.innerHTML = ""; return; } box.innerHTML = ""; box.appendChild(el("h2", "empty-title", isPheno() ? "Find phenotypes" : "Search medical codes")); box.appendChild(el("p", "empty-sub", isPheno() ? "Enter cohort criteria to search CIPHER phenotypes." : "Enter a clinical concept above to find matching diagnosis, medication, lab, or procedure codes.")); } // -- search --------------------------------------------------------------- // Working through a plan means revisiting the same searches: run a chip, look // at codes, go back, run the next, return to an earlier one. The request URL // already encodes everything that changes a result set (category, query, k, // ranking model, code systems), so it doubles as the cache key. Results are // derived from static indexes, so entries never go stale within a session. const searchCache = new Map(); const SEARCH_CACHE_MAX = 40; // Terms are kept per category and persisted, because "what did I search last // week" is a real question and the answer is not reconstructable from results. // The results themselves stay in memory only: a handful of 50-row payloads // with full code evidence would crowd localStorage's few megabytes, and they // are cheap to re-fetch, whereas a forgotten search term is not. const RECENT_KEY = "encode_recent_terms_v1"; const RECENT_MAX = 12; let recentTerms = (() => { try { return JSON.parse(localStorage.getItem(RECENT_KEY)) || {}; } catch (_) { return {}; } })(); function rememberTerm(category, q) { const list = (recentTerms[category] || []).filter((t) => t.toLowerCase() !== q.toLowerCase()); list.unshift(q); recentTerms[category] = list.slice(0, RECENT_MAX); try { localStorage.setItem(RECENT_KEY, JSON.stringify(recentTerms)); } catch (_) { /* full or blocked */ } renderRecentTerms(); } // Suggestions belong to the category being searched: "metformin" is no help // when looking for a phenotype. The most recent sits first, so the last thing // searched is the default pick. let recentActive = -1; function recentList() { return recentTerms[state.category] || []; } function renderRecentTerms(filter) { const box = $("#recent-panel"); if (!box) return; const needle = (filter || "").trim().toLowerCase(); const items = recentList().filter((t) => !needle || t.toLowerCase().includes(needle)); box.innerHTML = ""; if (!items.length) { box.appendChild(el("li", "recent-empty", recentList().length ? "No earlier search matches that." : "No earlier searches yet.")); recentActive = -1; return; } const newest = recentList()[0]; items.forEach((t, i) => { const li = el("li", "recent-item" + (i === recentActive ? " active" : ""), t); li.setAttribute("role", "option"); li.setAttribute("aria-selected", String(i === recentActive)); // The badge marks the term actually searched last, which is not the first // row once a filter is applied. if (t === newest) li.appendChild(el("span", "recent-when", "last searched")); // mousedown, not click: the input's blur would close the panel first. li.onmousedown = (e) => { e.preventDefault(); pickRecent(t); }; box.appendChild(li); }); } function openRecent() { recentActive = recentList().length ? 0 : -1; // last searched preselected // Opening the panel shows the whole history. Filtering by whatever is in the // box would hide it entirely after a search, because the box still holds the // term just searched and it is the only entry that matches itself: the // history looked like it remembered one search. Text the user has typed // since that search still filters, so a half-typed term keeps its context. const typed = $("#query").value; renderRecentTerms(typed.trim() === (state.query || "").trim() ? "" : typed); $("#recent-panel").classList.remove("hidden"); $("#query").setAttribute("aria-expanded", "true"); } function closeRecent() { $("#recent-panel").classList.add("hidden"); $("#query").setAttribute("aria-expanded", "false"); recentActive = -1; } function pickRecent(term) { $("#query").value = term; closeRecent(); runSearch(); } function moveRecent(step) { const box = $("#recent-panel"); const items = [...box.querySelectorAll(".recent-item")]; if (!items.length) return; recentActive = (recentActive + step + items.length) % items.length; items.forEach((li, i) => { li.classList.toggle("active", i === recentActive); li.setAttribute("aria-selected", String(i === recentActive)); }); items[recentActive].scrollIntoView({ block: "nearest" }); } function searchUrl(category, q) { const m = state.model ? `&model=${encodeURIComponent(state.model)}` : ""; // Selected systems are sent only when they are a real restriction. All-on is // the default and means the whole vocabulary, so it sends nothing and skips // the server's over-fetch. The key carries the mode: an Agent chip searching // diagnoses is not governed by what Search mode has selected. const catalog = state.systemCatalog[category]; const chosen = state.systems[`${state.mode === "plan" ? "plan" : "search"}:${category}`]; const restricted = chosen && catalog && chosen.size && chosen.size < catalog.length; const sys = restricted ? `&systems=${encodeURIComponent([...chosen].join(","))}` : ""; return category === "phenotype" ? `/api/search?q=${encodeURIComponent(q)}&k=${$("#k").value}${m}` : `/api/code/search?category=${category}&q=${encodeURIComponent(q)}&k=${$("#k").value}${sys}${m}`; } // Once a plan exists every search it can produce is known, so run them ahead of // the click. Strictly sequential and in chip order: the backend is a single // worker, so a burst would put the chip the user actually clicks behind six // others. In order means the first chip -- the one most likely clicked first -- // is warmed first, and it also pays the one-off cold FAISS load for a category // (the lab index takes ~30s the first time) while the user is still reading. async function prefetchPlan(token) { if (!state.plan) return; for (const c of state.plan.criteria) { if (state.planToken !== token || !state.plan) return; // plan replaced or cleared const url = searchUrl(c.category, c.concept); if (searchCache.has(url)) continue; try { cacheSearch(url, await apiGet(url)); } catch (_) { /* a chip click will retry */ } await new Promise((r) => setTimeout(r, 150)); } } function cacheSearch(url, data) { searchCache.set(url, data); if (searchCache.size > SEARCH_CACHE_MAX) { searchCache.delete(searchCache.keys().next().value); // oldest out } } async function runSearch() { if (!(await confirmLeaveGrades())) return; let q = $("#query").value.trim(); if (!q) { // Empty search falls back to the placeholder example ("e.g. …"). q = ($("#query").placeholder || "").replace(/^e\.g\.\s*/i, "").trim(); if (!q) return; $("#query").value = q; } state.query = q; state.annotations = {}; state.gradesDirty = false; syncCaptions(); // Re-run after the query is set: applyCategory() ran before it, and in Agent // mode the code-system filters stay hidden until a search gives them a // category to be about. updateSystemFilters(); updateCodeLookup(); closeDetailPage(); $("#empty").innerHTML = ""; $("#status").textContent = "Searching…"; $("#results").innerHTML = ""; $("#submit-row").classList.add("hidden"); $("#export").classList.add("hidden"); $("#submit-msg").textContent = ""; const t0 = performance.now(); // Same late-reply guard as runLookup: captured before the await, checked // after, so a slow response never repaints a screen that has moved on. const token = ++state.reqToken; try { const url = searchUrl(state.category, q); const render = isPheno() ? renderPhenotypes : renderCodes; const hit = searchCache.get(url); if (hit) { rememberTerm(state.category, q); state.resultsUrl = url; render(hit, 0, true); return; } const data = await apiGet(url); if (token !== state.reqToken) return; cacheSearch(url, data); rememberTerm(state.category, q); state.resultsUrl = url; render(data, (performance.now() - t0) / 1000); } catch (e) { if (token !== state.reqToken) return; $("#status").textContent = `Error: ${e.message}`; } } // Which model actually produced this ranking, as reported by the server — not // what the picker happens to show — so the attribution can't drift from reality. const escHtml = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); const timing = (secs, cached) => cached ? "" : ` (${secs.toFixed(1)}s)`; function afterResults(count) { if (!count) { $("#submit-row").classList.add("hidden"); $("#export").classList.add("hidden"); return; } $("#submit-row").classList.remove("hidden"); $("#export").classList.remove("hidden"); } // Server-side query feedback: a spelling suggestion built from the indexed // vocabulary, and a low-confidence flag when even the best match is weak // (a misspelled query silently returning nonsense was a top testing finding). function queryNotices(data) { const frag = document.createDocumentFragment(); if (data.suggestion) { const n = el("div", "notice notice-suggest"); n.appendChild(document.createTextNode("Did you mean ")); const b = el("button", "suggest-link", data.suggestion); b.onclick = () => { $("#query").value = data.suggestion; runSearch(); }; n.appendChild(b); n.appendChild(document.createTextNode("?")); frag.appendChild(n); } if (data.low_confidence) { frag.appendChild(el("div", "notice notice-low", "No close matches were found for this search. Check the spelling or try a different word.")); } // Query decomposition chips. Dormant: no current backend sends // query_mentions. Reserved for the planned LLM query interpreter, which // will split a mixed prompt into a phenotype concept plus med/lab/procedure // mentions and return them in this field. const mentions = (data.query_mentions || []).filter((m) => m.category !== state.category); if (mentions.length) { const n = el("div", "notice notice-mentions"); n.appendChild(el("span", "mentions-lead", "Also in your search:")); mentions.forEach((m) => { const b = el("button", "mention-chip"); b.appendChild(el("span", null, m.concept)); b.appendChild(el("span", "mention-cat", CATS[m.category].label)); b.title = `Search ${CATS[m.category].label.toLowerCase()} codes for "${m.concept}"`; b.onclick = () => searchRelated(m.category, m.concept); n.appendChild(b); }); frag.appendChild(n); } return frag; } // -- code table renderer -------------------------------------------------- // Family of a code-search row, for the group-by-family toggle. Diagnosis // derives the 3-character ICD stem from the code text; procedure and lab // use the mapping annotations when the backend provides them. function famKey(r) { if (state.category === "diagnosis") { const s = String(r.code || "").split(".")[0].split("-")[0].trim(); return s || null; } if (state.category === "procedure") return r.rbcs ? (r.rbcs.family || r.rbcs.subcategory) : null; if (state.category === "lab") return r.mapped_loinc || null; return null; } function renderCodes(data, secs, cached) { state.results = data.results; state.groupFam = false; state.mergeDupes = false; state.page = 1; state.seenKeys = new Set(); // Search ranks and reports a score; a lookup does neither, whatever its // match field says. The rows themselves decide whether a Relevance column // exists, so a table never shows a column of blanks. state.ranked = (data.results[0] || {}).relevance != null; $("#status").innerHTML = `${data.count.toLocaleString()} of ${data.total.toLocaleString()} results for "${escHtml(data.query)}"${timing(secs, cached)}`; const box = $("#results"); box.innerHTML = ""; box.appendChild(queryNotices(data)); if (!data.results.length) { $("#status").textContent = `No results for "${data.query}".`; afterResults(0); return; } const host = el("div"); host.id = "code-table-host"; box.appendChild(host); drawCodeTable(); afterResults(data.results.length); } // Display spelling only. The API returns the index's own tokens; the value // used for filtering, collection payloads and CSV export is never changed, // since it must round-trip to the API exactly. ICD keeps its published // spelling. The other two are VA CDW column names adopted straight from the // source tables, shown as the list they actually are with the raw column // name in brackets, so a CDW user still recognises the field it came from. const SYSTEM_LABELS = { ICD9: "ICD-9", ICD10: "ICD-10", "Local Drug SID": "VA Drug List (Local Drug SID)", LabChemTestSID: "VA Lab List (LabChemTestSID)", }; const systemLabel = (ct) => SYSTEM_LABELS[ct] || ct || ""; // Which code-search rows open a knowledge graph. Diagnosis = ICD (full); // procedure = ICD-10-PCS / ICD-9-Proc, plus CPT rows the API maps to an RBCS // group. A VA lab SID is clickable when the API returns a LOINC mapping. // Medication rows use local maps, then RxNAV. function codeSearchGraphable(cat, ct, row) { ct = ct || ""; if (cat === "diagnosis") return true; if (cat === "procedure") return ct.includes("PCS") || ct.includes("ICD-9-Proc") || !!(row && row.graphable); if (cat === "lab") return ct.includes("LOINC") || !!(row && row.graphable); if (cat === "medication") return true; return false; } // Take a whole result page in one click. The VA reviewers asked for this: a // search that returns the right 50 codes is already the code list they came // for, and ticking 50 boxes to say so is the entire cost of using the tool. // Scope is deliberately the page, not the query: what is on screen is what a // reviewer has read, so it is the only set they can vouch for. function resultsSelectAll(items) { const b = el("button", "fchip fchip-all"); b.type = "button"; const sync = () => { const missing = items.filter((it) => !inBasket(it)).length; b.textContent = missing ? `Select all ${items.length}` : `All ${items.length} selected`; b.disabled = !missing; b.title = missing ? `Add every result on this page to your collection (${missing} not yet on it)` : "Every result on this page is already on your collection"; }; b.onclick = () => { items.forEach((it) => { if (!inBasket(it)) toggleBasket(it); }); // The row buttons each own their state, so they are told rather than // rebuilt: rebuilding the table would lose the scroll position. $("#results").querySelectorAll(".collect-btn") .forEach((c) => { if (c.syncCollect) c.syncCollect(); }); sync(); }; sync(); return b; } const codeItem = (r) => ({ kind: "code", category: state.category, code: r.code, code_type: r.code_type, description: r.description }); // VA registers a new lab test id per station, so one test arrives as dozens // of rows. The merge option collapses rows that name one test into one face // row carrying its duplicates. Two rows count as one test when their names // share the same words in any order after case, punctuation and a leading // article are folded, when both map to one LOINC, or when their names differ // only by noise: a vocabulary of station, process and instrument tags mined // from the LOINC crosswalk by scripts/build_lab_noise_vocab.py, plus two // pattern rules (retired zz prefixes, 4+ digit site codes). No noise word is // hand-picked: each is served with its evidence and shown in the panel the // merge chip opens. Two gates keep the noise rule honest. Rows naming // different specimens never merge (blood is not urine), and shorter numbers // stay significant, so factor 8 never joins factor 9. Fuzzy matching is // still deliberately absent: free t4 and free t3 differ by one character and // are different tests. The article rule is position-aware so hepatitis a // never collapses into hepatitis. const ARTICLES = new Set(["a", "an", "the"]); // Specimen words are noise against a row that names none, but a conflict // against a row that names a different one; the gate in computeUnits holds // that line. Mirrors SPECIMEN_WORDS in scripts/lib/textrules.py. const SPECIMEN_WORDS = new Set(["blood", "bld", "serum", "ser", "plasma", "plas", "urine", "ur", "csf", "stool", "feces", "saliva", "tissue", "fluid", "sweat", "semen"]); // The ignored-word list is the user's own: it starts from the mined defaults // the server ships (/api/lab/noise) and every edit made in the panel is kept // in this browser as additions and removals over those defaults, so a // rebuilt vocabulary updates the defaults without erasing anyone's edits. // Until the defaults arrive (or on a deployment without the file) only the // user's additions and the pattern rules apply: fewer merges, never wrong. const MERGE_WORDS_KEY = "encode.merge_ignored_v1"; let wordEdits = (() => { try { return JSON.parse(localStorage.getItem(MERGE_WORDS_KEY)) || { added: [], removed: [] }; } catch (_) { return { added: [], removed: [] }; } })(); const saveWordEdits = () => localStorage.setItem(MERGE_WORDS_KEY, JSON.stringify(wordEdits)); const labNoise = { defaults: [], set: new Set() }; function rebuildIgnored() { const s = new Set(labNoise.defaults.filter((t) => !wordEdits.removed.includes(t))); wordEdits.added.forEach((t) => s.add(t)); labNoise.set = s; } rebuildIgnored(); // the user's own additions work before (and without) the fetch async function loadLabNoise() { try { const d = await apiGet("/api/lab/noise"); labNoise.defaults = (d.tokens || []).map((t) => t.token); rebuildIgnored(); // The list changes what counts as a duplicate; a lab table drawn before // it arrived is redrawn so the merge chip reflects it. if (state.category === "lab" && state.results.length && $("#code-table-host")) drawCodeTable(); } catch (_) { /* pattern rules and user additions only */ } } const noiseToken = (t) => labNoise.set.has(t) || t.startsWith("zz") || /^\d{4,}$/.test(t); function labTokens(r) { const tokens = String(r.description || "").toLowerCase() .replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter(Boolean); if (tokens.length > 1 && ARTICLES.has(tokens[0])) tokens.shift(); return tokens; } function dupeKeys(r) { const tokens = labTokens(r); const name = tokens.length ? tokens.sort().join(" ") : `code ${r.code}`; const keys = [`${r.code_type}|name:${name}`]; if (r.mapped_loinc) keys.push(`${r.code_type}|loinc:${r.mapped_loinc}`); return keys; } // Union-find over the rows, so a synonym row mapped to the same LOINC can // bridge two spellings that share no words. function computeUnits(rows) { const parent = rows.map((_, i) => i); const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }; const union = (i, j) => { const a = find(i), b = find(j); if (a !== b) parent[a] = b; }; const byKey = new Map(); rows.forEach((row, i) => { dupeKeys(row).forEach((key) => { if (byKey.has(key)) union(i, byKey.get(key)); else byKey.set(key, i); }); }); // The noise rule. Rows whose noise-stripped cores match are one test, with // the specimen gate: when a core group names two or more specimens, rows // merge only within their own specimen, and the no-specimen rows only with // each other. A name that is all noise never merges by core at all. const coreGroups = new Map(); rows.forEach((row, i) => { const tokens = labTokens(row); const core = tokens.filter((t) => !noiseToken(t) && !SPECIMEN_WORDS.has(t)); if (!core.length) return; const key = `${row.code_type}|core:${core.sort().join(" ")}`; const spec = tokens.filter((t) => SPECIMEN_WORDS.has(t)).sort().join(" "); if (!coreGroups.has(key)) coreGroups.set(key, []); coreGroups.get(key).push({ i, spec }); }); coreGroups.forEach((members) => { const specs = new Set(members.map((m) => m.spec).filter(Boolean)); const anchors = new Map(); members.forEach((m) => { const cls = specs.size > 1 ? m.spec : ""; if (anchors.has(cls)) union(m.i, anchors.get(cls)); else anchors.set(cls, m.i); }); }); const unitOf = new Map(); const units = []; rows.forEach((row, i) => { const root = find(i); const unit = unitOf.get(root); if (unit) unit.members.push(row); else { const u = { row, members: [] }; unitOf.set(root, u); units.push(u); } }); return units; } const mergeUnits = (rows) => state.mergeDupes ? computeUnits(rows) : rows.map((row) => ({ row, members: [] })); // Client-side pages over the one fetched result set. Fifty rows per page is // the default; the count is a sidebar setting because a reviewer skimming // wants more rows on screen than one grading them. Changing it only redraws: // the rows are already fetched. const PAGE_KEY = "encode.page_size"; const PAGE_MAX = 500; function pageSize() { const n = Math.round(Number($("#page-size").value)); return n >= 1 ? Math.min(n, PAGE_MAX) : 50; } const markSeen = (row) => state.seenKeys.add(isPheno() ? row.phenotype_id : row.rank); function pageSlice(items) { const per = pageSize(); const pages = Math.max(1, Math.ceil(items.length / per)); state.page = Math.min(Math.max(state.page, 1), pages); return { pageItems: items.slice((state.page - 1) * per, state.page * per), pages }; } function pagerBar(pages, redraw) { if (pages <= 1) return null; const box = el("div", "pager"); const flip = (step) => () => { state.page += step; redraw(); window.scrollTo(0, 0); }; const prev = el("button", "fchip", "Previous"); prev.type = "button"; prev.disabled = state.page <= 1; prev.onclick = flip(-1); const next = el("button", "fchip", "Next"); next.type = "button"; next.disabled = state.page >= pages; next.onclick = flip(1); box.appendChild(prev); box.appendChild(el("span", "pager-info", `Page ${state.page} of ${pages}`)); box.appendChild(next); return box; } // The results toolbar. Code-system choice is not here: it lives in the sidebar // and restricts the search itself, so by the time rows reach this table they // are already the systems the user asked for. What is left is taking the page // and grouping it. function codeFilterBar(pageRows) { const bar = el("div", "filter-bar"); bar.appendChild(resultsSelectAll(pageRows.map(codeItem))); const fams = state.results.map(famKey).filter(Boolean); if (fams.length && new Set(fams).size < fams.length) { const gchip = el("button", "fchip", "Group by family"); gchip.title = FAMILY_HINT[state.category] || ""; gchip.classList.toggle("on", state.groupFam); gchip.onclick = () => { state.groupFam = !state.groupFam; drawCodeTable(); }; bar.appendChild(gchip); } if (state.category === "lab" && computeUnits(state.results).length < state.results.length) { const mchip = el("button", "fchip", "Merge duplicates"); mchip.title = mergeRulesHint(); mchip.classList.toggle("on", state.mergeDupes); mchip.onclick = () => { state.mergeDupes = !state.mergeDupes; state.page = 1; drawCodeTable(); }; bar.appendChild(mchip); } return bar; } // The merge rules, as the hover states them. dupeKeys/computeUnits implement // exactly these, so the bullets and the behaviour cannot drift apart. const mergeRulesHint = () => "Rows merge when:\n" + "• same words in any order, symbols ignored\n" + "• same LOINC mapping\n" + "• selected words are ignored (edit the list while merging is on)\n" + "Different numbers and specimens are not merged."; // The ignored-word list, shown and editable while merging is on. Click a // word to stop ignoring it; type to add one; Reset restores the served // defaults. Every edit redraws the table, so what merges is always what the // list says. function mergeRulesPanel() { if (!state.mergeDupes) return null; const commit = () => { saveWordEdits(); rebuildIgnored(); drawCodeTable(); }; const box = el("div", "merge-rules"); const chips = el("div", "chips"); chips.appendChild(el("span", "chips-label", "Ignored words")); [...labNoise.set].sort().forEach((t) => { const c = el("button", "chip-static chip-word", t); c.type = "button"; c.title = "Click to stop ignoring this word"; c.onclick = () => { wordEdits.added = wordEdits.added.filter((w) => w !== t); if (labNoise.defaults.includes(t) && !wordEdits.removed.includes(t)) wordEdits.removed.push(t); commit(); }; chips.appendChild(c); }); const add = el("input", "chip-add"); add.type = "text"; add.placeholder = "add word"; add.setAttribute("aria-label", "Add a word to ignore when merging"); add.onkeydown = (e) => { e.stopPropagation(); if (e.key !== "Enter") return; const w = add.value.toLowerCase().replace(/[^a-z0-9]+/g, ""); if (!w || labNoise.set.has(w)) { add.value = ""; return; } wordEdits.removed = wordEdits.removed.filter((x) => x !== w); if (!labNoise.defaults.includes(w)) wordEdits.added.push(w); commit(); }; chips.appendChild(add); if (wordEdits.added.length || wordEdits.removed.length) { const reset = el("button", "link chip-reset", "Reset"); reset.type = "button"; reset.title = "Restore the default list"; reset.onclick = () => { wordEdits = { added: [], removed: [] }; commit(); }; chips.appendChild(reset); } box.appendChild(chips); return box; } // What "family" means, per category, in one line. famKey computes exactly // these, so the sentence and the grouping cannot drift apart. const FAMILY_HINT = { diagnosis: "Collapses the results into their shared 3-character ICD category.", procedure: "Collapses the results into their shared RBCS family.", lab: "Collapses the results into the LOINC term they map to.", }; // A whole row opens its detail. Never steal a text selection: copying codes and // descriptions is part of the review workflow. Rows also take focus and answer // Enter/Space, because a click handler on a is unreachable by keyboard and // the arrow button that used to provide that path has been removed. function rowOpens(tr, open) { tr.classList.add("row-click"); tr.tabIndex = 0; tr.onclick = () => { if (!String(window.getSelection()).length) open(); }; tr.onkeydown = (e) => { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); open(); }; } function drawCodeTable() { const host = $("#code-table-host"); host.innerHTML = ""; const units = mergeUnits(state.results); const { pageItems, pages } = pageSlice(units); pageItems.forEach((u) => { markSeen(u.row); u.members.forEach(markSeen); }); host.appendChild(codeFilterBar(pageItems.flatMap((u) => [u.row, ...u.members]))); const rules = mergeRulesPanel(); if (rules) host.appendChild(rules); const table = el("table", "code-table"); const thead = el("thead"); const hr = el("tr"); ["Select", "Grade", "Rank", "Code Type", "Code", "Description"] .concat(state.ranked ? ["Relevance"] : []) .forEach((h) => { const th = el("th", h === "Grade" ? "ann-col-grade" : null, h); if (COL_HELP[h]) th.title = COL_HELP[h]; hr.appendChild(th); }); thead.appendChild(hr); table.appendChild(thead); const tb = el("tbody"); const rowTr = (r, members) => { const tr = el("tr"); const collectTd = el("td", "ann-cell"); collectTd.appendChild(collectBtn(codeItem(r), true, (members || []).map(codeItem))); tr.appendChild(collectTd); const gradeTd = el("td", "ann-cell ann-cell-grade"); gradeTd.appendChild(gradeControl(r.rank, r, true, members)); tr.appendChild(gradeTd); tr.appendChild(el("td", "col-rank", String(r.rank))); tr.appendChild(withGlossary(el("td", "col-type", systemLabel(r.code_type)), systemLabel(r.code_type))); const codeTd = el("td", "col-code"); // The NDC and VA drug indexes hold one row per product with every package // code packed into `code` — thousands of them for something like oxygen. // Show the first and say how many more; the full list is in the detail. const packed = codeList(r.code); const shown = packed.length > 1 ? `${packed[0]} +${packed.length - 1}` : r.code; const derived = !!(r.mapping_derived || (r.phecodes && r.phecodes.derived)); // The code is styled as the link the whole row already is: hover told you // a row was clickable only once the pointer was on it, which is what // testing meant by not knowing where to click for related codes. const codeSpan = el("span", "code-link" + (derived ? " derived-map" : ""), shown); if (derived) codeSpan.title = "Derived mapping, not provided by a source vocabulary"; codeTd.appendChild(codeSpan); // An unmapped lab row has no LOINC and no graph, and looked identical to a // mapped one until it was opened. Mark it before the click. if (state.category === "lab" && !r.mapped_loinc) { const tag = el("span", "tag tag-muted", "no LOINC"); tag.title = "No LOINC assignment in the crosswalk for this VA test"; codeTd.appendChild(tag); } if (packed.length > 1) codeTd.title = `${packed.length} codes for this product`; tr.appendChild(codeTd); const descTd = el("td", "col-desc", r.description); if (members && members.length) { const n = members.length; const tag = el("button", "tag tag-muted", `+${n} duplicate${n === 1 ? "" : "s"}`); tag.type = "button"; tag.title = `Shows or hides the ${n} other row${n === 1 ? "" : "s"} merged into this one.\n` + mergeRulesHint(); descTd.appendChild(tag); tr.dupeTag = tag; } tr.appendChild(descTd); if (state.ranked) tr.appendChild(el("td", "col-rel", r.relevance.toFixed(4))); rowOpens(tr, () => openCodeDetail(r)); return tr; }; // A merged face row is followed by its hidden members, and its tag folds // them out for inspection. const emitUnit = (u, push) => { const face = rowTr(u.row, u.members); push(face); if (!u.members.length) return; const memberTrs = u.members.map((m) => { const tr = rowTr(m); tr.classList.add("dupe-member", "dupe-hidden"); push(tr); return tr; }); face.dupeTag.onclick = (e) => { e.stopPropagation(); const open = face.dupeTag.classList.toggle("on"); memberTrs.forEach((mtr) => mtr.classList.toggle("dupe-hidden", !open)); }; }; if (state.groupFam) { // Cluster under families, first appearance keeps the best-rank order. // A family header shows the stem's own description when it was retrieved, // and clicking it folds the family's rows. const groups = new Map(); pageItems.forEach((u) => { const key = famKey(u.row) || "Other"; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(u); }); groups.forEach((gunits, key) => { const head = el("tr", "fam-row"); const td = el("td"); td.colSpan = state.ranked ? 7 : 6; const stemRow = gunits.map((u) => u.row).find((r) => String(r.code).trim() === key); const total = gunits.reduce((n, u) => n + 1 + u.members.length, 0); td.textContent = `${key}${stemRow && stemRow.description ? " — " + stemRow.description : ""} · ${total}`; head.appendChild(td); const children = []; head.onclick = () => { const collapsed = head.classList.toggle("fam-collapsed"); children.forEach((tr) => tr.classList.toggle("hidden", collapsed)); }; tb.appendChild(head); gunits.forEach((u) => emitUnit(u, (tr) => { children.push(tr); tb.appendChild(tr); })); }); } else { pageItems.forEach((u) => emitUnit(u, (tr) => tb.appendChild(tr))); } table.appendChild(tb); const wrap = el("div", "table-wrap"); wrap.appendChild(table); host.appendChild(wrap); const pg = pagerBar(pages, drawCodeTable); if (pg) host.appendChild(pg); } // -- phenotype table renderer: same one-row style as the code-search table -- function renderPhenotypes(data, secs, cached) { state.results = data.results; state.page = 1; state.seenKeys = new Set(); $("#status").innerHTML = `${data.count} phenotype candidate(s) for "${escHtml(data.query)}"${timing(secs, cached)}`; const box = $("#results"); box.innerHTML = ""; box.appendChild(queryNotices(data)); if (!data.results.length) { $("#status").textContent = `No candidates for "${data.query}".`; afterResults(0); return; } const host = el("div"); host.id = "pheno-table-host"; box.appendChild(host); drawPhenoTable(); afterResults(data.results.length); } function drawPhenoTable() { const host = $("#pheno-table-host"); host.innerHTML = ""; const { pageItems, pages } = pageSlice(state.results); pageItems.forEach((res) => markSeen(res)); const bar = el("div", "filter-bar"); bar.appendChild(resultsSelectAll(pageItems.map((res) => ({ kind: "phenotype", category: "phenotype", code: String(res.phenotype_id), code_type: "CIPHER phenotype", description: res.title })))); host.appendChild(bar); const table = el("table", "code-table"); const thead = el("thead"); const hr = el("tr"); ["Select", "Grade", "Rank", "Phenotype", "Category", "Codes", "Relevance"] .forEach((h) => { const th = el("th", h === "Grade" ? "ann-col-grade" : null, h); if (COL_HELP[h]) th.title = COL_HELP[h]; hr.appendChild(th); }); thead.appendChild(hr); table.appendChild(thead); const tb = el("tbody"); const base = (state.page - 1) * pageSize(); pageItems.forEach((res, i) => tb.appendChild(phenoRow(res, base + i + 1))); table.appendChild(tb); const wrap = el("div", "table-wrap"); wrap.appendChild(table); host.appendChild(wrap); const pg = pagerBar(pages, drawPhenoTable); if (pg) host.appendChild(pg); } // "ICD-9 Diagnostic Codes" -> "ICD-9", for the compact codes column. const shortSystem = (cs) => String(cs || "").replace(/\s*(Diagnostic|Procedure)?\s*Codes?\s*$/i, "").trim(); function phenoRow(res, rank) { const tr = el("tr"); const collectTd = el("td", "ann-cell"); collectTd.appendChild(collectBtn({ kind: "phenotype", category: "phenotype", code: String(res.phenotype_id), code_type: "CIPHER phenotype", description: res.title }, true)); tr.appendChild(collectTd); const gradeTd = el("td", "ann-cell ann-cell-grade"); gradeTd.appendChild(gradeControl(res.phenotype_id, res, true)); tr.appendChild(gradeTd); tr.appendChild(el("td", "col-rank", String(rank))); const titleTd = el("td", "col-desc"); titleTd.appendChild(el("span", null, res.title || `Phenotype ${res.phenotype_id}`)); tr.appendChild(titleTd); tr.appendChild(el("td", "col-type", res.category || "")); // col-codes, not plain col-type: this cell lists every code system a // phenotype carries, and inheriting nowrap forced it to ~650px, squeezing // the title and pushing Relevance off the right edge of the table. tr.appendChild(el("td", "col-type col-codes", (res.code_evidence || []).map((e) => `${shortSystem(e.code_system)} ${e.code_count}`).join(" · "))); tr.appendChild(el("td", "col-rel", res.scores.relevance.toFixed(4))); rowOpens(tr, () => openPhenotypeDetail(res.phenotype_id)); return tr; } // Single-select grade, shared by the code table and the phenotype cards so a // reviewer's hand goes to the same control whichever search they are running. // Clicking the selected grade clears it; ungraded stays a valid state. // A result can show two controls at once (card and detail drawer), so every // control for a key registers a sync and a change refreshes them all. // A merged face row passes its duplicates as followers, so one click grades // the whole group. const gradeSyncs = {}; function gradeControl(key, row, compact, followers) { const box = el("div", "grade-seg" + (compact ? " grade-seg-compact" : "")); const btns = []; const sync = () => { const cur = (state.annotations[key] || {}).grade || null; btns.forEach(([g, b]) => { b.classList.toggle("on", g === cur); b.setAttribute("aria-pressed", String(g === cur)); }); }; gradeSyncs[key] = (gradeSyncs[key] || []).filter((e) => e.box.isConnected); gradeSyncs[key].push({ box, sync }); GRADES.forEach(([g, label]) => { const b = el("button", "grade-opt", label); b.type = "button"; b.title = GRADE_HELP[g]; b.onclick = (e) => { e.stopPropagation(); const a = state.annotations[key] || (state.annotations[key] = { row }); a.grade = a.grade === g ? null : g; (followers || []).forEach((f) => { const fa = state.annotations[f.rank] || (state.annotations[f.rank] = { row: f }); fa.grade = a.grade; }); state.gradesDirty = true; [key, ...(followers || []).map((f) => f.rank)].forEach((fk) => (gradeSyncs[fk] || []).forEach((x) => { if (x.box.isConnected) x.sync(); })); }; btns.push([g, b]); box.appendChild(b); }); sync(); return box; } // -- annotations submit --------------------------------------------------- // The stored record keeps one boolean per grade so existing analysis of the // relevant/related/unsure columns still works; at most one is true per row. const gradeFlags = (a) => ({ relevant: a.grade === "relevant", related: a.grade === "related", not_relevant: a.grade === "not_relevant", unsure: a.grade === "unsure", }); async function submitAnnotations() { const graded = Object.entries(state.annotations).filter(([, a]) => a.grade); if (!graded.length) { $("#submit-msg").textContent = "Grade at least one result first."; return; } // Rows on opened pages are submitted, graded rows with their grade and // ungraded rows as unsure. Rows on pages never opened are left out, since // the reviewer cannot vouch for rows they never saw. const entries = state.results .map((row, i) => ({ key: isPheno() ? row.phenotype_id : row.rank || i + 1, row })) .filter(({ key }) => state.seenKeys.has(key)) .map(({ key, row }) => { const a = state.annotations[key]; return { key, row, grade: a && a.grade ? a.grade : "unsure" }; }); const nUnsure = entries.filter((e) => e.grade === "unsure").length; const skipped = state.results.length - entries.length; const annotator = $("#annotator").value; let res; if (isPheno()) { const annotations = entries.map(({ key, row, grade }) => ({ phenotype_id: Number(key), title: row.title, score: row.scores ? row.scores.relevance : null, ...gradeFlags({ grade }), })); res = await postJSON("/api/annotations", { annotator, query: state.query, model: state.model, annotations }); } else { const annotations = entries.map(({ key, row, grade }) => ({ rank: Number(key), code: row.code, code_type: row.code_type, description: row.description, score: row.relevance != null ? row.relevance : null, ...gradeFlags({ grade }), })); res = await postJSON("/api/code/annotations", { annotator, category: state.category, query: state.query, model: state.model, annotations }); } // Each submit is stored in full and stamped with its own time, so a second // pass over the same query adds a submission rather than replacing the // first. Saying so is what stops a reviewer treating submit as one shot. state.gradesDirty = false; $("#submit-msg").textContent = `✓ Saved ${res.saved} label(s) as ${res.annotator}.` + (nUnsure > 0 ? ` ${nUnsure} ungraded row(s) recorded as Unsure.` : "") + (skipped > 0 ? ` ${skipped} row(s) on unopened pages were left out.` : "") + " Earlier submissions are kept."; } async function postJSON(url, body) { return (await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json(); } // One nudge per session, shown after the first export of anything: the CSV // just taken is the evidence the grades describe, so this is the moment a // rating costs the least. Only when a gradeable result set is on screen — // a nudge pointing at nothing teaches people to dismiss it. let exportNudged = false; function nudgeAfterExport() { if (exportNudged) return; if (!$("#results .grade-seg")) return; exportNudged = true; $("#export-modal").classList.remove("hidden"); setTimeout(() => $("#xm-rate").focus(), 0); } function closeExportNudge() { $("#export-modal").classList.add("hidden"); } function exportCsv() { if (!state.results.length) return; const esc = (s) => `"${String(s == null ? "" : s).replace(/"/g, '""')}"`; const [header, rows] = isPheno() ? [["rank", "phenotype_id", "title", "category", "validated", "relevance", "code_systems"], state.results.map((r, i) => [i + 1, r.phenotype_id, r.title, r.category, r.validated, r.scores.relevance, (r.code_systems || []).join("; ")])] : [["rank", "code_type", "code", "description", "relevance"], state.results.map((r) => [r.rank, r.code_type, r.code, r.description, r.relevance])]; const csv = [header.join(","), ...rows.map((row) => row.map(esc).join(","))].join("\n"); const blob = new Blob([csv], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `encode_${state.category}_${(state.query || "export").replace(/\s+/g, "_").slice(0, 30)}.csv`; a.click(); URL.revokeObjectURL(a.href); nudgeAfterExport(); } // -- phenotype detail drawer ---------------------------------------------- // Description sources that contain a resolved label. const RESOLVED_SRC = (s) => s && !["study_specific", "unresolved", "label_missing"].includes(s) && !s.startsWith("needs_vocab"); // Which vocabulary answered, short enough to sit beside the label. const SRC_NAMES = { loinc: "LOINC", rxnorm: "RxNorm", rxnorm_ndc: "RxNorm", ndc_cipher: "NDC", va_dim: "VA dict", va_labname: "VA lab", exact: "CIPHER", prefix_expanded: "CIPHER" }; const SRC_LABEL = (s) => SRC_NAMES[s] || (s || "").split(":")[0].replace(/_/g, " "); // Spelled out on hover, because a reader cannot be expected to infer what an // "approximate" label means for a code they are about to put in a cohort. const MATCH_HELP = { exact: "Exact match. The code was found as written.", normalised: "Normalised match. Punctuation was fixed before lookup. For example, E1100 becomes E11.00.", approximate: "Approximate match. A rule matched this code, so it may not be exact. Check it before you use it.", }; // A medication row's `code` is every package code of one product, comma-joined. const codeList = (code) => String(code || "").split(",").map((c) => c.trim()).filter(Boolean); // Which search category a CIPHER code group belongs to, for collection items. // Procedure checks run before ICD so "ICD-10 Procedure Codes" lands right. function systemCategory(cs) { cs = (cs || "").toLowerCase(); // "rxcui" is here because the graph collects ingredient nodes by that label; // without it an RxNorm ingredient lands in the collection as "other". if (cs.includes("rxnorm") || cs.includes("rxcui") || cs.includes("ndc") || cs.includes("med") || cs.includes("drug")) return "medication"; if (cs.includes("loinc") || cs.includes("lab")) return "lab"; if (cs.includes("proc") || cs.includes("cpt") || cs.includes("hcpcs")) return "procedure"; if (cs.includes("icd") || cs.includes("diagnos") || cs.includes("phecode") || cs.includes("snomed")) return "diagnosis"; return "other"; } function exportPhenotypeCodes(p) { const esc = (s) => `"${String(s == null ? "" : s).replace(/"/g, '""')}"`; const rows = [["phenotype_id", "title", "code_system", "sub_type", "code", "description", "label_source"].join(",")]; p.code_groups.forEach((g) => (g.codes || []).forEach((x) => { rows.push([p.phenotype_id, p.title, g.code_system, g.sub_type || "", x.code, x.description || "", x.description_source || ""].map(esc).join(",")); })); const blob = new Blob([rows.join("\n")], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `encode_phenotype_${p.phenotype_id}_codes.csv`; a.click(); URL.revokeObjectURL(a.href); nudgeAfterExport(); } // -- code detail drawer ---------------------------------------------------- // One place for everything the table used to bury in hover tooltips: the // mappings, their validation status, the packed package codes, and the // hierarchy view as an explicit action instead of a surprise click. // What the abbreviations stand for, and nothing else. A tooltip that explains // is a definition the reader did not ask for; a tooltip that expands answers // the only question an acronym raises. Keyed by the exact string the UI // prints, so a term gains its expansion once it is written the same way in // both places. Names with no expansion (RxNorm, phecodeX) are absent rather // than described. const GLOSSARY = { "ICD-9": "International Classification of Diseases, Ninth Revision, Clinical Modification", "ICD-10": "International Classification of Diseases, Tenth Revision, Clinical Modification", "CPT": "Current Procedural Terminology", "CPT / HCPCS codes": "Current Procedural Terminology / Healthcare Common Procedure Coding System", "NDC": "National Drug Code", "RXCUI": "RxNorm Concept Unique Identifier", "LOINC": "Logical Observation Identifiers Names and Codes", "Mapped LOINC": "Logical Observation Identifiers Names and Codes", "LOINC component": "Logical Observation Identifiers Names and Codes", "LOINC terms": "Logical Observation Identifiers Names and Codes", "Phecode": "Phenotype code", "Phecode (v1.2)": "Phenotype code, version 1.2", "Phecode (phecodeX)": "Phenotype code, phecodeX", "Phecode link": "Phenotype code", "RBCS": "Restructured BETOS Classification System", "RBCS group": "Restructured BETOS Classification System", "VA Drug List": "VA CDW Local Drug SID", "VA Drug List (Local Drug SID)": "VA CDW Local Drug SID", "VA Drug List ID": "VA CDW Local Drug SID", "This drug (VA Drug List)": "VA CDW Local Drug SID", "VA Lab List": "VA CDW LabChemTestSID", "VA Lab List (LabChemTestSID)": "VA CDW LabChemTestSID", "VA Lab List ID": "VA CDW LabChemTestSID", "VA lab test": "VA CDW LabChemTestSID", }; const glossary = (term) => GLOSSARY[term] || ""; // Attaches the expansion to a node when its text is an abbreviation we hold. function withGlossary(node, term) { const help = glossary(term); if (help) node.title = help; return node; } function detailField(box, label, value) { if (value == null || value === "") return; const row = el("div", "algo-row"); row.appendChild(withGlossary(el("span", "algo-key", label), label)); row.appendChild(el("span", "algo-val", value)); box.appendChild(row); } // -- full-page detail view ------------------------------------------------- // Replaces the old side drawer for code and phenotype detail. The page pushes // in over the results left → right; Back (or Escape) reverses the animation. function openDetailPage(build) { const page = $("#detail-page"); const body = $("#detail-body"); state.detailPid = null; // whatever loads next owns the page, not a late reply body.innerHTML = ""; build(body); page.classList.add("open"); page.setAttribute("aria-hidden", "false"); document.body.classList.add("detail-open"); page.scrollTop = 0; } function closeDetailPage() { const page = $("#detail-page"); if (!page.classList.contains("open")) return; page.classList.remove("open"); page.setAttribute("aria-hidden", "true"); document.body.classList.remove("detail-open"); state.detailPid = null; // Emptied after the slide-out so the page does not blank mid-animation. // Emptying matters: grade widgets left connected here are keyed by rank, // and rank 3 in the next category would keep syncing them. setTimeout(() => { if (!page.classList.contains("open")) $("#detail-body").innerHTML = ""; }, 350); } function openCodeDetail(r) { openDetailPage((body) => { const packed = codeList(r.code); body.appendChild(el("h2", "code-detail-title", packed[0])); const meta = el("div", "drawer-meta"); meta.appendChild(withGlossary(el("span", "tag", systemLabel(r.code_type)), systemLabel(r.code_type))); meta.appendChild(el("span", "tag", CATS[state.category].label)); const derived = !!(r.mapping_derived || (r.phecodes && r.phecodes.derived)); if (derived) meta.appendChild(el("span", "tag tag-warn", "derived mapping")); meta.appendChild(collectBtn({ kind: "code", category: state.category, code: r.code, code_type: r.code_type, description: r.description }, false)); meta.appendChild(gradeControl(r.rank, r, true)); body.appendChild(meta); body.appendChild(el("p", "summary detail-sec", r.description || "No description available.")); const fields = el("div", "algo-components detail-sec"); detailField(fields, "Code system", systemLabel(r.code_type)); if (packed.length > 1) { const med = state.category === "medication"; detailField(fields, med ? "Package codes" : "Merged codes", `${packed.length} ${med ? "codes for this product" : "equivalent local codes in this row"}: ` + `${packed.slice(0, 12).join(", ")}${packed.length > 12 ? ", …" : ""}`); } if (r.phecodes) { detailField(fields, "Phecode (v1.2)", phecodeLines(r.phecodes.v12)); detailField(fields, "Phecode (phecodeX)", phecodeLines(r.phecodes.x)); if (r.phecodes.provenance) detailField(fields, "Phecode link", r.phecodes.provenance); } // An unmapped VA test opened a detail page with no Details section at all, // which reads as a failure rather than an absent mapping. State it. if (r.mapped_loinc) detailField(fields, "Mapped LOINC", r.mapped_loinc); else if (state.category === "lab") { detailField(fields, "Mapped LOINC", "None in the crosswalk."); } if (r.rbcs) { // Full path, broadest first, so the group reads as one rung of a // hierarchy rather than a loose label. Testing asked why several codes // carry the same group name, which the family size answers on its own. // What RBCS stands for hangs off the label, not a row of its own. const path = [r.rbcs.category, r.rbcs.subcategory, r.rbcs.family] .filter(Boolean).join(" › "); const size = r.rbcs.family_size; detailField(fields, "RBCS group", path + (size ? ` — ${bigNum(size)} code${size === 1 ? "" : "s"} in this family` : "")); } // One line for the link itself, in the same words the graph uses: Provided // by a named authority, or Derived by ENCODE and by what method. if (r.mapping_provenance) detailField(fields, "This link", r.mapping_provenance); if (r.mapping_conflict) { detailField(fields, "This link", `None. The ${r.mapping_conflict.codes} mapped codes in this row point to ` + `${r.mapping_conflict.targets} different terms, so no single one is asserted.`); } if (fields.childNodes.length) { body.appendChild(el("h4", null, "Details")); body.appendChild(fields); } if (codeSearchGraphable(state.category, r.code_type, r)) { body.appendChild(el("h4", null, "Related codes & mappings")); const host = el("div"); body.appendChild(host); // The row's codes are equivalents; the graph opens on whichever one the // mapping is attached to, which is not always the first. const qs = `code=${encodeURIComponent(r.mapped_code || packed[0])}` + `&code_type=${encodeURIComponent(r.code_type || "")}` + (r.description ? `&drug_name=${encodeURIComponent(r.description)}` : ""); loadFlowGraph(host, `/api/graph?${qs}`, packed[0]); } }); } // Jump from a phenotype (or a plan chip) to a search seeded with a concept. // Deliberately does NOT change mode: a chip clicked in Agent mode runs its // search and renders the results underneath the plan, so working through a // plan never throws the user out of the view they started in. async function searchRelated(category, q) { if (!(await confirmLeaveGrades())) return; closeDrawer(); closeDetailPage(); $("#category").value = category; state.category = category; applyCategory(); $("#query").value = q; runSearch(); } // -- phenotype detail tabs ------------------------------------------------- // Overview answers "is this the right definition", Codes is the working // surface, Validation & source carries the trust and provenance material. function buildOverviewTab(p) { const pane = el("div"); if (p.llm_summary && p.llm_summary.summary) { pane.appendChild(el("h4", null, "Summary")); pane.appendChild(el("p", "summary", p.llm_summary.summary)); pane.appendChild(el("p", "gen-note", "Generated from the source record.")); } if (p.description) { pane.appendChild(el("h4", null, "Description")); pane.appendChild(el("p", "summary", p.description)); } if (p.population_description) { pane.appendChild(el("h4", null, "Population")); pane.appendChild(el("p", "summary", p.population_description)); } const f = p.facets; if (f) { const chips = el("div", "chips"); [["Age", f.age_group], ["Setting", f.care_setting], ["Design", f.incident_vs_prevalent]] .forEach(([k, v]) => { if (v) chips.appendChild(el("span", "chip-static", `${k}: ${v}`)); }); if (chips.childNodes.length) { pane.appendChild(el("h4", null, "Scope")); pane.appendChild(chips); } if (f.intended_use) { pane.appendChild(el("h4", null, "Intended use")); pane.appendChild(el("p", "summary", f.intended_use)); } const crit = (label, items) => { if (!items || !items.length) return; pane.appendChild(el("h4", null, label)); const ul = el("ul"); items.forEach((c) => ul.appendChild(el("li", null, c))); pane.appendChild(ul); }; crit("Inclusion criteria", f.inclusion); crit("Exclusion criteria", f.exclusion); } if ((p.keywords || []).length) { pane.appendChild(el("h4", null, "Keywords")); const kw = el("div", "chips"); p.keywords.forEach((k) => kw.appendChild(el("span", "chip-static", k))); pane.appendChild(kw); } if (!pane.childNodes.length) pane.appendChild(el("p", "summary", "No overview text in the source record.")); return pane; } function buildCodesTab(p) { const pane = el("div"); const cgHead = el("div", "cg-head"); cgHead.appendChild(el("h4", null, `Associated code groups (${p.code_groups.length})`)); const cgBtns = el("div", "cg-btns"); if (p.code_groups.some((g) => (g.codes || []).length)) { const exp = el("button", "graph-btn", "Export codes CSV"); exp.title = "Download every code in this phenotype as a CSV file"; exp.onclick = () => exportPhenotypeCodes(p); cgBtns.appendChild(exp); } if (cgBtns.childNodes.length) cgHead.appendChild(cgBtns); pane.appendChild(cgHead); p.code_groups.forEach((g) => { if (!(g.codes || []).length && !g.code_count) return; // nothing to show const det = el("details", "codegroup"); const cap = g.resolved_count ? ` · ${g.resolved_count}/${g.codes.length} described` : ""; det.appendChild(el("summary", null, `${g.code_system}${g.sub_type ? " / " + g.sub_type : ""}: ${g.code_count} codes${cap}`)); // Collection items for this group; "Select all" reuses each row's sync. const groupRows = []; const isIcd = (g.code_system || "").includes("ICD"); const itemFor = (x) => ({ kind: "code", category: systemCategory(g.code_system), code: x.code, code_type: g.code_system + (g.sub_type ? " / " + g.sub_type : ""), description: x.description || "", source_phenotype: p.phenotype_id, }); const addAllBtn = (entries, title) => { const b = el("button", "group-add-all", "Select all"); b.type = "button"; b.title = title; b.onclick = (e) => { e.preventDefault(); e.stopPropagation(); entries.forEach(({ it, btn }) => { if (!inBasket(it)) toggleBasket(it); btn.syncCollect(); }); }; return b; }; const rowFor = (x) => { const row = el("div", "code-row"); const it = itemFor(x); const cb = collectBtn(it, true); const entry = { it, btn: cb }; groupRows.push(entry); row.appendChild(cb); row.appendChild(el("span", "code", x.code)); const desc = el("span", "code-label", x.description || `(${x.label_status})`); if (!RESOLVED_SRC(x.description_source)) desc.classList.add("code-label-gap"); row.appendChild(desc); // Keep the source column present so rows align when a source is absent. const src = el("span", "code-src"); if (RESOLVED_SRC(x.description_source)) { const qualifier = x.match && x.match !== "exact" ? ` · ${x.match}` : ""; src.textContent = SRC_LABEL(x.description_source) + qualifier; src.title = [`Label source: ${x.description_source}`, x.source_version && `Release: ${x.source_version}`, x.match && `Match: ${MATCH_HELP[x.match] || x.match}`, x.concept && `Resolved to: ${x.concept.system} ${x.concept.code}`] .filter(Boolean).join("\n"); if (x.match && x.match !== "exact") src.classList.add(`code-src-${x.match}`); } row.appendChild(src); // Hierarchy behind an explicit button, not a click on the code text // (ICD -> this phenotype's phecode tree; medication -> ingredient graph). const act = el("span", "code-act"); if (x.graphable) { const gb = el("button", "code-tree-btn", "⤳"); gb.type = "button"; gb.title = isIcd ? "Show in the code graph above" : "View this code's graph above"; gb.onclick = isIcd ? () => focusDetailGraph(`/api/phenotype/${p.phenotype_id}/graph?focus=${encodeURIComponent(x.code)}`, p.title) : () => focusDetailGraph(`/api/graph?code=${encodeURIComponent(x.code)}&code_type=${encodeURIComponent(g.code_system)}`, x.code); act.appendChild(gb); } row.appendChild(act); return { row, entry }; }; if (g.codes.length) { const bar = el("div", "group-actions"); bar.appendChild(addAllBtn(groupRows, "Select every code in this group for your collection")); det.appendChild(bar); } const table = el("div", "codes"); // ICD groups nest one level: 3-character parent stem -> child codes. // Other systems have no client-derivable family and stay flat. const stems = new Map(); if (isIcd && g.codes.length > 6) { g.codes.forEach((x) => { const s = String(x.code || "").split(".")[0].split("-")[0].trim() || "?"; if (!stems.has(s)) stems.set(s, []); stems.get(s).push(x); }); } if (stems.size && stems.size < g.codes.length) { stems.forEach((codes, stem) => { if (codes.length === 1) { table.appendChild(rowFor(codes[0]).row); return; } const fam = el("details", "codefam"); fam.open = true; const sum = el("summary", "codefam-sum"); sum.appendChild(el("span", "code", stem)); sum.appendChild(el("span", "codefam-count", `${codes.length} codes`)); const famEntries = []; sum.appendChild(addAllBtn(famEntries, "Select every code in this family for your collection")); fam.appendChild(sum); const inner = el("div", "codes codes-nested"); codes.forEach((x) => { const { row, entry } = rowFor(x); famEntries.push(entry); inner.appendChild(row); }); fam.appendChild(inner); table.appendChild(fam); }); } else { g.codes.forEach((x) => table.appendChild(rowFor(x).row)); } det.appendChild(table); pane.appendChild(det); }); // Concepts named in this phenotype's own record, offered as one-click // searches in the other categories. Never codes, only search seeds. const rb = p.related_bundle; if (rb && ["medications", "labs", "procedures"].some((k) => (rb[k] || []).length)) { const box = el("div", "drawer-more"); box.appendChild(el("h4", null, "Related concepts")); [["medication", "medications"], ["lab", "labs"], ["procedure", "procedures"]].forEach(([cat, key]) => { if (!(rb[key] || []).length) return; const row = el("div", "chips"); row.appendChild(el("span", "chips-label", CATS[cat].label)); rb[key].forEach((it) => { const b = el("button", "example-chip", it.concept); b.title = `Named in this phenotype's record. Search ${CATS[cat].label.toLowerCase()} codes for it.`; b.onclick = () => searchRelated(cat, it.concept); row.appendChild(b); }); box.appendChild(row); }); pane.appendChild(box); } const rel = el("div", "drawer-more"); rel.appendChild(el("h4", null, "Related searches")); const relRow = el("div", "chips"); const seed = (p.title || "").replace(/\s*\([^()]*\)\s*$/, "").trim(); [["medication", "Medications"], ["lab", "Labs"], ["procedure", "Procedures"]].forEach(([cat, label]) => { const b = el("button", "example-chip", label); b.title = `Search ${label.toLowerCase()} for "${seed}"`; b.onclick = () => searchRelated(cat, seed); relRow.appendChild(b); }); rel.appendChild(relRow); pane.appendChild(rel); return pane; } // 279 of the 332 CIPHER validation_description values are placeholders rather // than text — 258 "N/A", 13 "None", 8 "Coming Soon" — so a record can carry a // description field and still say nothing. Printing those verbatim is how a // reader ends up staring at a section reading "Validation: N/A". They are // treated as absent, leaving 53 records with something real to show. const VALIDATION_PLACEHOLDERS = new Set(["n/a", "na", "none", "coming soon", "-", "tbd"]); function validationText(p) { const text = (p.validation_description || "").trim(); if (text && !VALIDATION_PLACEHOLDERS.has(text.toLowerCase())) return text; return p.validated ? "CIPHER records this algorithm as validated but holds no description of " + "how it was validated." : "CIPHER holds no validation record for this phenotype. That means no " + "validation study is on file, not that the phenotype is unsound."; } function buildSourceTab(p) { const pane = el("div"); pane.appendChild(el("h4", null, "Validation")); // No badge here or in the results table. "validated" is CIPHER's own record // that a phenotype's authors ran a validation study, not a judgement by // ENCODE, and only 233 of 8,013 phenotypes carry the flag -- a green tag on // 3% of rows reads as a quality score whose absence means "unreliable". // Only 52 records have both the flag and a real description; 181 carry the // flag with nothing behind it. Prose says which of those three states this // phenotype is in, which a badge cannot. pane.appendChild(el("p", "summary", validationText(p))); if (p.algorithm_description) { pane.appendChild(el("h4", null, "Algorithm")); pane.appendChild(el("p", "summary", p.algorithm_description)); } // Available CIPHER algorithm fields. if ((p.algorithm_components || []).length) { pane.appendChild(el("h4", null, "Algorithm components")); const box = el("div", "algo-components"); p.algorithm_components.forEach((row) => { const r = el("div", "algo-row"); r.appendChild(el("span", "algo-key", row.label)); r.appendChild(el("span", "algo-val", cleanDisplayText(row.value))); box.appendChild(r); }); pane.appendChild(box); } if ((p.authors || []).length) { pane.appendChild(el("h4", null, "Authors")); pane.appendChild(el("p", "summary", p.authors.join(", "))); } if (p.publications.length) { pane.appendChild(el("h4", null, "Publications")); const ul = el("ul"); p.publications.forEach((x) => ul.appendChild(el("li", null, x))); pane.appendChild(ul); } if (p.last_modified) pane.appendChild(el("p", "gen-note", `Last modified: ${p.last_modified}`)); // More information -> CIPHER original website (professor 1.5). if (p.cipher_url) { const more = el("div", "drawer-more"); more.appendChild(el("h4", null, "More information")); const a = el("a", "cipher-link", "View this phenotype on CIPHER ↗"); a.href = p.cipher_url; a.target = "_blank"; a.rel = "noopener"; more.appendChild(a); pane.appendChild(more); } return pane; } // One scrolling page, no tabs: identity, the code graph (big), then the // overview / provenance columns and the working codes list. async function openPhenotypeDetail(pid) { openDetailPage((body) => body.appendChild(el("p", "summary", "Loading…"))); state.detailPid = pid; let p; try { p = await apiGet(`/api/phenotype/${pid}`); } catch (e) { if (state.detailPid === pid) $("#detail-body").textContent = `Could not load phenotype ${pid}: ${e.message}`; return; } // The page may belong to something else by now: another detail opened over // this one while it loaded, or the reader went back to the results. if (state.detailPid !== pid) return; const body = $("#detail-body"); body.innerHTML = ""; body.appendChild(el("h2", null, p.title)); const meta = el("div", "drawer-meta"); const kind = phenotypeKind(p.title); meta.appendChild(el("span", "tag tag-kind", KIND_LABELS[kind])); if (p.category) meta.appendChild(el("span", "tag", p.category)); meta.appendChild(el("span", "pid", `CIPHER #${p.phenotype_id}`)); meta.appendChild(collectBtn({ kind: "phenotype", category: "phenotype", code: String(p.phenotype_id), code_type: "CIPHER phenotype", description: p.title }, false)); meta.appendChild(gradeControl(p.phenotype_id, { title: p.title }, false)); body.appendChild(meta); // The graph gets the room: phecode → ICD families → codes as a mapping flow. body.appendChild(el("h4", null, "Code graph")); const graphHost = el("div"); body.appendChild(graphHost); state.phenoGraphHost = graphHost; state.phenoGraphPid = p.phenotype_id; if (p.code_groups.some((g) => (g.code_system || "").includes("ICD"))) { loadFlowGraph(graphHost, `/api/phenotype/${p.phenotype_id}/graph`, p.title); } else { graphHost.appendChild(el("p", "summary", "No ICD code graph for this phenotype. Click ⤳ next to a code below to view that code's own graph here.")); } const cols = el("div", "detail-cols"); const c1 = el("div", "detail-col"); c1.appendChild(el("h4", null, "Overview")); c1.appendChild(buildOverviewTab(p)); const c2 = el("div", "detail-col"); c2.appendChild(el("h4", null, "Validation & source")); c2.appendChild(buildSourceTab(p)); cols.appendChild(c1); cols.appendChild(c2); body.appendChild(cols); const nCodes = p.code_groups.reduce((s, g) => s + (g.codes || []).length, 0); body.appendChild(el("h4", null, `Codes (${nCodes})`)); body.appendChild(buildCodesTab(p)); } // Re-render the embedded graph area of the open phenotype page (per-code ⤳). function focusDetailGraph(url, title) { const host = state.phenoGraphHost; if (!host || !host.isConnected) return; loadFlowGraph(host, url, title); host.scrollIntoView({ behavior: "smooth", block: "nearest" }); } // -- collected-codes basket drawer ---------------------------------------- function openBasket() { const body = $("#drawer-body"); body.innerHTML = ""; const items = Object.values(basket); body.appendChild(el("h2", null, `Collected codes (${items.length})`)); if (!items.length) { body.appendChild(el("p", "summary", "Select codes and phenotypes with “+ Select”. Your list persists across searches and categories, and exports as a CSV.")); showDrawer(); return; } const actions = el("div", "basket-actions"); const exp = el("button", "cipher-link", "Export CSV ↓"); exp.onclick = exportBasket; const clr = el("button", "link", "Clear all"); clr.onclick = () => { if (confirm("Clear the whole collected list?")) { basket = {}; saveBasket(); openBasket(); } }; actions.appendChild(exp); actions.appendChild(clr); body.appendChild(actions); const list = el("div", "basket-list"); items.forEach((it) => { const row = el("div", "basket-row"); const main = el("div", "basket-main"); main.appendChild(el("span", "chip-static", it.category)); main.appendChild(el("span", "code", it.code)); main.appendChild(el("span", "basket-desc", it.description || "")); row.appendChild(main); const rm = el("button", "basket-rm", "×"); rm.title = "Remove"; rm.onclick = () => { delete basket[basketKey(it)]; saveBasket(); openBasket(); }; row.appendChild(rm); list.appendChild(row); }); body.appendChild(list); showDrawer(); } function exportBasket() { const items = Object.values(basket); if (!items.length) return; const esc = (s) => `"${String(s == null ? "" : s).replace(/"/g, '""')}"`; const rows = [ "# ENCODE collected codes", `# Exported: ${new Date().toISOString().slice(0, 16).replace("T", " ")}`, `# Items: ${items.length}`, ["kind", "category", "code_type", "code", "description", "source_query"].join(","), ...items.map((it) => [it.kind, it.category, it.code_type, it.code, it.description, it.query].map(esc).join(",")), ]; const blob = new Blob([rows.join("\n")], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `encode_collected_${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); nudgeAfterExport(); } function syncPageScrollLock() { const panelOpen = !$("#drawer").classList.contains("hidden"); document.documentElement.classList.toggle("panel-open", panelOpen); document.body.classList.toggle("panel-open", panelOpen); } function showDrawer() { $("#drawer").classList.remove("hidden"); $("#overlay").classList.remove("hidden"); syncPageScrollLock(); } function closeDrawer() { $("#drawer").classList.add("hidden"); $("#overlay").classList.add("hidden"); syncPageScrollLock(); } // -- mapping-flow graph ----------------------------------------------------- // One visual grammar for every category: columns = vocabularies/levels, // solid edges = links a source vocabulary provides, dashed edges = links // ENCODE derived. That is the distinction a reader has to act on; whether a // link crosses vocabularies is already visible from the columns it spans. // Clicking a node highlights its chain, dims the rest, and fills the side // explanation panel. function phecodeLines(entries) { return (entries || []).map((p) => `${p.phecode} ${p.label}`.trim()).join("; "); } function graphField(list, label, value) { if (value == null || value === "") return; const row = el("div", "graph-selection-row"); row.appendChild(withGlossary(el("dt", null, label), label)); // Vocabulary and code-label rows print an abbreviation as their value, so // the expansion has to reach the value too, not only the field name. row.appendChild(withGlossary(el("dd", null, value), value)); list.appendChild(row); } const SVGNS = "http://www.w3.org/2000/svg"; const svgEl = (t, a) => { const n = document.createElementNS(SVGNS, t); for (const k in a) n.setAttribute(k, a[k]); return n; }; const truncate = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : (s || "")); // Fixed vocabulary colors (identity, never rank). const VOCAB_COLORS = { "ICD-10": "#2a78d6", "ICD-9": "#eb6834", "LOINC": "#1baf7a", "Phecode": "#4a3aa7", "RxNorm": "#e87ba4", "NDC": "#2a9db0", "VA Drug List": "#5a6773", "VA Lab List": "#5a6773", "CPT": "#2a78d6", "RBCS": "#4a3aa7", "Phenotype": "#2c3744", }; const vocabColor = (v) => VOCAB_COLORS[v] || "#5a6773"; const mixHex = (hex, t) => { // tint toward white const p = (i) => parseInt(hex.slice(i, i + 2), 16); return "#" + [p(1), p(3), p(5)].map((v) => Math.round(v + (255 - v) * t).toString(16).padStart(2, "0")).join(""); }; function flowVocab(g, n) { if (g.kind === "lab") return "LOINC"; if (g.kind === "medication") return n.id.startsWith("drug:") ? "VA Drug List" : "RxNorm"; if (g.kind === "procedure") return (n.tier || 0) === 0 ? "RBCS" : "CPT"; if (g.kind === "phenotype") { if ((n.tier || 0) === 0) return "Phenotype"; // Not startsWith: a category node's id is namespaced ("cat:ICD-9:428"). return n.id.includes("ICD-9:") ? "ICD-9" : "ICD-10"; } const ct = (n.nav && n.nav.code_type) || ""; return /9/.test(ct) && !/10/.test(ct) ? "ICD-9" : "ICD-10"; } // API payload -> flow model: nodes with columns, links with mapping semantics. // Invariant: every edge spans exactly one column gap — no edge ever crosses a // middle column, so edges can never run through another column's labels. function buildFlowModel(g) { const mkNode = (n, col) => ({ id: n.id, label: n.label, sub: n.sub || "", col, // The backend's own vocabulary wins when it sends one (NDC nodes carry // it); flowVocab only fills in for payloads that never name one. vocab: n.vocab || flowVocab(g, n), current: !!n.current, more: !!n.more, nav: n.nav || null, code: n.code || null, code_label: n.code_label || null, }); const rawCurrent = g.nodes.find((n) => n.current); // Lab with a crosswalk: VA test → mapped LOINC → same-component siblings. // The component becomes the third column's title instead of a node, so the // crosswalk edge no longer jumps across it. if (g.kind === "lab" && g.mapped_from && rawCurrent) { const comp = g.nodes.find((n) => (n.tier || 0) === 0 && !n.more); const terms = g.nodes.filter((n) => (n.tier || 0) === 1); const nodes = terms.map((n) => mkNode(n, n.current ? 1 : 2)); nodes.push({ id: "valocal", label: g.mapped_from.code, sub: `${g.mapped_from.name || ""} — ${g.mapped_from.system}`.replace(/^ — /, ""), col: 0, vocab: "VA Lab List", current: false, more: false, nav: null, code: g.mapped_from.code, code_label: "VA Lab List ID" }); const links = [{ a: "valocal", b: rawCurrent.id, map: true, derived: !!g.derived, label: "crosswalk" }]; terms.filter((n) => !n.current && !n.more) .forEach((n) => links.push({ a: rawCurrent.id, b: n.id, map: false, label: "" })); const titles = ["VA lab test", "Mapped LOINC", "Same component" + (comp ? ": " + truncate(comp.label, 28) : "")]; return { kind: g.kind, nodes, links, titles }; } // Medication: drug → mapped concepts → products and packages. The NDC hub // shares the middle column with the ingredients; its package codes land in // the outer column with the related products. const medCol = (id) => (id.startsWith("drug:") ? 0 : id.startsWith("ing:") || id === "ndcgrp" ? 1 : 2); const nodes = g.nodes.map((n) => mkNode(n, g.kind === "medication" ? medCol(n.id) : (n.tier || 0))); const links = (g.edges || []).map(([a, b]) => { if (g.kind === "medication" && a.startsWith("ing:") && b.startsWith("drug:")) { return { a: b, b: a, map: true, derived: !!g.derived, label: "has ingredient" }; } return { a, b, map: false, label: "" }; }); let titles = { icd: ["ICD family", "Codes"], procedure: ["RBCS group", "CPT / HCPCS codes"], lab: ["LOINC component", "LOINC terms"], medication: ["This drug (VA Drug List)", "Mapped concepts", "Related products & package codes"], phenotype: ["Phenotype", "Code families", "Codes"], }[g.kind] || []; // ICD phecode mappings sit in their own left column; their "maps to" edges // span two columns and are drawn as bowed arcs around the family column. const current = nodes.find((n) => n.current); if (g.kind === "icd" && g.phecodes && current) { nodes.forEach((n) => n.col += 1); [].concat((g.phecodes.v12 || []).map((p) => ({ ...p, ver: "v1.2" })), (g.phecodes.x || []).map((p) => ({ ...p, ver: "phecodeX" }))) .forEach((p) => { const id = `phe:${p.ver}:${p.phecode}`; nodes.push({ id, label: p.phecode, sub: `${p.label} (${p.ver})`, col: 0, vocab: "Phecode", current: false, more: false, nav: null }); links.push({ a: current.id, b: id, map: true, label: "maps to", derived: !!(g.phecodes.derived) }); }); titles = ["Phecode", ...titles]; } // A derived procedure grouping is one edge: the clicked code hanging off a // group it was matched into rather than assigned to. if (g.derived && g.kind === "procedure" && current) { links.forEach((l) => { if (l.b === current.id) l.derived = true; }); } const nCols = Math.max(1, ...nodes.map((n) => n.col + 1)); while (titles.length < nCols) titles.push(""); return { kind: g.kind, nodes, links, titles: titles.slice(0, nCols) }; } async function loadFlowGraph(host, url, fallbackTitle) { host.innerHTML = ""; host.appendChild(el("p", "summary", "Loading graph…")); let g; try { g = await apiGet(url); } catch (e) { host.innerHTML = ""; host.appendChild(el("p", "summary", "Could not load the graph.")); return; } host.innerHTML = ""; renderFlowGraph(host, g, fallbackTitle, url); } // -- graph "Select all" ---------------------------------------------------- // A tree of related codes is usually the whole set a reviewer wants on their // list, and the only way to take it was one detail page per code. Testing // asked for this directly. // // Only nodes that carry a code of their own count. A "+N more" stub is a // window control, and an RxNorm product node names a product without holding // a code, so neither is collectable and neither is counted. What the button // says is therefore exactly what pressing it adds. function graphCodeItems(model) { const seen = new Set(); const items = []; model.nodes.forEach((n) => { if (n.more) return; // A phenotype graph's middle column is ENCODE's own grouping: the 3-char // stems it derived by splitting the leaf codes. The phenotype's code list // is the leaves, so collecting the stems would hand back codes CIPHER // never listed. Family hubs in the other graphs carry no code at all and // fall out on the next line. if (String(n.id).startsWith("cat:")) return; const code = (n.nav && n.nav.code) || n.code; if (!code) return; const codeType = (n.nav && n.nav.code_type) || n.code_label || n.vocab || ""; // Nodes titled by their code carry the description underneath; nodes // titled by a name (an RxNorm ingredient) are their own description. const description = String(n.label) === String(code) ? (n.sub || "") : n.label; const it = { kind: "code", category: systemCategory(codeType), code: String(code), code_type: codeType, description }; const key = basketKey(it); if (seen.has(key)) return; seen.add(key); items.push(it); }); return items; } // Codes the graph is holding back behind "+N more" stubs. The stub carries the // count, so the button can promise the whole family before anything is // fetched; the codes themselves only arrive when it is pressed. function graphHiddenCount(model) { return model.nodes.filter((n) => n.more) .reduce((sum, n) => sum + Number((String(n.label).match(/\d+/) || [0])[0]), 0); } // `fullUrl` refetches the same graph with every window disabled. Select all // goes through it whenever the drawn graph is a window, because a button that // says "all" and quietly means "the 11 on screen" is worse than no button: // the collapsed codes are the ones a reviewer would never notice missing. function graphSelectAll(model, fullUrl) { const shown = graphCodeItems(model); const hidden = fullUrl ? graphHiddenCount(model) : 0; const total = shown.length + hidden; if (total < 2) return null; // one code is the button it already has const b = el("button", "graph-btn flow-select-all"); b.type = "button"; let items = shown; let complete = !hidden; const sync = () => { const missing = items.filter((it) => !inBasket(it)).length; const n = complete ? items.length : total; b.textContent = missing || !complete ? `Select all ${n} codes` : `All ${n} selected`; b.disabled = complete && !missing; b.title = complete ? (missing ? `Add every code in this graph to your collection (${missing} not yet on it); nodes without a code of their own, such as related products, are not counted.` : "Every code in this graph is already on your collection") : `Includes the ${hidden} code${hidden === 1 ? "" : "s"} behind “show all”`; }; b.onclick = async () => { if (!complete) { b.disabled = true; b.textContent = "Loading every code…"; try { items = graphCodeItems(buildFlowModel(await apiGet(fullUrl))); complete = true; } catch (_) { // Collect what is on screen rather than nothing, and say so. complete = true; items = shown; b.title = "Could not load the collapsed codes; only the visible ones were added."; } } items.forEach((it) => { if (!inBasket(it)) toggleBasket(it); }); sync(); }; sync(); return b; } function renderFlowGraph(host, g, fallbackTitle, url) { if (!g.available) { host.appendChild(el("p", "summary", g.reason || "No hierarchy is available for this code.")); return; } const model = buildFlowModel(g); const wrap = el("div", "flow-wrap"); const scroll = el("div", "flow-scroll"); const aside = el("div", "flow-aside"); const selectionBox = el("section", "graph-selection"); selectionBox.setAttribute("aria-live", "polite"); // Same url the "+N more" stub reloads, so Select all reaches the same codes // expanding would have revealed. const fullUrl = url && !/[?&]cap=/.test(url) ? url + (url.includes("?") ? "&" : "?") + "cap=0" : null; const all = graphSelectAll(model, fullUrl); if (all) aside.appendChild(all); aside.appendChild(selectionBox); aside.appendChild(el("p", "flow-aside-hint", "Click a node to see details.")); wrap.appendChild(scroll); wrap.appendChild(aside); host.appendChild(wrap); host.appendChild(flowLegend()); // What the link is, then where it comes from: the provenance line opens with // Provided or Derived and reads as the answer to the sentence above it. [g.note, g.source] .filter(Boolean).forEach((n) => host.appendChild(el("p", "graph-note", n))); let selectedId = null; const select = (id) => { selectedId = selectedId === id ? null : id; draw(); renderFlowSelection(selectionBox, g, model, selectedId ? model.nodes.find((n) => n.id === selectedId) : null); }; // Clicking a "+N more" stub reloads the same graph with windowing disabled. const expand = fullUrl ? () => loadFlowGraph(host, fullUrl, fallbackTitle) : null; const draw = () => { scroll.innerHTML = ""; scroll.appendChild(buildFlowSvg(model, selectedId, select, expand)); }; // On open nothing is dimmed: the clicked/current node is ringed and its // details shown, but highlight-and-dim starts only on an explicit click. const cur = model.nodes.find((n) => n.current); if (cur) renderFlowSelection(selectionBox, g, model, cur); draw(); } // Legend under the graph: real line samples, not words describing them. function flowLegend() { const box = el("div", "flow-legend"); const entry = (dashed, label) => { const item = el("span", "flow-legend-item"); const svg = svgEl("svg", { width: 34, height: 10, "aria-hidden": "true" }); svg.appendChild(svgEl("line", { x1: 1, y1: 5, x2: 33, y2: 5, stroke: "#8a97a4", "stroke-width": 1.6, ...(dashed ? { "stroke-dasharray": "5 4" } : {}) })); item.appendChild(svg); item.appendChild(el("span", null, label)); return item; }; box.appendChild(entry(false, "Provided")); box.appendChild(entry(true, "Derived")); return box; } function buildFlowSvg(model, selectedId, onSelect, onExpand) { const cols = model.titles.length; const byCol = model.titles.map((_, i) => model.nodes.filter((n) => n.col === i)); const maxRows = Math.max(1, ...byCol.map((c) => c.length)); const rowH = maxRows > 34 ? 20 : maxRows > 22 ? 24 : 32; const COLW = 250, PADL = 26, PADT = 46, LABELW = 330; const W = PADL + (cols - 1) * COLW + LABELW; const H = Math.max(PADT + maxRows * rowH + 14, 240); const svg = svgEl("svg", { width: W, height: H, viewBox: `0 0 ${W} ${H}`, class: "flow-svg" }); const pos = {}; byCol.forEach((nodes, ci) => { const step = (H - PADT - 10) / Math.max(nodes.length, 1); nodes.forEach((n, i) => { pos[n.id] = [PADL + ci * COLW, PADT + step * (i + 0.5)]; }); }); model.titles.forEach((t, ci) => { if (!t) return; const tx = svgEl("text", { x: PADL + ci * COLW - 8, y: 22, class: "flow-coltitle" }); tx.textContent = t; // SVG text takes a child, not a title attribute. const help = glossary(t); if (help) { const tt = svgEl("title"); tt.textContent = help; tx.appendChild(tt); } svg.appendChild(tx); }); // Highlight = the selected node, everything downstream of it (its whole // subtree), and its upstream chain to the root. Selecting the root // therefore lights the entire graph. let rel = null; if (selectedId) { rel = new Set([selectedId]); let grew = true; while (grew) { // downstream closure grew = false; model.links.forEach((l) => { if (rel.has(l.a) && !rel.has(l.b)) { rel.add(l.b); grew = true; } }); } grew = true; while (grew) { // upstream chain (stays inside the subtree's ancestry) grew = false; model.links.forEach((l) => { if (rel.has(l.b) && !rel.has(l.a)) { rel.add(l.a); grew = true; } }); } } const colOf = {}; model.nodes.forEach((n) => { colOf[n.id] = n.col; }); model.links.forEach((l) => { const pa = pos[l.a], pb = pos[l.b]; if (!pa || !pb) return; const [p1, p2] = pa[0] <= pb[0] ? [pa, pb] : [pb, pa]; const x1 = p1[0] + 9, x2 = p2[0] - 9; const hot = rel && rel.has(l.a) && rel.has(l.b); const span = Math.abs(colOf[l.a] - colOf[l.b]); let d; if (span > 1) { // Multi-column edge: bow around the middle column instead of through it. const mx = (x1 + x2) / 2; const my = (p1[1] + p2[1]) / 2 + (p2[1] <= p1[1] ? -1 : 1) * 50; d = `M${x1},${p1[1]} C${mx},${my} ${mx},${my} ${x2},${p2[1]}`; } else { // Control points near the endpoints so a fan splays apart immediately // instead of travelling as one flat bundle through labels mid-column. const dx = Math.min(56, Math.max(24, (x2 - x1) * 0.3)); d = `M${x1},${p1[1]} C${x1 + dx},${p1[1]} ${x2 - dx},${p2[1]} ${x2},${p2[1]}`; } // Edges carry no words: the line style says provided vs derived (see the // legend), and the link's name appears in the side panel on click. const path = svgEl("path", { d, class: "flow-edge" + (l.derived ? " flow-edge-derived" : "") + (hot ? " flow-edge-hot" : "") }); if (rel && !hot) path.setAttribute("opacity", "0.15"); svg.appendChild(path); }); byCol.forEach((nodes, ci) => { const dense = nodes.length; const mono = ci === cols - 1 && model.kind !== "medication"; nodes.forEach((n) => { const [x, y] = pos[n.id]; const grp = svgEl("g", { transform: `translate(${x},${y})`, class: "flow-node" }); if (rel && !rel.has(n.id)) grp.setAttribute("opacity", "0.22"); if (n.more) { // A stub is an action, not a code, so it is drawn as one: a pill in // the column's own lane. It used to be underlined italic text ending // in an em dash, which read as a broken label rather than a control. const hidden = (String(n.label).match(/\d+/) || [""])[0]; const text = onExpand ? `Show all ${hidden} more` : n.label; if (onExpand) { const w = text.length * 6.2 + 22; grp.appendChild(svgEl("rect", { x: 6, y: -10, width: w, height: 21, rx: 10.5, class: "flow-more-pill" })); } const t = svgEl("text", { x: onExpand ? 17 : 12, y: 4, class: "flow-more" + (onExpand ? " flow-more-btn" : "") }); t.textContent = text; grp.appendChild(t); if (onExpand) { const tt = svgEl("title"); tt.textContent = "Expand the graph to show every code"; grp.appendChild(tt); grp.onclick = (e) => { e.stopPropagation(); onExpand(); }; } svg.appendChild(grp); return; } const color = vocabColor(n.vocab); const hub = ci < cols - 1; const ringed = n.id === selectedId || (!selectedId && n.current); grp.appendChild(svgEl("circle", { r: hub ? 6 : 4.5, fill: mixHex(color, 0.72), stroke: ringed ? "#124b6b" : color, "stroke-width": ringed ? 2.5 : 1.4 })); // Hub labels sit above/below the dot so they never lie on the edge // lines fanning out to the right; leaf labels sit beside the dot. const lab = svgEl("text", { x: hub ? -6 : 12, y: hub ? -12 : 4, class: mono ? "flow-label-code" : "flow-label-main", "font-size": dense > 30 ? 10 : dense > 18 ? 11 : 12 }); lab.textContent = truncate(n.label, hub ? 30 : 42); grp.appendChild(lab); if (n.sub && dense <= 16) { const st = svgEl("text", { x: hub ? -6 : 12, y: hub ? 22 : 18, class: "flow-label-sub" }); st.textContent = truncate(n.sub, hub ? 34 : 46); grp.appendChild(st); } const tt = svgEl("title"); tt.textContent = `${n.label}${n.sub ? " — " + n.sub : ""} (${n.vocab})`; grp.appendChild(tt); grp.onclick = (e) => { e.stopPropagation(); onSelect(n.id); }; svg.appendChild(grp); }); }); svg.onclick = () => { if (selectedId) onSelect(selectedId); }; // background click clears return svg; } // The side explanation panel: what the clicked thing is, where it sits, what // it maps to, and how much to trust the mapping. function renderFlowSelection(box, g, model, node) { box.innerHTML = ""; const aside = box.parentElement; if (aside) aside.classList.toggle("has-selection", !!(node && !node.more)); if (!node || node.more) return; box.appendChild(el("h3", "sel-title", node.label)); const fields = el("dl", "graph-selection-fields"); const parents = model.links.filter((l) => l.b === node.id && !l.map) .map((l) => (model.nodes.find((m) => m.id === l.a) || {}).label).filter(Boolean); graphField(fields, "Vocabulary", systemLabel((node.nav && node.nav.code_type) || node.vocab)); // An RxNorm ingredient node is named, not numbered, so without this its code // never appears anywhere in the UI even though the graph is built on it. // Nodes that already show their code as the title (ICD, CPT, LOINC) skip it. const nodeCode = node.code || (node.nav && node.nav.code); if (nodeCode && String(nodeCode) !== node.label) { graphField(fields, node.code_label || "Code", String(nodeCode)); } graphField(fields, "Description", node.sub || "No description available."); graphField(fields, "Grouped under", parents.join(", ")); const mappings = model.links.filter((l) => l.map && (l.a === node.id || l.b === node.id)) .map((l) => { const other = model.nodes.find((m) => m.id === (l.a === node.id ? l.b : l.a)); return other ? `${l.label || "maps to"} ${other.label}${other.sub ? " — " + truncate(other.sub, 54) : ""}` : null; }).filter(Boolean); if (mappings.length) graphField(fields, "Mappings", mappings.join("; ")); if (node.current && g.phecodes && g.phecodes.provenance) { graphField(fields, "Phecode link", g.phecodes.provenance); } box.appendChild(fields); } // -- about panel ----------------------------------------------------------- // Four sections: what ENCODE is, what it holds, the two modes, and the two // things a reviewer does with results. Not a manual. Anything a control // states on its own face is left to the control. function openHelp() { const body = $("#drawer-body"); body.innerHTML = ""; body.appendChild(el("h2", null, "About ENCODE")); body.appendChild(el("p", "summary", "ENCODE matches clinical codes and CIPHER phenotype definitions to the meaning " + "of a query, so a description of a patient group finds the codes that express " + "it even when the wording differs. Results are candidates for review before use.")); // The corpus section arrives on its own: it is one small request, and the // rest of the panel must not wait on it. A deployment carrying no coverage // report simply shows the panel without the section. const slot = el("div"); body.appendChild(slot); corpusStats().then((data) => { if (!data || !slot.isConnected) return; slot.appendChild(corpusSection(data)); }); const agentOn = state.plan !== undefined && !$("#mode-switch").classList.contains("hidden"); body.appendChild(el("h4", null, agentOn ? "Search and Agent" : "Search")); body.appendChild(el("p", "summary", "Search retrieves codes for one concept within the selected code set. " + "Search by code in the sidebar switches the bar to exact code lookup." + (agentOn ? " Agent takes a written description of a cohort and separates it into the " + "individual criteria it contains, each available as its own search." : ""))); if (agentOn) { body.appendChild(el("p", "gen-note", "Agent sends your description to a language model to interpret it, and nothing else. " + "Do not enter patient identifiers or protected health information.")); } body.appendChild(el("h4", null, "Grading")); body.appendChild(el("p", "summary", "Grading records what a reviewer thinks of the ranking. Every row on the pages you " + "opened is submitted, together with your name and the ranking model that produced " + "the results.")); const grades = el("ul", "help-list"); [ "Relevant: an exact match for the query.", "Related: a nearby concept, not an exact match.", "Not relevant: reviewed and judged incorrect.", "Left blank: submitted as unsure.", ].forEach((t) => grades.appendChild(el("li", null, t))); body.appendChild(grades); body.appendChild(el("p", "summary", "Each submission is stored on its own. Grading the same query again adds a submission " + "instead of replacing the earlier one.")); body.appendChild(el("h4", null, "Collecting and exporting")); const exports = el("ul", "help-list"); [ "Select adds a row to your Collection, which holds codes from any search or category " + "and stays in this browser between visits.", "Collection exports everything you selected as one CSV.", "Export CSV in the sidebar takes the current result set instead.", ].forEach((t) => exports.appendChild(el("li", null, t))); body.appendChild(exports); showDrawer(); } // -- corpus section -------------------------------------------------------- // Index sizes and mapping coverage, read from /api/corpus. That endpoint // serves the standing coverage report, the single generator of these numbers, // so nothing is computed or rounded up here beyond display precision. Fetched // once per page: the report describes the release the server loaded, which // cannot change while the page is open. let corpusRequest = null; function corpusStats() { if (!corpusRequest) corpusRequest = apiGet("/api/corpus").catch(() => null); return corpusRequest; } const bigNum = (n) => (n == null ? "" : Number(n).toLocaleString("en-US")); const pctText = (p) => (p == null ? "" : `${Number(p).toFixed(1)}%`); function corpusSection(d) { const box = el("div"); box.appendChild(el("h4", null, "What ENCODE holds")); const dia = d.diagnosis || {}, pro = d.procedure || {}; const lab = d.lab || {}, med = d.medication || {}; const rows = [ ["ICD-10 diagnosis", "Phecode", (dia.icd10 || {}).distinct_codes, (dia.icd10 || {}).coverage_validated_pct, (dia.icd10 || {}).coverage_incl_derived_pct], ["ICD-9 diagnosis", "Phecode", (dia.icd9 || {}).distinct_codes, (dia.icd9 || {}).coverage_validated_pct, (dia.icd9 || {}).coverage_incl_derived_pct], ["CPT procedure", "CMS RBCS", pro.distinct_codes, pro.coverage_validated_pct, pro.coverage_incl_derived_pct], // Laboratory and NDC coverage are reported as a single figure, since the // standing report does not separate the two provenances for them. ["Laboratory test", "LOINC", lab.distinct_sids, null, lab.coverage_sids_pct], ["Medication product", "RxNorm ingredient", med.distinct_med_sids, med.med_sid_validated_pct, med.med_sid_coverage_pct], ["Drug package (NDC)", "RxNorm ingredient", med.distinct_ndc_codes, null, med.ndc_coverage_pct], ]; const total = rows.reduce((sum, r) => sum + (r[2] || 0), 0); const ph = d.phenotype || {}; box.appendChild(el("p", "summary", `${bigNum(total)} distinct codes across six code sets` + (ph.phenotypes ? `, and ${bigNum(ph.phenotypes)} CIPHER phenotype definitions` : "") + ". Each code set maps to a reference vocabulary, and these mappings are what " + "the code graph and related codes are built from. Coverage is the share of " + "codes with such a mapping.")); const table = el("table", "corpus-table"); const head = el("tr"); ["Code set", "Mapped to", "Codes", "Provided", "With derived"] .forEach((h) => head.appendChild(el("th", null, h))); const thead = el("thead"); thead.appendChild(head); table.appendChild(thead); const tb = el("tbody"); rows.forEach(([set, vocab, n, validated, all]) => { const tr = el("tr"); tr.appendChild(el("td", null, set)); tr.appendChild(el("td", null, vocab)); tr.appendChild(el("td", "corpus-num", bigNum(n))); tr.appendChild(el("td", "corpus-num", pctText(validated))); tr.appendChild(el("td", "corpus-num", pctText(all))); tb.appendChild(tr); }); table.appendChild(tb); const wrap = el("div", "table-wrap"); wrap.appendChild(table); box.appendChild(wrap); box.appendChild(el("p", "gen-note", "Provided mappings come from published sources. Derived mappings are ENCODE's " + "own inferences and are marked wherever they appear.")); const held = diagnosisHeldOut(d); if (held) box.appendChild(el("p", "gen-note", held)); return box; } // Diagnosis coverage is counted over the codes a phecode can describe, which // is fewer than the codes ENCODE indexes: the Codes column and the coverage // columns have different denominators, and the difference is large enough // (external-cause codes alone are a tenth of ICD-10) that leaving it implicit // reads as a mapping failure. Named here rather than in the table because it // qualifies two rows, not a cell. Absent on a deployment whose report predates // the per-version exclusion counts. function diagnosisHeldOut(d) { const dia = d.diagnosis || {}; const parts = []; let external = 0, admin = 0; ["icd10", "icd9"].forEach((v) => { const ex = (dia[v] || {}).excluded_by_design; if (!ex) return; external += ex.external_cause || 0; admin += ex.admin_and_status || 0; }); if (external) parts.push(`${bigNum(external)} external-cause codes`); if (admin) parts.push(`${bigNum(admin)} administrative and status codes`); const proc = (dia.icd9_procedure || {}).distinct_codes; if (proc) parts.push(`${bigNum(proc)} ICD-9 procedure codes`); const ranges = (dia.chapter_ranges || {}).distinct_codes; if (ranges) parts.push(`${bigNum(ranges)} chapter headings`); if (!parts.length) return null; return "Diagnosis coverage is measured over the codes a phecode can describe. " + `The ${parts.join(", ")} are left out of the percentage and remain fully searchable.`; } // -- query planner --------------------------------------------------------- // One natural-language cohort description in, a list of search criteria out. // This is a planner, not a chatbot: no memory, no dialogue, no clinical // answers. Every criterion becomes a chip that opens the ordinary search UI, // and nothing is searched until the user clicks one. // // The plan lives in #plan-bar, which sits outside #results on purpose — // clearResults() and applyCategory() wipe #results on every chip click, and // the whole point of the plan is that it survives while the user works // through it. const planKey = (c) => `${c.category}:${c.concept.toLowerCase()}`; // -- agent model registry -------------------------------------------------- // The deployment ships one model (a server-side key). Users may add their own; // those live in this browser only and ride along with each plan request, so // the server never holds someone else's credentials. const PLANNER_MODELS_KEY = "encode_planner_models_v1"; const PLANNER_PICK_KEY = "encode_planner_pick_v1"; const BUILTIN = "__builtin__"; const ADD_NEW = "__add__"; const FORMATS = { openai: { base: "https://api.openai.com/v1", model: "gpt-4o-mini" }, anthropic: { base: "https://api.anthropic.com", model: "claude-opus-5" }, }; // -- streamed reasoning ---------------------------------------------------- // One SSE line reader for both sources: our own /api/plan/stream, and a // provider called straight from the browser. Frames differ, the framing does // not. async function readSSE(response, onData) { const reader = response.body.getReader(); const dec = new TextDecoder(); let buf = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let cut; while ((cut = buf.indexOf("\n\n")) >= 0) { const frame = buf.slice(0, cut); buf = buf.slice(cut + 2); frame.split("\n").forEach((line) => { if (line.startsWith("data: ")) onData(line.slice(6).trim()); }); } } } // The raw stream arrives at roughly 650 characters a second, far past reading // speed. Collapsed, this reports only what is actually legible at that rate: // how long the model has been thinking and how many reasoning tokens it has // spent. The transcript rolls underneath for anyone who opens it. Nothing is // generated or reordered; it is the model's own text. let cotTimer = null; let cotStart = 0; function cotLabel(done) { const secs = Math.round((performance.now() - cotStart) / 1000); const tokens = state.cotTokens ? ` · ${state.cotTokens.toLocaleString()} reasoning tokens` : ""; return `${done ? "Thought for" : "Thinking…"} ${secs}s${tokens}`; } function cotReset() { state.cot = ""; state.cotTokens = null; cotStart = performance.now(); if (cotTimer) { clearInterval(cotTimer); cotTimer = null; } const box = $("#cot"); box.classList.remove("hidden"); box.open = false; $("#cot-text").textContent = ""; $("#cot-summary").textContent = cotLabel(false); // The token count only lands with the provider's final usage frame, so the // label is re-rendered on a tick rather than only when text arrives. cotTimer = setInterval(() => { $("#cot-summary").textContent = cotLabel(false); }, 250); } function cotAppend(text) { // One delta is one reasoning token: measured 2,180 deltas against 2,180 // reported reasoning_tokens on deepseek-v4-flash, exactly 1:1. So this is a // real count, not an estimate, and the provider's final usage frame // overwrites it with the authoritative number anyway. state.cotTokens = (state.cotTokens || 0) + 1; state.cot += text; const pre = $("#cot-text"); pre.textContent = state.cot; pre.scrollTop = pre.scrollHeight; // roll, for anyone watching it open } // Once the answer is in, the counter freezes and the transcript stays behind it. function cotSettle() { if (cotTimer) { clearInterval(cotTimer); cotTimer = null; } if (!state.cot) { $("#cot").classList.add("hidden"); return; } $("#cot-summary").textContent = cotLabel(true); $("#cot").open = false; } // Stream the user's own model from the browser, surfacing reasoning as it // arrives. OpenAI-compatible providers send it as `reasoning_content`; // Anthropic sends none unless extended thinking is enabled, so that panel // simply stays empty rather than pretending. async function callModelDirectStream(m, system, q, onThinking) { const openai = m.kind === "openai"; const url = openai ? `${m.base_url}/chat/completions` : `${m.base_url}/v1/messages`; const headers = openai ? { "Content-Type": "application/json", "Authorization": `Bearer ${m.api_key}` } : { "Content-Type": "application/json", "x-api-key": m.api_key, "anthropic-version": "2023-06-01", "anthropic-dangerous-direct-browser-access": "true" }; const body = openai ? { model: m.model, stream: true, temperature: 0, max_tokens: 4000, stream_options: { include_usage: true }, response_format: { type: "json_object" }, messages: [{ role: "system", content: system }, { role: "user", content: q }] } : { model: m.model, stream: true, max_tokens: 4000, system, messages: [{ role: "user", content: q }] }; let r; try { r = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); } catch (_) { throw new Error("your browser could not reach this model. Check the base URL, " + "or whether the provider allows calls from a browser"); } if (!r.ok) { let detail = ""; try { const j = await r.json(); detail = (j.error && j.error.message) || j.message || ""; } catch (_) { /* non-JSON error body */ } throw new Error(`the model returned HTTP ${r.status}${detail ? ": " + detail.slice(0, 200) : ""}`); } let text = ""; await readSSE(r, (chunk) => { if (chunk === "[DONE]") return; let d; try { d = JSON.parse(chunk); } catch (_) { return; } if (openai) { if (d.usage) { state.cotTokens = ((d.usage.completion_tokens_details || {}).reasoning_tokens) || null; } const delta = (((d.choices || [])[0] || {}).delta) || {}; const think = delta.reasoning_content || delta.reasoning; if (think) onThinking(think); if (delta.content) text += delta.content; } else { if (d.type === "content_block_delta" && d.delta) { if (d.delta.type === "thinking_delta" && d.delta.thinking) onThinking(d.delta.thinking); if (d.delta.type === "text_delta" && d.delta.text) text += d.delta.text; } } }); if (!text) throw new Error("the model returned an empty response"); return text; } // Non-streaming fallback, kept for providers whose SSE we cannot read. async function callModelDirect(m, system, q) { const openai = m.kind === "openai"; const url = openai ? `${m.base_url}/chat/completions` : `${m.base_url}/v1/messages`; const headers = openai ? { "Content-Type": "application/json", "Authorization": `Bearer ${m.api_key}` } : { "Content-Type": "application/json", "x-api-key": m.api_key, "anthropic-version": "2023-06-01", // Anthropic requires this opt-in before it will answer a browser. "anthropic-dangerous-direct-browser-access": "true" }; const body = openai ? { model: m.model, temperature: 0, stream: false, max_tokens: 4000, response_format: { type: "json_object" }, messages: [{ role: "system", content: system }, { role: "user", content: q }] } : { model: m.model, max_tokens: 4000, system, messages: [{ role: "user", content: q }] }; let r; try { r = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); } catch (_) { // A CORS refusal and a dead host are indistinguishable from here. throw new Error("your browser could not reach this model. Check the base URL, " + "or whether the provider allows calls from a browser"); } if (!r.ok) { let detail = ""; try { const j = await r.json(); detail = (j.error && j.error.message) || j.message || ""; } catch (_) { /* non-JSON error body */ } throw new Error(`the model returned HTTP ${r.status}${detail ? ": " + detail.slice(0, 200) : ""}`); } const j = await r.json(); const text = openai ? (((j.choices || [])[0] || {}).message || {}).content : (j.content || []).filter((b) => b.type === "text").map((b) => b.text).join(""); if (!text) throw new Error("the model returned an empty response"); return text; } let plannerModels = (() => { try { return JSON.parse(localStorage.getItem(PLANNER_MODELS_KEY)) || []; } catch (_) { return []; } })(); const savePlannerModels = () => localStorage.setItem(PLANNER_MODELS_KEY, JSON.stringify(plannerModels)); async function loadPlanner() { let status = { available: false }; try { status = await apiGet("/api/plan/status"); } catch (_) { /* older backend */ } // The deployment can ship more than one model. Older backends send a single // label instead of a list, so one is made from it. state.plannerBuiltins = status.models && status.models.length ? status.models : (status.available ? [{ id: BUILTIN, label: status.model || "Built-in model" }] : []); state.plannerPrompt = status.prompt || ""; const saved = localStorage.getItem(PLANNER_PICK_KEY); const known = (id) => id === ADD_NEW || state.plannerBuiltins.some((m) => builtinValue(m.id) === id) || plannerModels.some((m) => m.id === id); // A pick saved in this browser can name a model the deployment no longer // offers, so it is only honoured if it still exists. state.plannerPick = (saved && known(saved) ? saved : null) || builtinValue((state.plannerBuiltins[0] || {}).id) || (plannerModels[0] || {}).id || BUILTIN; // Offer the mode when the deployment has a model OR the user brought one; // otherwise it would appear and fail on click. const usable = state.plannerBuiltins.length > 0 || plannerModels.length > 0; if (usable) $("#mode-switch").classList.remove("hidden"); renderPlannerPicker(); return usable; } const pickedPlannerModel = () => plannerModels.find((m) => m.id === state.plannerPick) || null; // A deployment model is picked as "builtin:<id>"; the id alone goes to the // server. BUILTIN on its own is the older single-model spelling. const builtinValue = (id) => (id ? (id === BUILTIN ? BUILTIN : `${BUILTIN}${id}`) : null); const pickedBuiltinId = () => { const pick = state.plannerPick || ""; if (pick === BUILTIN) return null; // server default return pick.startsWith(BUILTIN) ? pick.slice(BUILTIN.length) : null; }; function renderPlannerPicker() { const sel = $("#plan-model"); if (!sel) return; sel.innerHTML = ""; const add = (value, label) => { const o = el("option", null, label); o.value = value; o.selected = value === state.plannerPick; sel.appendChild(o); }; (state.plannerBuiltins || []).forEach((m) => add(builtinValue(m.id), m.label)); // Models are named after the model itself, so the same name can appear // twice (a user's own gpt-4o-mini alongside a colleague's, or a custom entry // matching the deployment's). Qualify only the ones that actually collide. const seen = {}; [...(state.plannerBuiltins || []).map((m) => m.label), ...plannerModels.map((m) => m.label)] .forEach((n) => { if (n) seen[n] = (seen[n] || 0) + 1; }); plannerModels.forEach((m) => { let host = ""; if (seen[m.label] > 1) { try { host = ` · ${new URL(m.base_url).hostname}`; } catch (_) { /* keep bare */ } } add(m.id, m.label + host); }); if (!(state.plannerBuiltins || []).length && !plannerModels.length) { const o = el("option", null, "No model yet. Add one"); o.value = ""; o.disabled = true; o.selected = true; sel.appendChild(o); } add(ADD_NEW, "+ Add model…"); $("#plan-model-remove").classList.toggle("hidden", !pickedPlannerModel()); } // -- add-a-model dialog ---------------------------------------------------- let modalReturnFocus = null; function openModelModal() { applyFormatDefaults(); $("#mm-error").textContent = ""; $("#mm-key").value = ""; modalReturnFocus = document.activeElement; $("#model-modal").classList.remove("hidden"); $("#mm-model").focus(); } function closeModelModal() { $("#model-modal").classList.add("hidden"); $("#mm-key").value = ""; // don't leave a key sitting in the DOM renderPlannerPicker(); // undo the "Add model…" selection // Send focus back where it came from, or a keyboard user is dumped at the // top of the document with no idea what happened. if (modalReturnFocus && modalReturnFocus.isConnected) modalReturnFocus.focus(); else $("#plan-model").focus(); modalReturnFocus = null; } // Keep Tab inside the dialog while it is open; aria-modal alone does not do // this, and tabbing out to the page behind a modal is disorienting. function trapModalTab(e) { if (e.key !== "Tab") return; const modal = [...document.querySelectorAll(".modal")].find((m) => !m.classList.contains("hidden")); if (!modal) return; const items = [...modal.querySelectorAll("select, input, button")] .filter((n) => !n.disabled && n.offsetParent !== null); if (!items.length) return; const first = items[0], last = items[items.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } // Prefill from the chosen format so the common case is one field of typing. function applyFormatDefaults() { const f = FORMATS[$("#mm-kind").value] || FORMATS.openai; $("#mm-base").value = f.base; $("#mm-base").placeholder = f.base; $("#mm-model").placeholder = f.model; } function saveModelFromModal() { const kind = $("#mm-kind").value; const base_url = $("#mm-base").value.trim().replace(/\/+$/, ""); const model = $("#mm-model").value.trim(); const api_key = $("#mm-key").value.trim(); const label = model; const fail = (m) => { $("#mm-error").textContent = m; return false; }; if (!/^https:\/\//i.test(base_url)) return fail("Base URL must start with https://"); if (!model) return fail("Model name is required."); if (!api_key) return fail("API key is required."); const id = `m${Date.now().toString(36)}`; plannerModels.push({ id, kind, base_url, model, api_key, label }); savePlannerModels(); state.plannerPick = id; localStorage.setItem(PLANNER_PICK_KEY, id); $("#model-modal").classList.add("hidden"); $("#mm-key").value = ""; $("#mode-switch").classList.remove("hidden"); // a model exists now renderPlannerPicker(); return true; } function removePickedModel() { const m = pickedPlannerModel(); if (!m || !confirm(`Remove "${m.label}" and its stored key from this browser?`)) return; plannerModels = plannerModels.filter((x) => x.id !== m.id); savePlannerModels(); state.plannerPick = builtinValue((state.plannerBuiltins[0] || {}).id) || (plannerModels[0] || {}).id || BUILTIN; localStorage.setItem(PLANNER_PICK_KEY, state.plannerPick); renderPlannerPicker(); } async function setMode(mode) { // Each mode keeps its own screen. Leaving Search and coming back should find // the last code search still there rather than a blank page, and the same // for a plan's results. if (state.mode !== mode) { if (!(await confirmLeaveGrades())) return; snapshotScreen(); resetSearchSurface(); } const restoring = state.mode !== mode; state.mode = mode; const planning = mode === "plan"; // Only the "what to search" blocks swap. Ranking Model, Number of Results // and Code Systems stay put in both modes: they govern retrieval itself, so // they apply to a chip search exactly as they do to a typed one. $("#mode-search").classList.toggle("on", !planning); $("#mode-plan").classList.toggle("on", planning); $("#mode-search").setAttribute("aria-selected", String(!planning)); $("#mode-plan").setAttribute("aria-selected", String(planning)); $("#search-side").classList.toggle("hidden", planning); $("#plan-side").classList.toggle("hidden", !planning); $("#search-controls").classList.toggle("hidden", planning); $("#plan-controls").classList.toggle("hidden", !planning); renderPlanBar(); // shows the plan in Agent, takes it away in Search updateSystemFilters(); updateCodeLookup(); if (planning) { closeDetailPage(); $("#empty").innerHTML = ""; $("#plan-query").focus(); } else { renderEmpty(); } if (restoring) { // Coming back to Search lands on the category that was open when it was // left, not whatever the picker happens to say. if (!planning && state.lastCategory && state.lastCategory !== state.category) { state.category = state.lastCategory; $("#category").value = state.category; } applyCategoryChrome(); restoreScreen(); } syncCaptions(); } async function runPlan() { // The button is disabled during a plan, but Enter in the textarea calls // this directly; a second plan racing the first would double-write state. if ($("#plan-btn").disabled) return; let q = $("#plan-query").value.trim(); if (!q) { // Same affordance as the search box: an empty submit runs the placeholder // example ("e.g. …") so the control is self-demonstrating. q = ($("#plan-query").placeholder || "").replace(/^e\.g\.\s*/i, "").trim(); if (!q) return; $("#plan-query").value = q; } // A new description supersedes the old plan, so anything still on screen // from the previous one goes now rather than lingering through the wait. resetSearchSurface(); cotReset(); // Planning a long description takes ~10-20s (the model reasons before // answering). The streamed reasoning is the progress indicator, so the // status line stays empty until there is a result (or an error) to report. const t0 = performance.now(); $("#plan-status").textContent = ""; $("#plan-announce").textContent = ""; $("#plan-btn").disabled = true; let data; try { const picked = pickedPlannerModel(); if (picked) { // The user's own model: their browser calls it and streams the // reasoning; only the finished reply is posted here for validation, so // the key never touches our server. const text = await callModelDirectStream(picked, state.plannerPrompt, q, cotAppend); const r = await fetch("/api/plan/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ q, text, label: picked.label }), }); data = await r.json(); if (!r.ok) throw new Error(data.detail || r.statusText); } else { // Built-in model: the server streams its reasoning through to us. const r = await fetch("/api/plan/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ q, builtin: pickedBuiltinId() }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || r.statusText); } let failed = null; await readSSE(r, (chunk) => { let d; try { d = JSON.parse(chunk); } catch (_) { return; } if (d.type === "thinking") cotAppend(d.text); else if (d.type === "usage") state.cotTokens = (d.usage || {}).reasoning_tokens || null; else if (d.type === "plan") data = d.plan; else if (d.type === "error") failed = d.message; }); if (failed) throw new Error(failed); if (!data) throw new Error("the model did not return a plan"); } } catch (e) { $("#plan-btn").disabled = false; cotSettle(); const msg = `Could not plan that request: ${e.message}. You can still use Search directly.`; $("#plan-status").textContent = msg; $("#plan-announce").textContent = msg; return; } $("#plan-btn").disabled = false; cotSettle(); state.plan = data; state.planToken += 1; state.planEditing = null; $("#plan-announce").textContent = data.criteria.length ? `${data.criteria.length} search criteria ready. Choose one to search.` : (data.note || "No search criteria found."); syncCaptions(); const secs = ((performance.now() - t0) / 1000).toFixed(1); $("#plan-status").textContent = data.criteria.length ? `${data.criteria.length} search criteri${data.criteria.length === 1 ? "on" : "a"} (${secs}s)` : ""; renderPlanBar(); prefetchPlan(state.planToken); // warm every chip's search while the user reads } // A chip is two controls in one shell: the label searches, the pencil edits. // Nested <button>s are invalid, so the shell is a <span>. function planChip(c, i) { const shell = el("span", "plan-chip"); // Green marks the chip being looked at, not a worklist tick: with a handful // of criteria worked through in order, "already searched" earned little and // competed with the signal that matters. Keyed on index, so exactly one chip // can be current even if an edit makes two concepts identical. const current = state.planCurrent === i; shell.classList.toggle("current", current); const go = el("button", "plan-chip-go"); go.type = "button"; go.appendChild(el("span", null, c.concept)); go.appendChild(el("span", "mention-cat", CATS[c.category].label)); // The quote is the span of the user's own words this came from; the server // rejected any criterion that could not point at one. go.title = [`Search ${CATS[c.category].label.toLowerCase()} for "${c.concept}"`, c.quote ? `From your words: "${c.quote}"` : null, c.rationale || null, current ? "Showing these results now." : null] .filter(Boolean).join("\n"); // "Searched" is signalled visually by a green fill; colour alone is not a // signal, and `title` is not reliably announced, so it goes in the name. go.setAttribute("aria-label", `Search ${CATS[c.category].label.toLowerCase()} for ${c.concept}` + (current ? ", showing these results" : "")); go.onclick = () => runCriterion(i); shell.appendChild(go); const edit = el("button", "plan-chip-edit", "✎"); edit.type = "button"; edit.title = "Edit this search term"; edit.setAttribute("aria-label", `Edit search term "${c.concept}"`); edit.onclick = (e) => { e.stopPropagation(); state.planEditing = i; renderPlanBar(); }; shell.appendChild(edit); return shell; } // Editing is state-driven (state.planEditing) rather than a DOM mutation, so // a re-render from anywhere else cannot destroy a half-typed edit. function planChipEditor(c, i) { const shell = el("span", "plan-chip editing"); const input = el("input", "plan-chip-input"); input.type = "text"; input.value = c.concept; input.setAttribute("aria-label", "Search term"); input.size = Math.max(c.concept.length + 1, 8); const commit = () => { const next = input.value.trim(); // An empty term is a cancel, not a way to blank a chip. if (!next || next === c.concept) { cancel(); return; } c.concept = next; c.edited = true; state.planEditing = null; runCriterion(i); // confirm re-runs the search }; const cancel = () => { state.planEditing = null; renderPlanBar(); }; input.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { e.preventDefault(); cancel(); } e.stopPropagation(); // don't trip the global Escape handler }; input.oninput = () => { input.size = Math.max(input.value.length + 1, 8); }; shell.appendChild(input); const ok = el("button", "plan-chip-ok", "✓"); ok.type = "button"; ok.title = "Search this term"; ok.setAttribute("aria-label", "Confirm and search"); ok.onclick = commit; shell.appendChild(ok); const no = el("button", "plan-chip-cancel", "✕"); no.type = "button"; no.title = "Cancel"; no.setAttribute("aria-label", "Cancel edit"); no.onclick = cancel; shell.appendChild(no); // Focus after the node is in the document. setTimeout(() => { input.focus(); input.select(); }, 0); return shell; } // Run one criterion: mark it searched, refresh the bar, drive the ordinary // search UI. Shared by chip clicks and by confirming an edit. async function runCriterion(i) { const c = state.plan && state.plan.criteria[i]; if (!c) return; if (!(await confirmLeaveGrades())) return; state.planEditing = null; state.planCurrent = i; searchRelated(c.category, c.concept); renderPlanBar(); } function renderPlanBar() { const bar = $("#plan-bar"); const p = state.plan; bar.innerHTML = ""; // The plan belongs to Agent. Search is the plain tool, and a row of chips // left above its results reads as criteria the search is applying, which it // is not. The plan is kept in state either way, so it comes back intact on // the way into Agent rather than having to be re-planned. if (!p || state.mode !== "plan") { bar.classList.add("hidden"); return; } bar.classList.remove("hidden"); const head = el("div", "plan-head"); // The full description is still in the box above, so the echo is only an // anchor. Prose-length requests would otherwise repeat four lines in bold // and push the chips down. const shown = p.query.length > 120 ? p.query.slice(0, 117).trimEnd() + "…" : p.query; const qEl = el("span", "plan-head-q", `"${shown}"`); qEl.title = p.query; head.appendChild(qEl); const clear = el("button", "link", "Clear plan"); clear.type = "button"; clear.onclick = async () => { if (!(await confirmLeaveGrades())) return; state.plan = null; state.planEditing = null; state.planCurrent = null; state.planToken += 1; // stop any prefetch in flight $("#plan-status").textContent = ""; $("#cot").classList.add("hidden"); state.cot = ""; renderPlanBar(); resetSearchSurface(); // the results came from this plan; they go with it }; head.appendChild(clear); bar.appendChild(head); if (p.note) bar.appendChild(el("p", "plan-note", p.note)); if (p.criteria.length) { const row = el("div", "chips plan-chips"); row.appendChild(el("span", "chips-label", "Search")); p.criteria.forEach((c, i) => row.appendChild( state.planEditing === i ? planChipEditor(c, i) : planChip(c, i))); bar.appendChild(row); } // Constraints stay in the payload and in the chip tooltips, but are no longer // drawn: ENCODE never filters on them, so a row of things it did not do added // noise to every plan. `p.constraints` remains available for anything that // later hands the cohort logic to SAGE. } // -- validation & feedback mode ------------------------------------------- // Always on, and no longer offered as a control: grading is how the tool // learns which rankings hold up, so every reader sees the grade column. A // stored "0" from the opt-in era is ignored rather than honoured, because the // checkbox that could undo it is hidden now; it stays in the DOM anchoring // this state and the wiring below. One class on <body> drives the CSS that // shows the grade column, the per-row controls, and the submit box together. const REVIEW_KEY = "encode.review_mode"; function applyReviewMode(on) { document.body.classList.toggle("review-mode", on); const box = $("#review-mode"); if (box) box.checked = on; } applyReviewMode(true); // Unsubmitted grades die with the results they grade, so anything that // replaces those results asks first. The dialog offers submitting on the way // out, so it settles asynchronously and every caller awaits the verdict. const unsavedGrades = () => state.gradesDirty && Object.values(state.annotations).some((a) => a.grade); let leaveVerdict = null; function confirmLeaveGrades() { if (!unsavedGrades()) return Promise.resolve(true); if (leaveVerdict) return Promise.resolve(false); // dialog already open $("#leave-modal").classList.remove("hidden"); // Deferred, or the Enter that triggered the guarded search lands on the // freshly focused button and answers the dialog unseen. setTimeout(() => { if (leaveVerdict) $("#lm-submit").focus(); }, 0); return new Promise((resolve) => { leaveVerdict = resolve; }); } function settleLeave(leave) { $("#leave-modal").classList.add("hidden"); const resolve = leaveVerdict; leaveVerdict = null; if (resolve) resolve(leave); } $("#lm-stay").onclick = () => settleLeave(false); $("#lm-leave").onclick = () => { state.gradesDirty = false; settleLeave(true); }; $("#lm-submit").onclick = async () => { try { await submitAnnotations(); } catch (_) { /* saving failed, so stay */ } settleLeave(!unsavedGrades()); }; $("#leave-modal").onclick = (e) => { if (e.target.id === "leave-modal") settleLeave(false); }; // Closing the tab still goes through the browser's own prompt, the one place // a page cannot draw its own dialog. window.addEventListener("beforeunload", (e) => { if (unsavedGrades()) { e.preventDefault(); e.returnValue = ""; } }); // The count boxes take any number and one clamp each: the retrieval count // mirrors the server cap, the page size is client-only. const K_KEY = "encode.k"; const K_MAX = 2000; const savedK = Math.round(Number(localStorage.getItem(K_KEY))); if (savedK >= 1) $("#k").value = Math.min(savedK, K_MAX); const savedPage = Math.round(Number(localStorage.getItem(PAGE_KEY))); if (savedPage >= 1) $("#page-size").value = Math.min(savedPage, PAGE_MAX); // -- wiring --------------------------------------------------------------- $("#review-mode").onchange = (e) => { const on = e.target.checked; localStorage.setItem(REVIEW_KEY, on ? "1" : "0"); applyReviewMode(on); }; $("#category").onchange = async (e) => { const next = e.target.value; e.target.value = state.category; // hold the visible choice until decided if (!(await confirmLeaveGrades())) return; e.target.value = next; snapshotScreen(); // bank the tab being left state.category = next; state.query = ""; $("#query").value = ""; applyCategory(); restoreScreen(); // and bring back the one being entered }; $("#search").onclick = () => (state.lookup ? runLookup() : runSearch()); $("#lookup-mode").onchange = (e) => { setLookupMode(e.target.checked); $("#query").focus(); }; // Recent terms are past text searches, so the dropdown stays shut while the // bar is taking a code. const openRecentUnlessLookup = () => { if (!state.lookup) openRecent(); }; $("#query").addEventListener("focus", openRecentUnlessLookup); $("#query").addEventListener("click", openRecentUnlessLookup); $("#query").addEventListener("input", () => { if (state.lookup) return; recentActive = -1; renderRecentTerms($("#query").value); $("#recent-panel").classList.remove("hidden"); }); $("#query").addEventListener("blur", () => setTimeout(closeRecent, 120)); $("#query").addEventListener("keydown", (e) => { const open = !$("#recent-panel").classList.contains("hidden"); if (e.key === "ArrowDown" && open) { e.preventDefault(); moveRecent(1); return; } if (e.key === "ArrowUp" && open) { e.preventDefault(); moveRecent(-1); return; } if (e.key === "Escape" && open) { e.preventDefault(); closeRecent(); return; } if (e.key !== "Enter") return; const active = $("#recent-panel").querySelector(".recent-item.active"); // A highlighted suggestion wins; otherwise search exactly what was typed. if (open && active && $("#query").value.trim() !== active.firstChild.textContent) { e.preventDefault(); pickRecent(active.firstChild.textContent); return; } closeRecent(); state.lookup ? runLookup() : runSearch(); }); $("#submit").onclick = submitAnnotations; $("#export").onclick = exportCsv; $("#basket-btn").onclick = openBasket; $("#help-btn").onclick = openHelp; $("#drawer-close").onclick = closeDrawer; $("#overlay").onclick = closeDrawer; $("#detail-back").onclick = closeDetailPage; $("#xm-later").onclick = closeExportNudge; $("#xm-rate").onclick = () => { closeExportNudge(); closeDrawer(); closeDetailPage(); const seg = $("#results .grade-seg"); if (seg) seg.scrollIntoView({ behavior: "smooth", block: "center" }); }; $("#export-modal").onclick = (e) => { if (e.target.id === "export-modal") closeExportNudge(); }; document.addEventListener("keydown", trapModalTab); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; // The modal is on top, so it takes Escape first and nothing behind it moves. if (!$("#leave-modal").classList.contains("hidden")) { settleLeave(false); return; } if (!$("#model-modal").classList.contains("hidden")) { closeModelModal(); return; } if (!$("#export-modal").classList.contains("hidden")) { closeExportNudge(); return; } closeDrawer(); closeDetailPage(); }); $("#model").onchange = (e) => { state.model = e.target.value; localStorage.setItem(MODEL_KEY, state.model); if (state.query) runSearch(); // same query, re-ranked by the newly picked model }; // A count box is its own slider: dragging scrubs the value geometrically, // every 80px doubling or halving it, and values snap to two significant // digits, so small counts move by ones and large counts move by hundreds. A // plain click or a click while focused leaves typing untouched, and onCommit // runs once on release or on change. One wiring for every count box, so the // two boxes cannot drift apart in behaviour. const twoSig = (n) => { const mag = Math.pow(10, Math.max(0, Math.floor(Math.log10(n)) - 1)); return Math.round(n / mag) * mag; }; function wireCountBox(box, key, max, onCommit) { const commit = () => { const n = Math.round(Number(box.value)); const v = n >= 1 ? Math.min(n, max) : 50; box.value = v; localStorage.setItem(key, String(v)); onCommit(); }; box.onchange = commit; let scrub = null; box.addEventListener("pointerdown", (e) => { if (e.button !== 0 || document.activeElement === box) return; const val = Math.round(Number(box.value)); scrub = { y: e.clientY, val: val >= 1 ? Math.min(val, max) : 50, moved: false, id: e.pointerId }; }); box.addEventListener("pointermove", (e) => { if (!scrub) return; const dy = e.clientY - scrub.y; if (!scrub.moved && Math.abs(dy) < 4) return; if (!scrub.moved) { scrub.moved = true; box.setPointerCapture(scrub.id); box.blur(); } const raw = Math.round(scrub.val * Math.pow(2, -dy / 80)); box.value = Math.min(Math.max(twoSig(Math.max(raw, 1)), 1), max); e.preventDefault(); }); const end = () => { if (!scrub) return; const moved = scrub.moved; scrub = null; if (moved) { commit(); box.blur(); } }; box.addEventListener("pointerup", end); box.addEventListener("pointercancel", end); } wireCountBox($("#k"), K_KEY, K_MAX, () => { if (state.query) runSearch(); }); // Page size only redraws: the rows are already fetched, so changing how many // show per page must never re-run the search. wireCountBox($("#page-size"), PAGE_KEY, PAGE_MAX, () => { state.page = 1; if (!state.results.length) return; if ($("#pheno-table-host")) drawPhenoTable(); else if ($("#code-table-host")) drawCodeTable(); }); $("#mode-search").onclick = () => setMode("search"); $("#mode-plan").onclick = () => setMode("plan"); $("#plan-btn").onclick = runPlan; $("#cot").addEventListener("toggle", () => { if (!$("#cot").open) return; const pre = $("#cot-text"); pre.scrollTop = pre.scrollHeight; // a collapsed <pre> has no layout to scroll }); $("#plan-model").onchange = (e) => { if (e.target.value === ADD_NEW) { openModelModal(); return; } state.plannerPick = e.target.value; localStorage.setItem(PLANNER_PICK_KEY, state.plannerPick); renderPlannerPicker(); }; $("#plan-model-remove").onclick = removePickedModel; $("#mm-kind").onchange = applyFormatDefaults; $("#mm-cancel").onclick = closeModelModal; $("#mm-save").onclick = saveModelFromModal; $("#model-modal").onclick = (e) => { if (e.target.id === "model-modal") closeModelModal(); }; $("#mm-model").addEventListener("keydown", (e) => { if (e.key === "Enter") saveModelFromModal(); }); $("#mm-key").addEventListener("keydown", (e) => { if (e.key === "Enter") saveModelFromModal(); }); // Enter plans; Shift+Enter is a newline, since this is a free-text box. function autoGrowPlanBox() { const ta = $("#plan-query"); ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 260)}px`; } $("#plan-query").addEventListener("input", autoGrowPlanBox); $("#plan-query").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runPlan(); } }); async function init() { await loadModels(); // before the first search, so a query carries a model applyCategory(); updateBasketCount(); loadPlanner(); // non-blocking: the mode switch appears if configured loadLabNoise(); // non-blocking: merging works on fixed rules until it lands // Deliberately no focus() here. Stealing focus on load pops the recent // searches open before the page has been read, and takes the caret away from // anyone who meant to use the keyboard elsewhere. The field is one click or // one Tab away. } init();