Spaces:
Running
Running
| 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 <b>${escHtml(code)}</b> 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 <b>"${escHtml(data.query)}"</b>${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 <tr> 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 <b>"${escHtml(data.query)}"</b>${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 <title> 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(); | |