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