T−minus source

Every file that ships, readable right here. MIT licence. Prefer it offline? Download the .zip.

README.md53 lines
# T−minus · Readout radar for Medical AffairsT−minus shows when every interventional trial in a therapeutic area reaches primary completion, on one page that works as well on a phone as on a desktop.- **Search.** Type a condition (and optionally a molecule or target) and press Search. T−minus pulls the matching interventional studies from the public ClinicalTrials.gov registry, from one year back to five years ahead.- **Radar.** Every study on one screen: the bright ring is today, the edge is 36 months ahead. Arrange it by phase, sponsor type, top sponsors or top molecules. Press play to watch the landscape read out, with a running ticker. On desktop, a Present button puts the radar full screen.- **Landscape.** Headline counts, readouts per quarter by phase, the sponsors reading out next, and where late-stage trials enroll (EU sites highlighted). Everything follows the radar's filters.- **All studies.** A collapsible, searchable, sortable list of every study (cards on mobile), exportable to CSV.- **Study summary.** Tap or click any study, on the radar or in the list, for a plain-language summary of its registry record: design, population, enrollment, countries, dates, interventions and primary endpoints. On mobile it opens as a bottom sheet you can swipe away.## Install it as an appT−minus is a Progressive Web App. Open it on its own page and use **Install app** in the header (Chrome, Edge, Android), **Share, Add to Home Screen** (iPhone and iPad) or **File, Add to Dock** (Safari on macOS). Installed, it opens in its own window and the app shell works offline; searches still need a connection.## Deploy in three steps (IONOS, Hostinger or any web host)1. Upload `index.html`, `ctgov.php`, `.htaccess`, `manifest.webmanifest`, `sw.js` and the `icons/` folder to a folder on your site, for example `/t-minus/`. HTTPS is required for installation.2. Open `https://your-domain/t-minus/`.3. Type a condition and press **Search**. The page calls `ctgov.php` on your own server, which fetches public registry data from the ClinicalTrials.gov API v2.No database, no build step, no accounts, no API key. PHP 7.4+ with cURL (standard on shared hosting) is all the relay needs. Without the relay, the page tries ClinicalTrials.gov directly from the visitor's browser; that depends on ClinicalTrials.gov accepting cross-site requests, so keep `ctgov.php` in place.The preview on claude.ai cannot search: that viewer blocks every connection to other websites. Search works on your own site.## Privacy and compliance by design| Area | What T−minus does ||---|---|| Personal data | Collects none. No cookies, no localStorage, no analytics, no fingerprinting. Once installed, the browser caches the app's own files for offline use; searches are never cached. || Third parties | None. Fonts are embedded; no CDN, no Google Fonts. The only outbound call is to ClinicalTrials.gov, and only when the user presses Search. With the relay, that call comes from your server, so visitor IP addresses never reach a third party. || Locked by policy | A Content Security Policy with a SHA-256 script hash blocks any script or connection except ClinicalTrials.gov. Links carry no referrer. || Data used | Public registry records only. No patient data, no HCP personal data, no confidential input. || Use | Internal scientific landscape monitoring, not promotion. Anything used externally goes through medical, legal and regulatory review. || Method | Deterministic, transparent rules. No generative AI, so outputs are reproducible and auditable. |Your hosting provider may keep standard web server access logs. If you need zero logs, disable or anonymise them in your hosting control panel.## Customising- **Author name:** edit the footer line near the end of `index.html` (search for `id="author"`).- **Updating an installed app:** change `VERSION` in `sw.js` whenever you ship a new `index.html`.- **Editing the script:** the Content Security Policy pins the exact script by its SHA-256 hash. After any change inside `<script>`, run `python3 build.py` from the `source` folder, or update the hash in the `Content-Security-Policy` meta tag.## Known limitsPrimary completion is not the date results are announced. Registry dates are sponsor-reported and often slip. ClinicalTrials.gov does not hold studies registered only in EU CTIS, ChiCTR, jRCT or other registries. Live scans cover up to 1,500 studies per query.Data source: ClinicalTrials.gov, U.S. National Library of Medicine. T−minus is independent and not endorsed by NLM. Fonts: Newsreader and Atkinson Hyperlegible Next, SIL Open Font License.Concept, medical strategy and code: Aitor Martínez Rosell.Published on [mafflab](https://mafflab.xyz/lab/t-minus/), the open lab for Medical Affairs tools. Code under the MIT licence.
source/src/index.html120 lines
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#0b1724"><!--CSP--><meta name="referrer" content="no-referrer"><link rel="manifest" href="manifest.webmanifest"><link rel="icon" href="icons/icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icons/apple-touch-icon.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="T−minus"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="robots" content="noindex"><meta name="description" content="T−minus: a readout radar for Medical Affairs. See when every trial in a therapeutic area reaches primary completion. No cookies, no tracking."><title>T−minus · Readout radar for Medical Affairs</title><style>/*FONTS*//*CSS*/</style></head><body><header class="top">  <a class="mark" href="#radar" aria-label="T−minus, readout radar for Medical Affairs"><b>T−minus</b><span>Readout radar for Medical Affairs</span></a>  <span class="shield" id="shield" title="This page sets no cookies, stores nothing about you and loads no third-party scripts, fonts or trackers. If you install it, your browser keeps a copy of the app's own files so it opens offline."><i></i><span>No cookies, no trackers</span></span>  <button class="btn small install" id="installBtn" type="button" hidden>Install app</button></header><section class="scan" aria-label="Choose a therapeutic area">  <form id="scanForm" autocomplete="off">    <div class="field"><label class="sr" for="qCond">Condition or disease</label><input id="qCond" type="search" placeholder="Condition or disease, e.g. obesity" required enterkeyhint="search" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false"></div>    <div class="field"><label class="sr" for="qIntr">Molecule or target</label><input id="qIntr" type="search" placeholder="Molecule or target (optional)" enterkeyhint="search" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false"></div>    <button class="btn primary" type="submit" id="scanBtn">Search</button>  </form>  <div class="scan-meta">    <div class="source" id="source" aria-live="polite"></div>    <button class="btn small scan-toggle" id="scanToggle" aria-expanded="false" aria-controls="scanForm">New search</button>  </div>  <div class="progress" id="progress"><i></i></div></section><main>  <!-- RADAR -->  <section class="radar-sec" id="radar-sec" aria-labelledby="lede">    <div class="radar-grid">      <h1 class="lede side-lede" id="lede">Search a condition to see when every trial in it reaches primary completion.</h1>      <div class="radar-col">        <div class="controls needs-data" role="group" aria-label="Radar controls">          <div class="seg" id="sectorSeg" aria-label="Arrange by">            <button data-s="phase" aria-pressed="true">By phase</button>            <button data-s="sponsorClass" aria-pressed="false">Sponsor type</button>            <button data-s="sponsor" aria-pressed="false">Top sponsors</button>            <button data-s="intervention" aria-pressed="false">Top molecules</button>          </div>          <div class="legend" id="phaseLegend" role="group" aria-label="Show phases"></div>          <label class="switch"><input type="checkbox" id="industryOnly"> Industry only</label>          <label class="sr" for="textFilter">Filter studies</label>          <input class="filter" id="textFilter" type="search" placeholder="Filter by sponsor, drug, title">        </div>        <div class="radar-wrap" id="radarWrap">          <canvas id="radar" role="img" aria-label="Radar of trial primary completion dates. Centre is the past year, the bright ring is today, the outer edge is 36 months ahead. A full list of studies follows below the radar."></canvas>          <div class="corner tl" id="cornerTL"></div>          <div class="corner br">Centre: read out in the past year<br>Edge: 36 months ahead</div>          <button class="btn small present needs-data" id="presentBtn" aria-label="Show the radar full screen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>Present</button>        </div>        <div class="timebar needs-data" aria-label="Time machine">          <button class="play" id="playBtn" aria-label="Play the next 36 months"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4.5v15l13-7.5z"/></svg></button>          <div class="tm-mid">            <div class="tm-label"><span>Viewing the landscape as of <strong id="tmDate">today</strong></span><button class="btn small ghost" id="tmReset" hidden>Back to today</button></div>            <label class="sr" for="tm">Move forward in time</label>            <input type="range" id="tm" min="0" max="1095" value="0" step="1">          </div>          <div class="tm-count"><b id="tmCount">0</b><span id="tmCountLbl">trials read out since today</span></div>        </div>        <div class="ticker needs-data" id="ticker" aria-live="polite"></div>      </div>      <aside class="next needs-data" id="nextList" aria-label="Next readouts"></aside>    </div>  </section>  <!-- LANDSCAPE -->  <section class="land needs-data" aria-labelledby="landHead">    <div class="sect-head"><div><h2 class="h big" id="landHead">The landscape behind the radar</h2><p class="sub">Counts and charts follow the phase, sponsor and text filters above the radar.</p></div></div>    <div class="kpis" id="kpis"></div>    <div class="chart" id="waveChart"></div>    <div class="cgrid">      <div class="chart" id="sponsorChart"></div>      <div class="chart" id="geoBlock"></div>    </div>    <details class="studies" id="studies">      <summary><span class="h">All studies</span><span class="count" id="studyCount"></span><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>      <div class="studies-in">        <div class="exports"><label class="sr" for="tableFilter">Search the list</label><input class="filter" id="tableFilter" type="search" placeholder="Search the list"><button class="btn small" id="csvCopy">Copy as CSV</button><button class="btn small" id="csvDl">Download CSV</button></div>        <p class="note">Select a study for its summary.</p>        <div class="tablewrap"><table id="studyTable"></table></div>        <div id="tableMore"></div>      </div>    </details>  </section>  <footer class="foot">    <span>T−minus, a Medical Affairs concept by <strong id="author">Aitor Martínez Rosell</strong></span>    <span>Registry data: ClinicalTrials.gov, U.S. National Library of Medicine; T−minus is independent and not endorsed by NLM. No cookies, no trackers, nothing stored about you.</span>    <span>Part of <a href="https://mafflab.xyz/lab/t-minus/" rel="noopener">mafflab</a>, the open lab for Medical Affairs tools. MIT licence.</span>  </footer></main><div class="scrim" id="scrim"></div><aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drTitle"><div class="dr-in" id="drIn"></div></aside><div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="mTitle"><div class="modal-card" id="modalCard"></div></div><div class="tip" id="tip" role="tooltip"></div><div class="toast" id="toast" role="status"></div><script>/*JS*/</script></body></html>
source/src/app.js820 lines
'use strict';(() => {/* =========================================================   T−minus · Readout radar for Medical Affairs   One file, no dependencies, no cookies, no storage, no tracking.   ========================================================= */const CFG = {  api: 'https://clinicaltrials.gov/api/v2/studies',  proxy: 'ctgov.php',  maxStudies: 1500,  past: 12, future: 36,};const DAY = 864e5, MONTH = 30.4375 * DAY, TAU = Math.PI * 2;const MINUS = '\u2212';const $ = (s, r = document) => r.querySelector(s);const $$ = (s, r = document) => [...r.querySelectorAll(s)];const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));const fmtN = n => (n == null || isNaN(n)) ? '—' : Math.round(n).toLocaleString('en-GB');const fmtD = d => d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '—';const fmtM = d => d ? d.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }) : '—';const fmtDM = d => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });const clamp = (v, a, b) => Math.max(a, Math.min(b, v));const sod = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; };const addDays = (d, n) => new Date(d.getTime() + n * DAY);const addMonths = (d, n) => { const x = new Date(d); const whole = Math.trunc(n); x.setMonth(x.getMonth() + whole); return addDays(x, Math.round((n - whole) * 30.44)); };const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;const pct = (a, b) => b ? Math.round(100 * a / b) : 0;const plural = (n, one, many) => `${fmtN(n)} ${n === 1 ? one : (many || one + 's')}`;const listJoin = a => a.length < 2 ? (a[0] || '') : a.slice(0, -1).join(', ') + ' and ' + a[a.length - 1];const TODAY = sod(new Date());const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;// Embedded = shown inside another site's frame (e.g. a preview). A same-origin frame, such as the// mafflab test drive, runs the full app.const EMBEDDED = (() => { try { return window.self !== window.top && window.top.location.origin !== location.origin; } catch (e) { return true; } })();function hash32(s) { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16; return h >>> 0; }function rng(seed) { return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }function parseDate(s) {  if (!s) return null;  if (s instanceof Date) return s;  const m = /^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?/.exec(String(s).trim());  if (m) return new Date(+m[1], +m[2] - 1, m[3] ? +m[3] : 15);  const t = Date.parse(s); return isNaN(t) ? null : sod(new Date(t));}/* ---------- Domain vocabulary ---------- */const PH = {  P3: { label: 'Phase 3', color: '#c98216' },  P2: { label: 'Phase 2', color: '#17a08a' },  P1: { label: 'Phase 1', color: '#8f84ec' },  OT: { label: 'Phase 4 & other', color: '#6c7c90' },};const PH_ORDER = ['P3', 'P2', 'P1', 'OT'];const ACTIVE = new Set(['NOT_YET_RECRUITING', 'RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING']);const KEEP = new Set([...ACTIVE, 'COMPLETED']);const STATUS_L = { NOT_YET_RECRUITING: 'Not yet recruiting', RECRUITING: 'Recruiting', ENROLLING_BY_INVITATION: 'Enrolling by invitation', ACTIVE_NOT_RECRUITING: 'Active, not recruiting', COMPLETED: 'Completed' };const EU = new Set(['Austria', 'Belgium', 'Bulgaria', 'Croatia', 'Cyprus', 'Czechia', 'Czech Republic', 'Denmark', 'Estonia', 'Finland', 'France', 'Germany', 'Greece', 'Hungary', 'Ireland', 'Italy', 'Latvia', 'Lithuania', 'Luxembourg', 'Malta', 'Netherlands', 'Poland', 'Portugal', 'Romania', 'Slovakia', 'Slovenia', 'Spain', 'Sweden']);const COMPARATOR = /(placebo|vehicle|sham|standard of care|usual care|\bsoc\b|best supportive|no intervention|saline|diet|lifestyle|exercise|counsel|matching|background therapy)/i;/* ---------- State ---------- */const S = {  all: [], src: { kind: 'none' }, ver: 0,  phases: { P3: true, P2: true, P1: true, OT: false },  industryOnly: false, text: '', sector: 'phase',  viewDays: 0, playing: false,  selected: null, hl: null,  table: { sort: 'pcd', dir: 1, q: '', limit: 25 },};const viewDate = () => addDays(TODAY, S.viewDays);let _vis = null, _visVer = -1;function visible() {  if (_visVer === S.ver && _vis) return _vis;  const q = S.text.trim().toLowerCase();  _vis = S.all.filter(t => S.phases[t.phase] && (!S.industryOnly || t.sclass === 'Industry') &&    (!q || (t.title + ' ' + t.acronym + ' ' + t.sponsor + ' ' + t.interventions.map(i => i.name).join(' ') + ' ' + t.id).toLowerCase().includes(q)));  _visVer = S.ver; return _vis;}const bump = () => { S.ver++; };const byId = id => S.all.find(t => t.id === id);/* ---------- Normalisation ---------- */function phaseOf(arr) {  const a = (arr || []).map(x => String(x).toUpperCase().replace(/[^A-Z0-9]/g, ''));  const has = p => a.includes(p);  if (has('PHASE3')) return { key: 'P3', label: has('PHASE2') ? 'Phase 2/3' : 'Phase 3' };  if (has('PHASE2')) return { key: 'P2', label: has('PHASE1') ? 'Phase 1/2' : 'Phase 2' };  if (has('PHASE1') || has('EARLYPHASE1')) return { key: 'P1', label: has('PHASE1') ? 'Phase 1' : 'Early Phase 1' };  if (has('PHASE4')) return { key: 'OT', label: 'Phase 4' };  return { key: 'OT', label: 'Phase not applicable' };}const normStatus = s => s ? String(s).toUpperCase().replace(/[^A-Z]+/g, '_').replace(/^_|_$/g, '') : '';function sclassOf(c) {  const x = String(c || '').toUpperCase().replace(/[^A-Z]+/g, '_');  if (x === 'INDUSTRY') return 'Industry';  if (['NIH', 'FED', 'OTHER_GOV', 'U_S_FED'].includes(x)) return 'Government';  return 'Academic & other';}function shortTitle(s) { s = String(s || ''); if (s.length <= 44) return s; const cut = s.slice(0, 44); return cut.slice(0, cut.lastIndexOf(' ')) + '…'; }function drugKey(n) { return String(n).toLowerCase().replace(/\b\d+(\.\d+)?\s*(mg|µg|mcg|ug|ml|iu|units?|%)\b/g, '').replace(/[^a-z0-9\- ]/g, ' ').replace(/\s+/g, ' ').trim(); }function mkTrial(o) {  const pcd = parseDate(o.pcd);  if (!pcd || !o.id) return null;  if (o.studyType && !/INTERVENTIONAL/i.test(o.studyType)) return null;  const status = normStatus(o.status);  if (status && !KEEP.has(status)) return null;  const ph = phaseOf(o.phases);  let pcdType = String(o.pcdType || '').toUpperCase();  if (!pcdType) pcdType = pcd < TODAY ? 'ACTUAL' : 'ESTIMATED';  const lastUpdate = parseDate(o.lastUpdate);  const interventions = (o.interventions || []).filter(x => x && x.name).map(x => ({ name: String(x.name).trim(), type: String(x.type || 'DRUG').toUpperCase() }));  const primary = (o.primary || []).filter(Boolean).map(s => String(s).trim().slice(0, 260));  const countries = [...new Set((o.countries || []).filter(Boolean).map(c => String(c).trim()))];  const drugs = [...new Map(interventions.filter(x => /DRUG|BIOLOGICAL|GENETIC|COMBINATION/.test(x.type) && !COMPARATOR.test(x.name)).map(x => [drugKey(x.name), x.name])).entries()];  const t = {    id: String(o.id), title: o.title || String(o.id), acronym: o.acronym || '', status,    phase: ph.key, phaseLabel: ph.label, pcd, pcdType,    start: parseDate(o.start), completion: parseDate(o.completion), lastUpdate,    sponsor: (o.sponsor || 'Unknown sponsor').trim(), sclass: sclassOf(o.sponsorClass),    enrollment: +o.enrollment || null, interventions, conditions: o.conditions || [], primary,    countries, eu: countries.filter(c => EU.has(c)).length,    hasResults: !!o.hasResults, drugs,  };  t.active = ACTIVE.has(status);  t.overdue = pcdType === 'ESTIMATED' && pcd < TODAY && t.active;  t.stale = t.active && !!lastUpdate && (TODAY - lastUpdate) > 365 * DAY;  t.multiActive = drugs.length >= 2;  const phShort = { P3: 'Phase 3', P2: 'Phase 2', P1: 'Phase 1' }[ph.key] || ph.label;  const d0 = drugs.length ? drugs[0][1] : '';  t.name = t.acronym || (d0 && d0.length <= 30 ? `${d0} ${phShort} study` : shortTitle(t.title));  t.seed = hash32(t.id) / 4294967296;  t.size = clamp(2.3 + 1.55 * Math.log10((t.enrollment || 40) + 1), 2.6, 9.5);  return t;}function fromApi(s) {  const p = s.protocolSection || {}, id = p.identificationModule || {}, st = p.statusModule || {}, sp = p.sponsorCollaboratorsModule || {},    de = p.designModule || {}, ai = p.armsInterventionsModule || {}, co = p.conditionsModule || {}, oc = p.outcomesModule || {}, cl = p.contactsLocationsModule || {};  return mkTrial({    id: id.nctId, title: id.briefTitle, acronym: id.acronym, status: st.overallStatus, studyType: de.studyType,    phases: de.phases, pcd: st.primaryCompletionDateStruct?.date, pcdType: st.primaryCompletionDateStruct?.type,    start: st.startDateStruct?.date, completion: st.completionDateStruct?.date, lastUpdate: st.lastUpdatePostDateStruct?.date,    sponsor: sp.leadSponsor?.name, sponsorClass: sp.leadSponsor?.class, enrollment: de.enrollmentInfo?.count,    interventions: (ai.interventions || []).map(x => ({ name: x.name, type: x.type })), conditions: co.conditions,    primary: (oc.primaryOutcomes || []).map(o => o.measure), countries: (cl.locations || []).map(l => l.country), hasResults: s.hasResults,  });}/* ---------- Live registry (ClinicalTrials.gov API v2) ---------- */const FIELDS = 'NCTId,BriefTitle,Acronym,OverallStatus,Phase,StudyType,PrimaryCompletionDate,PrimaryCompletionDateType,StartDate,CompletionDate,LastUpdatePostDate,LeadSponsorName,LeadSponsorClass,EnrollmentCount,InterventionName,InterventionType,Condition,PrimaryOutcomeMeasure,LocationCountry,HasResults';function apiParams(cond, intr, level, size) {  const p = new URLSearchParams();  p.set('format', 'json'); p.set('countTotal', 'true'); p.set('pageSize', String(size || (level ? 500 : 200)));  p.set('query.cond', cond); if (intr) p.set('query.intr', intr);  p.set('filter.overallStatus', [...KEEP].join(','));  if (level >= 1) p.set('filter.advanced', `AREA[StudyType]INTERVENTIONAL AND AREA[PrimaryCompletionDate]RANGE[${iso(addMonths(TODAY, -CFG.past))},${iso(addMonths(TODAY, 60))}]`);  if (level >= 2) p.set('fields', FIELDS);  return p;}function err(kind, status) { const e = new Error(kind); e.kind = kind; e.status = status; return e; }async function fetchAll(base, params, onProg) {  let token = null, out = [], total = null;  do {    const p = new URLSearchParams(params); if (token) p.set('pageToken', token);    const url = base + '?' + p.toString();    let res;    try { res = await fetch(url, { credentials: 'omit', referrerPolicy: 'no-referrer', cache: 'no-store' }); }    catch (e) { throw err('network'); }    if (!res.ok) throw err('http', res.status);    if (!/json/i.test(res.headers.get('content-type') || '')) throw err('notjson');    const j = await res.json();    if (j && j.error) throw err('http', 502);    if (total == null && j.totalCount != null) total = j.totalCount;    out.push(...(j.studies || []));    token = j.nextPageToken;    onProg(out.length, total);  } while (token && out.length < CFG.maxStudies);  return { studies: out, total };}async function scan(cond, intr) {  cond = cond.trim(); intr = (intr || '').trim();  if (!cond) return;  stopPlay();  busy(true, `Contacting ClinicalTrials.gov for “${cond}”…`);  const bases = (/^https?:$/.test(location.protocol) ? [CFG.proxy] : []).concat(CFG.api);  let last;  for (const base of bases) {    for (const level of [2, 1, 0]) {      try {        const r = await fetchAll(base, apiParams(cond, intr, level), (n, t) => busy(true, `Receiving studies: ${fmtN(n)}${t != null ? ` of ${fmtN(Math.min(t, CFG.maxStudies))}` : ''}`));        const trials = r.studies.map(fromApi).filter(Boolean);        busy(false);        if (!trials.length) { modalEmpty(cond, intr); return; }        load(trials, { kind: 'live', label: intr ? `${cond} with ${intr}` : cond, cond, intr, total: r.total, fetched: r.studies.length, via: base === CFG.proxy ? 'your relay' : 'direct', at: new Date() });        return;      } catch (e) { last = e; if (!(e.kind === 'http' && e.status === 400)) break; }    }  }  busy(false);  modalBlocked(cond, intr, last);}/* ---------- Loading data ---------- */function load(trials, src) {  S.all = trials; S.src = src; S.selected = null; S.text = ''; $('#textFilter').value = '';  S.table.q = ''; $('#tableFilter').value = ''; S.table.limit = 25;  bump();  setViewDays(0, true);  if (S.sector !== 'phase' && S.sector !== 'sponsorClass') S.sector = 'phase';  syncControls();  radar.relayout(true);  renderSource();  document.body.classList.add('has-data');  renderRadarSide();  closeDrawer(); renderLandscape();  radar.resize();  $('.scan').classList.remove('open'); $('#scanToggle').setAttribute('aria-expanded', 'false'); $('#scanToggle').textContent = 'New search';  if (document.activeElement && document.activeElement.blur) document.activeElement.blur();  if (innerWidth <= 760) setTimeout(() => $('#lede').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' }), 80);}/* ---------- Derived data ---------- */const inWin = t => t.pcd >= addMonths(TODAY, -CFG.past) && t.pcd <= addMonths(TODAY, CFG.future);const upcoming = (list, from = TODAY, months = CFG.future) => { const end = addMonths(from, months); return list.filter(t => t.pcd > from && t.pcd <= end); };function sectors(list) {  const mode = S.sector;  let keys = [];  const count = (fn) => { const m = new Map(); for (const t of list) { const k = fn(t); m.set(k, (m.get(k) || 0) + 1); } return m; };  if (mode === 'phase') { const m = count(t => t.phase); keys = PH_ORDER.filter(k => m.get(k)).map(k => ({ key: k, label: PH[k].label, n: m.get(k) })); return { keys, of: t => t.phase }; }  if (mode === 'sponsorClass') { const m = count(t => t.sclass); keys = ['Industry', 'Academic & other', 'Government'].filter(k => m.get(k)).map(k => ({ key: k, label: k, n: m.get(k) })); return { keys, of: t => t.sclass }; }  if (mode === 'sponsor') {    const m = count(t => t.sponsor); const top = [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 7).map(e => e[0]);    const set = new Set(top); const of = t => set.has(t.sponsor) ? t.sponsor : '__other';    keys = top.map(k => ({ key: k, label: k, n: m.get(k) }));    const rest = list.length - keys.reduce((s, k) => s + k.n, 0); if (rest) keys.push({ key: '__other', label: 'All other sponsors', n: rest });    return { keys, of };  }  const m = new Map(), lab = new Map();  for (const t of list) for (const [k, nm] of t.drugs.slice(0, 1)) { m.set(k, (m.get(k) || 0) + 1); if (!lab.has(k)) lab.set(k, nm); }  const top = [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 7).map(e => e[0]); const set = new Set(top);  const of = t => { const d = t.drugs.find(([k]) => set.has(k)); return d ? d[0] : '__other'; };  keys = top.map(k => ({ key: k, label: lab.get(k), n: 0 }));  const cm = new Map(); for (const t of list) { const k = of(t); cm.set(k, (cm.get(k) || 0) + 1); }  keys.forEach(k => k.n = cm.get(k.key) || 0);  if (cm.get('__other')) keys.push({ key: '__other', label: 'Other molecules', n: cm.get('__other') });  return { keys: keys.filter(k => k.n), of };}/* =========================================================   Radar   ========================================================= */const radar = {  init() {    this.cv = $('#radar'); this.ctx = this.cv.getContext('2d'); this.wrap = $('#radarWrap');    this.sweep = -Math.PI / 2; this.acq = 0; this.last = performance.now(); this.ripples = []; this.pts = []; this.hover = null;    this.angles = new Map(); this.sectorInfo = [];    this.sprites = {};    for (const k of PH_ORDER) this.sprites[k] = this.sprite(PH[k].color);    new ResizeObserver(() => this.resize()).observe(this.wrap);    this.onscreen = true; if ('IntersectionObserver' in window) new IntersectionObserver(es => { this.onscreen = es[0].isIntersecting; }).observe(this.wrap);    this.resize();    const hit = (e, pad) => { const r = this.cv.getBoundingClientRect(); const x = e.clientX - r.left, y = e.clientY - r.top; let best = null, bd = 1e9; for (const p of this.pts) { const d = Math.hypot(p.x - x, p.y - y); if (d < p.s + pad && d < bd) { bd = d; best = p; } } return best; };    this.cv.addEventListener('pointermove', e => { if (e.pointerType === 'touch') return; const p = hit(e, 6); this.hover = p ? p.t.id : null; this.cv.style.cursor = p ? 'pointer' : 'crosshair'; p ? showTip(tipTrial(p.t), e.clientX, e.clientY) : hideTip(); });    this.cv.addEventListener('pointerleave', () => { this.hover = null; hideTip(); });    this.cv.addEventListener('click', e => { const p = hit(e, e.pointerType === 'touch' ? 14 : 6); if (p) { hideTip(); openTrial(p.t.id); } });    requestAnimationFrame(t => this.loop(t));  },  sprite(color) {    const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d');    const gr = g.createRadialGradient(32, 32, 0, 32, 32, 32); gr.addColorStop(0, color + 'cc'); gr.addColorStop(0.35, color + '55'); gr.addColorStop(1, color + '00');    g.fillStyle = gr; g.fillRect(0, 0, 64, 64); return c;  },  resize() {    const r = this.wrap.getBoundingClientRect(); if (!r.width) return;    this.dpr = Math.min(2, window.devicePixelRatio || 1); this.W = r.width; this.H = r.height;    this.cv.width = Math.round(this.W * this.dpr); this.cv.height = Math.round(this.H * this.dpr);    this.narrow = this.W < 560;    this.margin = this.narrow ? 40 : 84;    this.R = Math.max(60, Math.min(this.W, this.H) / 2 - this.margin);    this.cx = this.W / 2; this.cy = this.H / 2;    this.relayout();  },  relayout(acquire) {    const list = visible();    const sec = sectors(list); this.sec = sec;    const total = list.length || 1, minW = total * 0.07;    const weights = sec.keys.map(k => Math.max(k.n, minW)); const ws = weights.reduce((a, b) => a + b, 0) || 1;    let a = -Math.PI / 2; this.sectorInfo = [];    const groups = new Map(sec.keys.map(k => [k.key, []])); for (const t of list) { const g = groups.get(sec.of(t)); if (g) g.push(t); }    this.angles.clear();    sec.keys.forEach((k, i) => {      const span = TAU * weights[i] / ws, pad = Math.min(0.06, span * 0.08);      this.sectorInfo.push({ a0: a, a1: a + span, label: k.label, n: k.n, key: k.key });      for (const t of groups.get(k.key) || []) this.angles.set(t.id, a + pad + t.seed * (span - 2 * pad));      a += span;    });    this.order = [...list].sort((x, y) => y.size - x.size);    if (acquire && !REDUCED) { this.acq = 0; this.sweep = -Math.PI / 2; }    if (REDUCED) this.acq = TAU;  },  rad(m) {    const R = this.R, r0 = R * 0.06, rN = R * 0.3;    if (m <= 0) return r0 + (rN - r0) * (1 + Math.max(m, -CFG.past) / CFG.past);    return rN + (R - rN) * Math.pow(Math.min(m, CFG.future) / CFG.future, 0.82);  },  loop(ts) {    const dt = Math.min(80, ts - this.last); this.last = ts;    if (!REDUCED) { this.sweep += dt * 0.00105; this.acq += dt * 0.00105; }    if (S.playing) { setViewDays(S.viewDays + dt * 0.05); if (S.viewDays >= 1095) stopPlay(); }    if (this.onscreen && !document.hidden) this.draw(ts);    requestAnimationFrame(t => this.loop(t));  },  draw(ts) {    const c = this.ctx, { W, H, cx, cy, R } = this; if (!W) return;    c.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); c.clearRect(0, 0, W, H);    const vd = viewDate(), rN = this.rad(0);    // past zone    c.beginPath(); c.arc(cx, cy, rN, 0, TAU); c.fillStyle = 'rgba(4,10,18,.35)'; c.fill();    // rings    const rings = [6, 12, 18, 24, 36];    c.lineWidth = 1;    for (const m of rings) { c.beginPath(); c.arc(cx, cy, this.rad(m), 0, TAU); c.strokeStyle = m === 12 || m === 24 || m === 36 ? '#22405e' : '#1a3149'; c.stroke(); }    c.beginPath(); c.arc(cx, cy, this.rad(-6), 0, TAU); c.strokeStyle = '#172b40'; c.stroke();    // sectors    c.strokeStyle = '#1d3650';    for (const s of this.sectorInfo) { c.beginPath(); c.moveTo(cx + Math.cos(s.a0) * this.rad(-CFG.past), cy + Math.sin(s.a0) * this.rad(-CFG.past)); c.lineTo(cx + Math.cos(s.a0) * (R + 6), cy + Math.sin(s.a0) * (R + 6)); c.stroke(); }    // sweep    const sw = ((this.sweep % TAU) + TAU) % TAU, wedge = 0.9;    if (!REDUCED) {      if (c.createConicGradient) { const g = c.createConicGradient(sw - wedge, cx, cy); g.addColorStop(0, 'rgba(191,224,255,0)'); g.addColorStop(wedge / TAU, 'rgba(191,224,255,0.16)'); g.addColorStop(Math.min(1, wedge / TAU + 0.0005), 'rgba(191,224,255,0)'); c.fillStyle = g; }      else c.fillStyle = 'rgba(191,224,255,0.06)';      c.beginPath(); c.moveTo(cx, cy); c.arc(cx, cy, R, sw - wedge, sw); c.closePath(); c.fill();      c.beginPath(); c.moveTo(cx, cy); c.lineTo(cx + Math.cos(sw) * R, cy + Math.sin(sw) * R); c.strokeStyle = 'rgba(191,224,255,.5)'; c.lineWidth = 1.2; c.stroke();    }    // now ring    c.beginPath(); c.arc(cx, cy, rN, 0, TAU); c.strokeStyle = 'rgba(191,224,255,.85)'; c.lineWidth = 1.6; c.stroke();    c.beginPath(); c.arc(cx, cy, rN, 0, TAU); c.strokeStyle = 'rgba(191,224,255,.18)'; c.lineWidth = 7; c.stroke();    // sector labels    const maxCh = this.narrow ? 13 : 24;    for (const s of this.sectorInfo) {      const mid = (s.a0 + s.a1) / 2, lx = cx + Math.cos(mid) * (R + (this.narrow ? 10 : 16)), ly = cy + Math.sin(mid) * (R + (this.narrow ? 10 : 16));      const cs = Math.cos(mid), sn = Math.sin(mid);      c.textAlign = cs > 0.25 ? 'left' : cs < -0.25 ? 'right' : 'center';      c.textBaseline = sn > 0.3 ? 'top' : sn < -0.3 ? 'bottom' : 'middle';      let lbl = s.label.length > maxCh ? s.label.slice(0, maxCh - 1) + '…' : s.label;      c.font = `600 ${this.narrow ? 11 : 12.5}px "Atkinson Hyperlegible Next", system-ui, sans-serif`; c.fillStyle = '#c9d6e3';      const dy = c.textBaseline === 'bottom' ? -14 : 0, sub = plural(s.n, 'study', 'studies');      const tw = Math.max(c.measureText(lbl).width, c.measureText(sub).width + 4);      let x = lx;      if (c.textAlign === 'left') x = Math.min(lx, W - 6 - tw); else if (c.textAlign === 'right') x = Math.max(lx, 6 + tw); else x = clamp(lx, 6 + tw / 2, W - 6 - tw / 2);      c.fillText(lbl, x, ly + dy);      c.font = `${this.narrow ? 10 : 11.5}px "Atkinson Hyperlegible Next", system-ui, sans-serif`; c.fillStyle = '#7f91a5';      c.fillText(sub, x, ly + dy + 15);    }    // blips    this.pts = [];    const scale = this.narrow ? 0.8 : 1;    const acquiredAll = this.acq >= TAU;    for (const t of this.order) {      const m = (t.pcd - vd) / MONTH;      if (m < -CFG.past || m > CFG.future) continue;      const a = this.angles.get(t.id); if (a == null) continue;      if (!acquiredAll) { const rel = ((a + Math.PI / 2) % TAU + TAU) % TAU; if (rel > this.acq) continue; }      const r = this.rad(m), x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r;      const d = ((sw - ((a % TAU) + TAU) % TAU) + TAU) % TAU;      const glow = REDUCED ? 0.5 : Math.exp(-d / 1.15);      const s = t.size * scale, past = m <= 0;      const hl = this.hover === t.id || S.hl === t.id || S.selected === t.id;      c.globalAlpha = (past ? 0.25 : 0.35) + 0.55 * glow + (hl ? 0.3 : 0);      const gs = s * (2.4 + 1.8 * glow);      c.drawImage(this.sprites[t.phase], x - gs, y - gs, gs * 2, gs * 2);      c.globalAlpha = past ? 0.62 : 0.92;      c.beginPath(); c.arc(x, y, s, 0, TAU); c.fillStyle = PH[t.phase].color; c.fill();      c.lineWidth = 1.5; c.strokeStyle = '#0f1d2c'; c.stroke();      c.globalAlpha = 1;      if (t.overdue && !past) { c.beginPath(); c.arc(x, y, s + 3, 0, TAU); c.strokeStyle = '#e5615c'; c.lineWidth = 1.5; c.stroke(); }      else if (t.overdue) { c.beginPath(); c.arc(x, y, s + 3, 0, TAU); c.strokeStyle = 'rgba(229,97,92,.8)'; c.lineWidth = 1.5; c.stroke(); }      if (hl) { const pulse = REDUCED ? 0 : (Math.sin(ts / 220) + 1) * 2; c.beginPath(); c.arc(x, y, s + 7 + pulse, 0, TAU); c.strokeStyle = '#bfe0ff'; c.lineWidth = 2; c.stroke(); }      this.pts.push({ x, y, s: Math.max(s, 5), t });    }    // ring labels, haloed so blips never hide them    c.font = `${this.narrow ? 10 : 11}px "Atkinson Hyperlegible Next", system-ui, sans-serif`; c.textBaseline = 'bottom'; c.textAlign = 'left'; c.lineWidth = 3.5; c.strokeStyle = 'rgba(11,23,36,.92)';    const ringLbl = (txt, y, col) => { c.strokeText(txt, cx + 5, y); c.fillStyle = col; c.fillText(txt, cx + 5, y); };    for (const m of [6, 12, 24, 36]) { if (this.narrow && m === 6) continue; ringLbl(`+${m} mo`, cy - this.rad(m) - 3, '#8fa1b5'); }    c.font = `600 ${this.narrow ? 10.5 : 11.5}px "Atkinson Hyperlegible Next", system-ui, sans-serif`;    ringLbl(S.viewDays > 0 ? fmtDM(vd) + ' ' + vd.getFullYear() : 'Today', cy - rN - 4, '#e8eef5');    // direct labels for the next Phase 3 readouts    if (!this.narrow) {      c.font = `600 11.5px "Atkinson Hyperlegible Next", system-ui, sans-serif`; c.textBaseline = 'middle'; c.textAlign = 'left';      const boxes = [{ x: cx + 2, y: cy - rN - 20, w: 64, h: 18 }];      for (const p of this.pts.filter(p => p.t.phase === 'P3' && p.t.pcd > vd).sort((a, b) => a.t.pcd - b.t.pcd).slice(0, 6)) {        const txt = p.t.name.length > 20 ? p.t.name.slice(0, 19) + '…' : p.t.name, tw = c.measureText(txt).width;        const right = p.x >= cx, lx = right ? p.x + p.s + 8 : p.x - p.s - 8 - tw;        const bx = { x: lx - 2, y: p.y - 8, w: tw + 4, h: 16 };        if (boxes.some(b => bx.x < b.x + b.w && bx.x + bx.w > b.x && bx.y < b.y + b.h && bx.y + bx.h > b.y)) continue;        boxes.push(bx);        c.lineWidth = 4; c.strokeStyle = 'rgba(11,23,36,.9)'; c.strokeText(txt, lx, p.y); c.fillStyle = '#e8eef5'; c.fillText(txt, lx, p.y);      }    }    // ripples    const now = performance.now();    this.ripples = this.ripples.filter(rp => now - rp.t0 < 1500);    for (const rp of this.ripples) {      const a = this.angles.get(rp.id); if (a == null) continue;      const k = (now - rp.t0) / 1500, x = cx + Math.cos(a) * rN, y = cy + Math.sin(a) * rN;      c.beginPath(); c.arc(x, y, 4 + k * 34, 0, TAU); c.strokeStyle = rp.color; c.globalAlpha = (1 - k) * 0.9; c.lineWidth = 2; c.stroke(); c.globalAlpha = 1;    }  },  ripple(t) { if (!REDUCED && this.ripples.length < 40) this.ripples.push({ id: t.id, t0: performance.now(), color: PH[t.phase].color }); },};function tipTrial(t) {  const d = Math.round((t.pcd - viewDate()) / DAY);  const when = d > 0 ? `in ${plural(d, 'day')}` : d === 0 ? 'today' : `${plural(-d, 'day')} ago`;  return `<b>${esc(t.name)}</b><div class="k">${esc(t.sponsor)}</div>  <div class="row" style="margin-top:6px"><span class="k">${esc(t.phaseLabel)}</span><span>${t.enrollment ? fmtN(t.enrollment) + ' planned' : ''}</span></div>  <div class="row"><span class="k">Primary completion</span><span>${fmtD(t.pcd)}</span></div>  <div class="row"><span class="k">${t.pcdType === 'ACTUAL' ? 'Actual date' : 'Sponsor estimate'}</span><span>${when}</span></div>  ${t.overdue ? '<div style="color:#f2a29e;margin-top:4px">Estimate has passed, still ongoing</div>' : ''}`;}/* ---------- Time machine ---------- */let uiTimer = 0;function setViewDays(d, silent) {  const prev = S.viewDays; d = clamp(d, 0, 1095); S.viewDays = d;  const tm = $('#tm'); tm.value = Math.round(d); tm.style.setProperty('--fill', (d / 1095 * 100) + '%');  if (!silent && d > prev && d - prev < 90) {    const a = addDays(TODAY, prev), b = addDays(TODAY, d);    const crossed = visible().filter(t => t.pcd > a && t.pcd <= b).sort((x, y) => x.pcd - y.pcd);    for (const t of crossed) { radar.ripple(t); addTick(t); }  }  if (silent) { $('#ticker').innerHTML = ''; }  const now = performance.now();  if (silent || now - uiTimer > 140 || d === 0 || d === 1095) { uiTimer = now; updateTimeUI(); }}function updateTimeUI() {  const vd = viewDate();  $('#tmDate').textContent = S.viewDays < 0.5 ? 'today' : fmtD(vd);  $('#tmReset').hidden = S.viewDays < 0.5;  const passed = visible().filter(t => t.pcd > TODAY && t.pcd <= vd);  $('#tmCount').textContent = fmtN(passed.length);  const p3 = passed.filter(t => t.phase === 'P3').length;  $('#tmCountLbl').textContent = S.viewDays < 0.5 ? 'press play to watch the next 36 months read out' : `trials read out since today, ${fmtN(p3)} Phase 3`;  renderLede(); renderNext();}function addTick(t) {  const tk = $('#ticker');  const el = document.createElement('div'); el.className = 'tick';  el.innerHTML = `<i style="background:${PH[t.phase].color}"></i><time>${fmtD(t.pcd)}</time><span><b>${esc(t.name)}</b> from ${esc(t.sponsor)} reaches primary completion (${esc(t.phaseLabel)})</span>`;  tk.prepend(el); while (tk.children.length > 3) tk.lastChild.remove();}function play() {  if (S.viewDays >= 1094) setViewDays(0, true);  S.playing = true; $('#playBtn').innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>`; $('#playBtn').setAttribute('aria-label', 'Pause');}function stopPlay() {  S.playing = false; $('#playBtn').innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4.5v15l13-7.5z"/></svg>`; $('#playBtn').setAttribute('aria-label', 'Play the next 36 months'); updateTimeUI();}/* ---------- Radar side: lede + next list ---------- */function scopeName() { return S.src.label || 'this search'; }function renderLede() {  if (S.src.kind === 'none') { $('#lede').innerHTML = 'Search a condition to see when every trial in it reaches primary completion.'; return; }  const vd = viewDate(), list = visible(), up = upcoming(list, vd);  const p3 = up.filter(t => t.phase === 'P3').length;  const next = up.slice().sort((a, b) => a.pcd - b.pcd)[0];  let s = `${S.viewDays >= 0.5 ? `As of ${fmtD(vd)}, ` : ''}${plural(up.length, 'trial')} in ${esc(scopeName())} reach primary completion in the next 36 months`;  s += p3 ? `, ${fmtN(p3)} of them Phase 3.` : '.';  if (next) { const d = Math.max(1, Math.round((next.pcd - vd) / DAY)); s += ` <span class="q">Next up: ${esc(next.name)}, ${d <= 99 ? plural(d, 'day') : Math.round(d / 30.44) + ' months'} out.</span>`; }  if (!list.length) s = 'No studies match the current filters. Turn a phase back on or clear the filter.';  $('#lede').innerHTML = s;}const flagHtml = t => (t.overdue ? `<span class="flag overdue" title="Sponsor's estimated date has passed but the study is still listed as ongoing">${ICON.alert}Overdue estimate</span>` : '') + (t.stale ? `<span class="flag" title="No registry update for more than 12 months">${ICON.clock}Stale record</span>` : '');function renderNext() {  const vd = viewDate();  const up = visible().filter(t => t.pcd > vd).sort((a, b) => a.pcd - b.pcd).slice(0, 7);  const key = up.map(t => t.id).join() + Math.round(S.viewDays);  if (key === renderNext.k) return; renderNext.k = key;  $('#nextList').innerHTML = `<h2 class="h">Next to read out</h2>` + (up.length ? up.map(t => {    const d = Math.max(1, Math.round((t.pcd - vd) / DAY));    const big = d <= 99 ? d : (d / 30.44).toFixed(d < 300 ? 1 : 0), unit = d <= 99 ? (d === 1 ? 'day' : 'days') : 'months';    return `<button class="nx" data-id="${esc(t.id)}"><div class="t">${big}<small>${unit}</small></div><div><div class="n">${esc(t.name)}</div><div class="m">${esc(t.sponsor)}</div><div class="m" style="display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;margin-top:4px"><span class="ph"><i style="background:${PH[t.phase].color}"></i>${esc(t.phaseLabel)}</span>${t.enrollment ? `<span>${fmtN(t.enrollment)} planned</span>` : ''}${flagHtml(t)}</div></div></button>`;  }).join('') : `<p class="empty">Nothing left on the horizon for these filters.</p>`);}function renderRadarSide() { renderNext.k = null; renderLede(); renderNext(); updateTimeUI(); $('#cornerTL').textContent = S.src.kind === 'none' ? '' : `${fmtN(visible().length)} studies shown`; }/* =========================================================   Trial drawer   ========================================================= */let lastFocus = null;function summaryText(t) {  const d = Math.round((t.pcd - TODAY) / DAY);  const rel = d > 0 ? `${plural(d, 'day')} from today` : d === 0 ? 'today' : `${plural(-d, 'day')} ago`;  const mols = t.drugs.map(x => x[1]).slice(0, 3);  const placebo = t.interventions.some(i => /placebo|vehicle|sham/i.test(i.name));  const other = t.interventions.find(i => COMPARATOR.test(i.name) && !/placebo|vehicle|sham/i.test(i.name));  const kind = t.sclass === 'Industry' ? 'industry-sponsored' : t.sclass === 'Government' ? 'publicly funded' : 'academic or network-led';  const what = mols.length ? ` testing ${listJoin(mols)}${placebo ? ' against placebo' : other ? ` against ${other.name.toLowerCase()}` : ''}` : '';  const out = [`A ${t.phaseLabel}, ${kind} study${what}${t.conditions.length ? ` in ${listJoin(t.conditions.slice(0, 3))}` : ''}.`];  const where = t.countries.length ? ` in ${plural(t.countries.length, 'country', 'countries')}${t.eu ? `, ${t.eu} of them in the EU` : ''}` : '';  if (t.enrollment) out.push(`${t.status === 'COMPLETED' ? 'It enrolled' : 'It involves'} ${fmtN(t.enrollment)} participants${where}.`); else if (where) out.push(`Sites are listed${where}.`);  if (t.pcdType === 'ACTUAL') out.push(`Primary completion was reached on ${fmtD(t.pcd)}, ${rel}.`);  else if (d >= 0) out.push(`The sponsor estimates primary completion for ${fmtD(t.pcd)}, ${rel}.`);  else out.push(`The sponsor estimated primary completion for ${fmtD(t.pcd)}, ${rel}, yet the study is still listed as ${(STATUS_L[t.status] || 'ongoing').toLowerCase()}.`);  if (t.primary.length) out.push(`${t.primary.length > 1 ? `Its ${t.primary.length} primary endpoints include` : 'Its primary endpoint is'} ${t.primary[0].replace(/\.$/, '').replace(/^([A-Z])(?=[a-z])/, c => c.toLowerCase())}.`);  return out.join(' ');}function openTrial(id) {  const t = byId(id); if (!t) return;  if (document.fullscreenElement) document.exitFullscreen().catch(() => {});  stopPlay(); S.selected = id; lastFocus = document.activeElement;  const d = Math.round((t.pcd - TODAY) / DAY);  const nct = /^NCT\d{8}$/.test(t.id);  const cs = t.countries, csTxt = cs.length ? cs.slice(0, 14).join(', ') + (cs.length > 14 ? ` and ${cs.length - 14} more` : '') : 'Not listed';  $('#drIn').innerHTML = `    <div class="dr-top"><span class="ph"><i style="background:${PH[t.phase].color}"></i>${esc(t.phaseLabel)}</span><button class="x" data-close aria-label="Close summary">${ICON.x}</button></div>    <h2 class="dr-title" id="drTitle">${esc(t.name)}</h2>    <p class="dr-sub">${t.name !== t.title ? esc(t.title) + '<br>' : ''}${esc(t.sponsor)} (${esc(t.sclass)})</p>    <div class="countdown"><b>T${d >= 0 ? MINUS : '+'}${fmtN(Math.abs(d))}</b><span>${d >= 0 ? 'days to primary completion' : 'days since primary completion'}<br>${fmtD(t.pcd)}, ${t.pcdType === 'ACTUAL' ? 'actual' : 'sponsor estimate'}</span></div>    ${t.overdue || t.stale ? `<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px">${flagHtml(t)}</div>` : ''}    <p class="dr-summary">${esc(summaryText(t))}</p>    <dl class="facts">      <dt>Status</dt><dd>${esc(STATUS_L[t.status] || t.status || 'Not stated')}</dd>      <dt>Study start</dt><dd>${fmtD(t.start)}</dd>      <dt>Primary completion</dt><dd>${fmtD(t.pcd)} (${t.pcdType === 'ACTUAL' ? 'actual' : 'estimate'})</dd>      <dt>Study completion</dt><dd>${fmtD(t.completion)}</dd>      <dt>Enrollment</dt><dd>${t.enrollment ? fmtN(t.enrollment) + ' participants' : 'Not stated'}</dd>      <dt>Conditions</dt><dd>${esc(t.conditions.join(', ') || 'Not stated')}</dd>      <dt>Interventions</dt><dd>${t.interventions.map(i => esc(i.name)).join('<br>') || 'Not stated'}</dd>      <dt>Countries</dt><dd>${esc(csTxt)}</dd>      <dt>Last registry update</dt><dd>${fmtD(t.lastUpdate)}${t.stale ? ', more than a year ago' : ''}</dd>      <dt>Registry record</dt><dd>${nct ? `<a href="https://clinicaltrials.gov/study/${t.id}" target="_blank" rel="noopener noreferrer">${t.id} on ClinicalTrials.gov</a>` : esc(t.id)}</dd>    </dl>    <section class="dr-sec"><h3 class="h">Primary endpoints</h3><ul class="eplist">${t.primary.length ? t.primary.map(p => `<li>${esc(p)}</li>`).join('') : '<li class="muted">Not listed in this record.</li>'}</ul></section>    <p class="compl">Summary of the public registry record. Dates are reported by the sponsor and can change.</p>`;  $('#scrim').classList.add('on'); const dr = $('#drawer'); dr.classList.add('on'); dr.scrollTop = 0;  setTimeout(() => $('.x', dr).focus(), 60);}function closeDrawer() {  if (!$('#drawer').classList.contains('on')) return;  $('#drawer').classList.remove('on'); $('#scrim').classList.remove('on'); S.selected = null;  if (lastFocus && lastFocus.focus) lastFocus.focus();}/* =========================================================   SVG helpers & tooltip   ========================================================= */const tipEl = () => $('#tip');function showTip(html, x, y) {  const t = tipEl(); t.innerHTML = html; t.classList.add('on');  const w = t.offsetWidth, h = t.offsetHeight;  let lx = x + 16, ly = y + 16;  if (lx + w > innerWidth - 8) lx = x - w - 16; if (ly + h > innerHeight - 8) ly = y - h - 16;  t.style.left = Math.max(8, lx) + 'px'; t.style.top = Math.max(8, ly) + 'px';}function hideTip() { tipEl().classList.remove('on'); }document.addEventListener('pointermove', e => {  const el = e.target.closest && e.target.closest('[data-tip]');  if (el) showTip(el.getAttribute('data-tip'), e.clientX, e.clientY);  else if (!e.target.closest || !e.target.closest('#radar')) hideTip();});document.addEventListener('pointerdown', e => {  if (e.pointerType !== 'touch') return;  const el = e.target.closest && e.target.closest('[data-tip]');  if (el) showTip(el.getAttribute('data-tip'), e.clientX, e.clientY); else if (!e.target.closest('#radar')) hideTip();});const W = el => Math.max(280, Math.floor(el.clientWidth || 600));const legendHtml = keys => `<div class="legend">${keys.map(k => `<span class="lg"><i style="background:${PH[k].color}"></i>${PH[k].label}</span>`).join('')}</div>`;function niceMax(v) { if (v <= 5) return 5; const p = Math.pow(10, Math.floor(Math.log10(v))); for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p; return v; }function topRoundRect(x, y, w, h, r) { r = Math.min(r, w / 2, h); return `M${x},${y + h}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h}Z`; }function rightRoundRect(x, y, w, h, r) { r = Math.min(r, h / 2, w); return `M${x},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h - r}Q${x + w},${y + h} ${x + w - r},${y + h}H${x}Z`; }/* =========================================================   Landscape   ========================================================= */function renderLandscape() {  const list = visible(), scope = list.filter(inWin);  const n12 = upcoming(list, TODAY, 12), p3n12 = n12.filter(t => t.phase === 'P3');  $('#kpis').innerHTML = [    [scope.length, 'studies in scope, past year to 36 months ahead'],    [n12.length, 'reach primary completion in the next 12 months'],    [p3n12.length, 'Phase 3 readouts in the next 12 months'],    [list.filter(t => t.overdue).length, 'estimated dates already passed, still ongoing'],    [list.filter(t => t.stale).length, 'registry records not updated for over a year'],  ].map(([v, l]) => `<div class="kpi"><b>${fmtN(v)}</b><span>${l}</span></div>`).join('');  renderWave(list); renderSponsors(list); renderGeo(list);  $('#studyCount').textContent = fmtN(list.length);  if ($('#studies').open) renderTable();}function phasesPresent(list) { return PH_ORDER.filter(k => S.phases[k] && list.some(t => t.phase === k)); }function renderWave(list) {  const box = $('#waveChart'), w = W(box), h = 250, m = { l: 34, r: 6, t: 18, b: 30 };  const qk = d => d.getFullYear() * 4 + Math.floor(d.getMonth() / 3);  const q0 = qk(TODAY) - 4, NQ = 16, cols = Array.from({ length: NQ }, (_, i) => ({ q: q0 + i, c: { P3: 0, P2: 0, P1: 0, OT: 0 }, n: 0 }));  for (const t of list) { const i = qk(t.pcd) - q0; if (i >= 0 && i < NQ) { cols[i].c[t.phase]++; cols[i].n++; } }  const ph = phasesPresent(list), max = niceMax(Math.max(1, ...cols.map(c => c.n)));  const pw = w - m.l - m.r, ph_ = h - m.t - m.b, cw = pw / NQ, bw = Math.max(6, cw * 0.66);  const y = v => m.t + ph_ - v / max * ph_;  let s = '';  for (let i = 0; i <= 4; i++) { const v = max / 4 * i; s += `<line class="grid" x1="${m.l}" x2="${w - m.r}" y1="${y(v)}" y2="${y(v)}"/><text class="ax" x="${m.l - 8}" y="${y(v) + 4}" text-anchor="end">${fmtN(v)}</text>`; }  const cur = qk(TODAY) - q0;  cols.forEach((col, i) => {    const x = m.l + i * cw + (cw - bw) / 2; let base = 0; const past = i < cur;    const stack = ['P3', 'P2', 'P1', 'OT'].filter(k => ph.includes(k) && col.c[k]);    stack.forEach((k, j) => {      const v = col.c[k], y1 = y(base + v), y0 = y(base), hh = Math.max(0, y0 - y1 - (j ? 2 : 0));      const top = j === stack.length - 1;      s += top ? `<path d="${topRoundRect(x, y1, bw, hh, 4)}" fill="${PH[k].color}" opacity="${past ? .42 : 1}"/>` : `<rect x="${x}" y="${y1}" width="${bw}" height="${hh}" fill="${PH[k].color}" opacity="${past ? .42 : 1}"/>`;      base += v;    });    const yr = Math.floor(col.q / 4), lbl = `Q${col.q % 4 + 1}`;    const showY = col.q % 4 === 0 || (i === 0 && (w > 520 || col.q % 4 < 2));    if (w > 520 || i % 2 === 0) s += `<text class="ax" x="${m.l + i * cw + cw / 2}" y="${h - m.b + 15}" text-anchor="middle" ${i === cur ? 'style="fill:#e8eef5;font-weight:600"' : ''}>${lbl}</text>`;    if (showY) s += `<text class="ax" x="${m.l + i * cw + cw / 2}" y="${h - m.b + 28}" text-anchor="middle">${yr}</text>`;    const tip = `<b>Q${col.q % 4 + 1} ${yr}${i === cur ? ' (this quarter)' : ''}</b>${ph.map(k => `<div class="row"><span class="k">${PH[k].label}</span><span>${col.c[k]}</span></div>`).join('')}<div class="row"><span class="k">Total</span><span>${col.n}</span></div>`;    s += `<rect x="${m.l + i * cw}" y="${m.t}" width="${cw}" height="${ph_}" fill="transparent" data-tip="${esc(tip)}"/>`;  });  const fx = m.l + cur * cw + cw * ((TODAY.getMonth() % 3) * 30.4 + TODAY.getDate()) / 91.3;  s += `<line x1="${fx}" x2="${fx}" y1="${m.t - 6}" y2="${m.t + ph_}" stroke="#bfe0ff" stroke-dasharray="3 3" stroke-width="1.2"/><text class="ax" x="${fx + 4}" y="${m.t - 6}" style="fill:#bfe0ff">Today</text>`;  box.innerHTML = `<h3 class="h">When the readouts land</h3><p class="sub">Trials reaching primary completion per quarter. Faded bars are quarters already behind us.</p>${legendHtml(ph)}<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Stacked column chart of trials reaching primary completion per quarter, by phase">${s}</svg>`;}function renderSponsors(list) {  const box = $('#sponsorChart'), w = W(box), up = upcoming(list);  const m = new Map(); for (const t of up) { if (!m.has(t.sponsor)) m.set(t.sponsor, { n: 0, c: { P3: 0, P2: 0, P1: 0, OT: 0 }, next: t.pcd }); const o = m.get(t.sponsor); o.n++; o.c[t.phase]++; if (t.pcd < o.next) o.next = t.pcd; }  const rows = [...m.entries()].sort((a, b) => b[1].n - a[1].n || b[1].c.P3 - a[1].c.P3).slice(0, 10);  const ph = phasesPresent(up), lw = w < 480 ? 118 : 178, rh = 26, h = rows.length * rh + 8, vw = 34;  const max = Math.max(1, ...rows.map(r => r[1].n)), pw = w - lw - vw;  let s = '';  rows.forEach(([name, o], i) => {    const y = 4 + i * rh, bh = 14; let x = lw;    const nm = name.length > (w < 480 ? 16 : 26) ? name.slice(0, (w < 480 ? 15 : 25)) + '…' : name;    s += `<text class="ax-l" x="${lw - 10}" y="${y + bh / 2 + 4}" text-anchor="end">${esc(nm)}</text>`;    const stack = ['P3', 'P2', 'P1', 'OT'].filter(k => ph.includes(k) && o.c[k]);    stack.forEach((k, j) => {      const bw = o.c[k] / max * pw - (j < stack.length - 1 ? 2 : 0);      s += j === stack.length - 1 ? `<path d="${rightRoundRect(x, y, Math.max(1, bw), bh, 4)}" fill="${PH[k].color}"/>` : `<rect x="${x}" y="${y}" width="${Math.max(1, bw)}" height="${bh}" fill="${PH[k].color}"/>`;      x += bw + 2;    });    s += `<text class="ax-l" x="${x + 6}" y="${y + bh / 2 + 4}">${o.n}</text>`;    const tip = `<b>${esc(name)}</b>${ph.filter(k => o.c[k]).map(k => `<div class="row"><span class="k">${PH[k].label}</span><span>${o.c[k]}</span></div>`).join('')}<div class="row"><span class="k">Next readout</span><span>${fmtD(o.next)}</span></div>`;    s += `<rect x="0" y="${y - 6}" width="${w}" height="${rh}" fill="transparent" data-tip="${esc(tip)}"/>`;  });  box.innerHTML = `<h3 class="h">Who reads out next</h3><p class="sub">Sponsors with the most trials reaching primary completion in the next 36 months.</p>${legendHtml(ph)}${rows.length ? `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Bar chart of upcoming readouts by sponsor">${s}</svg>` : '<p class="empty">No upcoming readouts.</p>'}`;}function renderGeo(list) {  const box = $('#geoBlock'), w = W(box);  const up = upcoming(list).filter(t => t.phase === 'P3' || t.phase === 'P2');  const withC = up.filter(t => t.countries.length);  if (withC.length < Math.max(3, up.length * 0.4)) { box.innerHTML = `<h3 class="h">Where trials enroll</h3><p class="sub">This dataset has too little location data to map. Live scans and full JSON exports include countries.</p>`; return; }  const m = new Map(); for (const t of withC) for (const c of t.countries) m.set(c, (m.get(c) || 0) + 1);  const rows = [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);  const p3 = withC.filter(t => t.phase === 'P3'), p3eu = p3.filter(t => t.eu);  const lw = w < 480 ? 118 : 160, rh = 24, h = rows.length * rh + 6, max = rows[0][1], pw = w - lw - 40;  let s = '';  rows.forEach(([c, n], i) => {    const y = 3 + i * rh, bw = Math.max(2, n / max * pw), isEU = EU.has(c);    s += `<text class="ax-l" x="${lw - 10}" y="${y + 12}" text-anchor="end">${esc(c.length > 20 ? c.slice(0, 19) + '…' : c)}</text>`;    s += `<path d="${rightRoundRect(lw, y + 3, bw, 12, 4)}" fill="${isEU ? '#8fc4f5' : '#3d6488'}"/><text class="ax-l" x="${lw + bw + 6}" y="${y + 13}">${n}</text>`;    s += `<rect x="0" y="${y}" width="${w}" height="${rh}" fill="transparent" data-tip="${esc(`<b>${esc(c)}</b><div>${n} upcoming Phase 2 or 3 trials list a site here${isEU ? '. EU member state.' : '.'}</div>`)}"/>`;  });  box.innerHTML = `<h3 class="h">Where late-stage trials enroll</h3><p class="sub">Countries listed by upcoming Phase 2 and 3 trials. ${p3.length ? `${p3eu.length} of ${p3.length} Phase 3 trials (${pct(p3eu.length, p3.length)}%) include an EU site.` : ''}</p><div class="legend"><span class="lg"><i style="background:#8fc4f5"></i>EU member state</span><span class="lg"><i style="background:#3d6488"></i>Other country</span></div><svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Bar chart of countries by number of upcoming trials">${s}</svg>`;}const COLS = [  { k: 'pcd', l: 'Primary completion', v: t => t.pcd },  { k: 'phase', l: 'Phase', v: t => PH_ORDER.indexOf(t.phase) },  { k: 'name', l: 'Study', v: t => t.name.toLowerCase() },  { k: 'sponsor', l: 'Sponsor', v: t => t.sponsor.toLowerCase() },  { k: 'enr', l: 'Enrollment', v: t => t.enrollment || 0 },  { k: 'status', l: 'Status', v: t => t.status },  { k: 'flags', l: 'Flags', v: t => (t.overdue ? 2 : 0) + (t.stale ? 1 : 0) },];function tableRows() {  const q = S.table.q.trim().toLowerCase();  let rows = visible().filter(t => !q || (t.title + ' ' + t.acronym + ' ' + t.sponsor + ' ' + t.id + ' ' + t.interventions.map(i => i.name).join(' ')).toLowerCase().includes(q));  const c = COLS.find(c => c.k === S.table.sort);  rows.sort((a, b) => { const x = c.v(a), y = c.v(b); return (x > y ? 1 : x < y ? -1 : 0) * S.table.dir; });  return rows;}function renderTable() {  const rows = tableRows(), show = rows.slice(0, S.table.limit);  $('#studyTable').innerHTML = `<thead><tr>${COLS.map(c => `<th scope="col" ${c.v ? `aria-sort="${S.table.sort === c.k ? (S.table.dir > 0 ? 'ascending' : 'descending') : 'none'}"` : ''}>${c.v ? `<button data-sort="${c.k}">${c.l}${S.table.sort === c.k ? (S.table.dir > 0 ? ' ↑' : ' ↓') : ''}</button>` : c.l}</th>`).join('')}</tr></thead>  <tbody>${show.map(t => `<tr data-id="${esc(t.id)}" tabindex="0"><td class="c-date">${fmtD(t.pcd)}<div class="muted">${t.pcdType === 'ACTUAL' ? 'actual' : 'estimate'}</div></td><td class="c-phase"><span class="ph"><i style="background:${PH[t.phase].color}"></i>${esc(t.phaseLabel)}</span></td><td class="c-study"><div class="ttl"><b>${esc(t.acronym || '')}</b>${t.acronym ? ' ' : ''}${esc(t.title)}</div><div class="muted">${esc(t.id)}</div></td><td class="c-sponsor">${esc(t.sponsor)}</td><td class="c-enr">${t.enrollment ? `${fmtN(t.enrollment)}<span class="m-only"> participants</span>` : ''}</td><td class="c-status">${esc(STATUS_L[t.status] || t.status || '')}</td><td class="c-flags">${flagHtml(t)}</td></tr>`).join('')}</tbody>`;  $('#tableMore').innerHTML = rows.length > show.length ? `<div style="margin-top:12px"><button class="btn small" id="moreRows">Show all ${fmtN(rows.length)} studies</button></div>` : `<p class="note" style="margin-top:10px">${plural(rows.length, 'study', 'studies')}.</p>`;}function csvText() {  const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;  const head = ['ID', 'Acronym', 'Title', 'Phase', 'Sponsor', 'Sponsor type', 'Status', 'Primary completion', 'Date type', 'Enrollment', 'Overdue estimate', 'Stale record', 'Primary endpoints', 'Countries'];  return [head.map(q).join(',')].concat(tableRows().map(t => [t.id, t.acronym, t.title, t.phaseLabel, t.sponsor, t.sclass, STATUS_L[t.status] || t.status, iso(t.pcd), t.pcdType.toLowerCase(), t.enrollment || '', t.overdue ? 'yes' : '', t.stale ? 'yes' : '', t.primary.join(' | '), t.countries.join(' | ')].map(q).join(','))).join('\n');}/* =========================================================   Source line, busy state, modal, toast, clipboard, download   ========================================================= */function renderSource() {  const s = S.src;  $('#source').innerHTML = s.kind === 'live'    ? `<span class="badge live">ClinicalTrials.gov</span><span><strong>${fmtN(S.all.length)}</strong> interventional studies for <strong>${esc(s.label)}</strong>, retrieved ${s.at.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}${s.total > s.fetched ? `. First ${fmtN(s.fetched)} of ${fmtN(s.total)} matches` : ''}.</span>`    : EMBEDDED ? '<span>This preview cannot reach ClinicalTrials.gov. Search runs when T−minus is hosted on your own site.</span>'    : '<span>Searches the public ClinicalTrials.gov registry. Nothing about you is stored or tracked.</span>';}function busy(on, msg) {  $('#progress').classList.toggle('on', on); $('#scanBtn').disabled = on;  if (on) $('#source').innerHTML = `<span>${esc(msg)}</span>`; else renderSource();}function modal(html) { $('#modalCard').innerHTML = html; $('#modal').classList.add('on'); setTimeout(() => { const f = $('#modalCard [data-focus]') || $('#modalCard button, #modalCard a'); f && f.focus(); }, 30); }function closeModal() { $('#modal').classList.remove('on'); }function modalBlocked(cond, intr, e) {  const web = `https://clinicaltrials.gov/search?cond=${encodeURIComponent(cond)}${intr ? `&intr=${encodeURIComponent(intr)}` : ''}`;  const why = EMBEDDED ? 'This preview blocks connections to other websites, the same rule that guarantees the page cannot track you. Search works when T−minus runs on your own site with the included relay.'    : e && e.kind === 'http' ? `ClinicalTrials.gov answered with an error (${e.status}). It may be busy; try again in a minute.`    : 'Your connection or ClinicalTrials.gov did not respond. Check you are online and try again.';  modal(`<h2 class="h" id="mTitle">Search could not reach ClinicalTrials.gov</h2><p class="sub">${esc(why)}</p><p class="sub">Meanwhile you can <a href="${esc(web)}" target="_blank" rel="noopener noreferrer">open “${esc(cond)}” on ClinicalTrials.gov</a>.</p><div class="modal-actions"><button class="btn primary" data-close data-focus>Close</button></div>`);}function modalEmpty(cond, intr) { modal(`<h2 class="h" id="mTitle">No matching interventional studies</h2><p class="sub">ClinicalTrials.gov returned nothing for “${esc(cond)}”${intr ? ` with “${esc(intr)}”` : ''} in the past year to five years ahead. Try a broader condition term, or drop the drug filter.</p><div class="modal-actions"><button class="btn primary" data-close>Close</button></div>`); }let toastT = 0;function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('on'), 2200); }async function copy(text, what) {  try { await navigator.clipboard.writeText(text); toast(`Copied ${what}`); }  catch (e) { textModal(`Copy ${what}`, 'Copying is blocked here. Select all the text below and copy it.', text); }}function textModal(title, note, text) {  modal(`<h2 class="h" id="mTitle">${esc(title)}</h2><p class="sub">${esc(note)}</p><textarea readonly data-focus>${esc(text)}</textarea><div class="modal-actions"><button class="btn primary" data-close>Done</button></div>`);  setTimeout(() => { const ta = $('#modalCard textarea'); ta && ta.select(); }, 40);}let dlNS;async function hostDownloads() {  if (dlNS !== undefined) return dlNS;  try { dlNS = window.claude && typeof window.claude.use === 'function' ? await window.claude.use('downloads') : null; } catch (e) { dlNS = null; }  return dlNS;}async function download(name, text, mime, what) {  if (EMBEDDED) {    const ext = name.split('.').pop().toLowerCase();    const dl = ['csv', 'txt', 'md', 'json'].includes(ext) ? await hostDownloads() : null;    if (dl) {      try { await dl.save({ filename: name, data: text }); toast(`Saved ${name}`); return; }      catch (e) { if (e && e.code === 'declined') { toast('Download cancelled'); return; } }    }    textModal(`Your ${what}`, ext === 'ics' ? 'Calendar files cannot be downloaded inside this preview. Copy the text below into a file ending in .ics, or run T−minus on your own site to download it directly.' : 'Downloads are not available in this view. Copy the text below instead.', text); return;  }  const url = URL.createObjectURL(new Blob([text], { type: mime }));  const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();  setTimeout(() => URL.revokeObjectURL(url), 2000); toast(`Downloaded ${name}`);}/* ---------- Icons ---------- */const ICON = {  x: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>',  alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3 2 20h20z"/><path d="M12 10v4M12 17.5v.01"/></svg>',  clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',  check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 4.5 4.5L19 7"/></svg>',  star: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 2.8 2.8 5.9 6.4.8-4.7 4.4 1.2 6.4L12 17.2l-5.7 3.1 1.2-6.4-4.7-4.4 6.4-.8z"/></svg>',};/* =========================================================   Controls, routing, events   ========================================================= */function syncControls() {  $('#phaseLegend').innerHTML = PH_ORDER.map(k => `<button class="lg" data-ph="${k}" aria-pressed="${S.phases[k]}"><i style="background:${PH[k].color}"></i>${PH[k].label} <span class="muted">${S.all.filter(t => t.phase === k).length}</span></button>`).join('');  $$('#sectorSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.s === S.sector));  $('#industryOnly').checked = S.industryOnly;}function filtersChanged() { bump(); radar.relayout(); renderRadarSide(); syncControls(); renderLandscape(); }function bind() {  $('#scanForm').addEventListener('submit', e => { e.preventDefault(); scan($('#qCond').value, $('#qIntr').value); });  $('#studies').addEventListener('toggle', () => { if ($('#studies').open) renderTable(); });  $('#presentBtn').addEventListener('click', async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await $('#radarWrap').requestFullscreen(); } catch (e) { toast('Full screen is not available in this view'); } });  document.addEventListener('fullscreenchange', () => { const fs = document.fullscreenElement; (fs ? $('#radarWrap') : document.body).appendChild($('#tip')); $('#presentBtn').lastChild.textContent = fs ? 'Exit' : 'Present'; });  $('#scanToggle').addEventListener('click', () => { const sc = $('.scan'); const open = sc.classList.toggle('open'); $('#scanToggle').setAttribute('aria-expanded', open); $('#scanToggle').textContent = open ? 'Hide search' : 'New search'; if (open) $('#qCond').focus(); });  $('#sectorSeg').addEventListener('click', e => { const b = e.target.closest('button'); if (!b) return; S.sector = b.dataset.s; syncControls(); radar.relayout(); });  $('#phaseLegend').addEventListener('click', e => { const b = e.target.closest('[data-ph]'); if (!b) return; S.phases[b.dataset.ph] = !S.phases[b.dataset.ph]; if (!Object.values(S.phases).some(Boolean)) S.phases[b.dataset.ph] = true; filtersChanged(); });  $('#industryOnly').addEventListener('change', e => { S.industryOnly = e.target.checked; filtersChanged(); });  let ft; $('#textFilter').addEventListener('input', e => { clearTimeout(ft); ft = setTimeout(() => { S.text = e.target.value; filtersChanged(); }, 160); });  $('#tm').addEventListener('input', e => { stopPlayQuiet(); setViewDays(+e.target.value); updateTimeUI(); });  $('#playBtn').addEventListener('click', () => S.playing ? stopPlay() : play());  $('#tmReset').addEventListener('click', () => { stopPlay(); setViewDays(0, true); updateTimeUI(); });  $('#nextList').addEventListener('click', e => { const b = e.target.closest('[data-id]'); if (b) openTrial(b.dataset.id); });  $('#nextList').addEventListener('pointerover', e => { const b = e.target.closest('[data-id]'); S.hl = b ? b.dataset.id : null; });  $('#nextList').addEventListener('pointerleave', () => S.hl = null);  $('#scrim').addEventListener('click', closeDrawer);  document.addEventListener('click', e => {    const t = e.target.closest('[data-close],[data-sort],#moreRows');    if (!t) { const tr = e.target.closest('#studyTable tbody tr'); if (tr) openTrial(tr.dataset.id); return; }    if (t.hasAttribute('data-close')) { closeModal(); closeDrawer(); }    else if (t.dataset.sort) { const k = t.dataset.sort; S.table.dir = S.table.sort === k ? -S.table.dir : 1; S.table.sort = k; renderTable(); }    else if (t.id === 'moreRows') { S.table.limit = 1e6; renderTable(); }  });  $('#studyTable').addEventListener('keydown', e => { const tr = e.target.closest('tbody tr'); if (tr && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openTrial(tr.dataset.id); } });  let tf; $('#tableFilter').addEventListener('input', e => { clearTimeout(tf); tf = setTimeout(() => { S.table.q = e.target.value; S.table.limit = 25; renderTable(); }, 150); });  $('#csvCopy').addEventListener('click', () => copy(csvText(), 'table as CSV'));  $('#csvDl').addEventListener('click', () => download('t-minus-studies.csv', csvText(), 'text/csv', 'CSV'));  $('#modal').addEventListener('click', e => { if (e.target.id === 'modal') closeModal(); });  document.addEventListener('keydown', e => {    if (e.key === 'Escape') { closeModal(); closeDrawer(); hideTip(); }    const typing = /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.activeElement.isContentEditable;    if (!typing && e.key === '/' ) { e.preventDefault(); $('#qCond').focus(); }    if (!typing && e.key === ' ' && S.all.length && document.activeElement === document.body) { e.preventDefault(); S.playing ? stopPlay() : play(); }  });  // bottom sheet: swipe down to close on touch screens  let sy = null; const dr = $('#drawer');  dr.addEventListener('touchstart', e => { sy = dr.scrollTop <= 0 ? e.touches[0].clientY : null; }, { passive: true });  dr.addEventListener('touchmove', e => { if (sy == null) return; const d = e.touches[0].clientY - sy; if (d > 0) { dr.style.transition = 'none'; dr.style.transform = `translateY(${d}px)`; } }, { passive: true });  dr.addEventListener('touchend', e => { if (sy == null) return; const d = e.changedTouches[0].clientY - sy; dr.style.transition = ''; dr.style.transform = ''; sy = null; if (d > 90) closeDrawer(); });  let rt, lastW = innerWidth; window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { if (innerWidth !== lastW) { lastW = innerWidth; renderLandscape(); } }, 180); });}function stopPlayQuiet() { if (S.playing) stopPlay(); }/* ---------- Boot ---------- *//* ---------- Install as an app (PWA) ---------- */function pwa() {  if (EMBEDDED || !('serviceWorker' in navigator) || !window.isSecureContext) return;  navigator.serviceWorker.register('sw.js', { scope: './' }).catch(() => {});  let prompt = null; const b = $('#installBtn');  window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); prompt = e; b.hidden = false; });  b.addEventListener('click', async () => { if (!prompt) return; prompt.prompt(); try { await prompt.userChoice; } catch (e) {} prompt = null; b.hidden = true; });  window.addEventListener('appinstalled', () => { b.hidden = true; toast('T\u2212minus installed'); });}function boot() {  bind(); pwa();  syncControls(); renderSource();  radar.init(); radar.relayout(true);  renderRadarSide();}if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();})();
source/src/style.css326 lines
/* ---------- Tokens ---------- */:root{  color-scheme: dark;  --page:#0b1724; --surface:#0f1d2c; --panel:#112235; --raise:#16293e;  --line:#1e3348; --line-2:#2b4560;  --text:#e8eef5; --text-2:#a9b8c8; --muted:#7f91a5;  --ice:#bfe0ff; --ice-2:#8fc4f5; --action:#d6eaff; --action-ink:#0b1724;  --p3:#c98216; --p2:#17a08a; --p1:#8f84ec; --pot:#6c7c90;  --critical:#e5615c; --serious:#e08a4a;  --serif:"Newsreader","Iowan Old Style","Palatino Linotype",Georgia,serif;  --sans:"Atkinson Hyperlegible Next",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;  --r-s:6px; --r-m:10px; --r-l:18px;  --pad: clamp(16px, 3vw, 36px);}*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--page);color:var(--text);font-family:var(--sans);font-size:15px;line-height:1.5;font-variant-numeric:tabular-nums;min-height:100vh}a{color:var(--ice)}button,input,select{font:inherit;color:inherit}button,a,input,summary,canvas{touch-action:manipulation}*{-webkit-tap-highlight-color:transparent}body:not(.has-data) .needs-data{display:none!important}.m-only{display:none}:focus-visible{outline:2px solid var(--ice);outline-offset:2px;border-radius:4px}.sr{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.num{font-variant-numeric:tabular-nums}/* ---------- Header ---------- */.top{display:flex;align-items:center;gap:20px;padding:14px var(--pad);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:30;background:rgba(11,23,36,.92);backdrop-filter:blur(10px)}.mark{display:flex;align-items:baseline;gap:12px;text-decoration:none;color:var(--text);flex:none}.mark b{font-family:var(--serif);font-style:italic;font-weight:500;font-size:27px;letter-spacing:-.01em;line-height:1}.mark span{color:var(--muted);font-size:13px}.shield{margin-left:auto;display:flex;align-items:center;gap:8px;border:1px solid var(--line-2);border-radius:999px;padding:6px 12px 6px 9px;color:var(--text-2);text-decoration:none;font-size:13px;white-space:nowrap;flex:none}.shield:hover{color:var(--text);border-color:var(--ice-2)}.install{flex:none}.install[hidden]{display:none}.shield i{width:8px;height:8px;border-radius:50%;background:var(--p2);box-shadow:0 0 0 3px rgba(23,160,138,.22)}/* ---------- Scan bar ---------- */.scan{padding:14px var(--pad) 4px;display:grid;gap:10px}.scan-meta{display:flex;flex-wrap:wrap;gap:8px 24px;align-items:center;justify-content:space-between}.btn.scan-toggle{display:none}[hidden]{display:none!important}.scan form{display:grid;grid-template-columns:minmax(180px,1.3fr) minmax(160px,1fr) auto;gap:8px}.scan form .btn.primary{min-width:120px}.field{position:relative}.field input{width:100%;background:var(--surface);border:1px solid var(--line-2);border-radius:var(--r-m);padding:9px 12px;outline:none}.field input::placeholder{color:var(--muted)}.field input:focus{border-color:var(--ice-2)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:var(--r-m);padding:10px 16px;border:1px solid var(--line-2);background:var(--panel);cursor:pointer;white-space:nowrap;text-decoration:none;color:var(--text);font-size:14px}.btn:hover{border-color:var(--ice-2)}.btn.primary{background:var(--action);color:var(--action-ink);border-color:var(--action);font-weight:600}.btn.primary:hover{background:#fff}.btn.ghost{background:transparent}.btn.small{padding:6px 11px;font-size:13px;border-radius:8px}.btn svg{width:16px;height:16px;flex:none}.btn[disabled]{opacity:.5;cursor:progress}.source{font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center}.source strong{color:var(--text-2);font-weight:600}.badge{display:inline-flex;align-items:center;gap:6px;border-radius:6px;padding:2px 8px;font-size:12px;border:1px solid var(--line-2);color:var(--text-2)}.badge.synthetic{border-color:#5d4a26;color:#e8c48a}.badge.live{border-color:#1e5a52;color:#7fd8c9}.progress{height:2px;background:var(--line);border-radius:2px;overflow:hidden;display:none}.progress.on{display:block}.progress i{display:block;height:100%;width:30%;background:var(--ice);animation:slide 1.1s ease-in-out infinite}@keyframes slide{0%{transform:translateX(-100%)}100%{transform:translateX(340%)}}/* ---------- Views ---------- */main{padding:6px var(--pad) 60px}.lede{font-family:var(--serif);font-weight:400;font-size:clamp(25px,3.3vw,42px);line-height:1.12;letter-spacing:-.012em;margin:14px 0 18px;max-width:30ch;text-wrap:balance;font-variation-settings:"opsz" 60}.lede .q{color:var(--text-2)}h2.h{font-family:var(--serif);font-weight:500;font-size:25px;line-height:1.2;margin:0 0 4px;letter-spacing:-.005em}h2.h.big{font-size:clamp(26px,2.6vw,34px);letter-spacing:-.012em}h3.h{font-family:var(--serif);font-weight:500;font-size:20px;line-height:1.25;margin:0 0 4px}.sub{color:var(--text-2);margin:0 0 14px;max-width:72ch}.muted{color:var(--muted)}/* ---------- Radar layout ---------- */.radar-grid{display:grid;grid-template-columns:minmax(0,1fr) 380px;grid-template-areas:"radar lede" "radar next";grid-template-rows:auto 1fr;gap:6px 36px;align-items:start;margin-top:12px}.radar-col{grid-area:radar;display:flex;flex-direction:column;min-width:0}.side-lede{grid-area:lede;font-size:clamp(24px,2.2vw,31px);margin:4px 0 14px;max-width:none}.next{grid-area:next}.side-lede{scroll-margin-top:16px}.foot{margin-top:56px;padding-top:18px;border-top:1px solid var(--line);font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:8px 24px;justify-content:space-between}.foot span{max-width:80ch}.foot strong{color:var(--text-2);font-weight:600}.present{position:absolute;top:10px;right:10px;background:rgba(15,29,44,.8)}.radar-wrap:fullscreen{border-radius:0;max-height:none;height:100vh;width:100vw;aspect-ratio:auto;background:radial-gradient(closest-side,#132b43 0%,var(--surface) 70%,var(--page) 100%)}.radar-wrap:fullscreen canvas{border-radius:0}.controls{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center;margin-bottom:10px;font-size:13px;color:var(--text-2)}.seg{display:inline-flex;border:1px solid var(--line-2);border-radius:999px;padding:2px}.seg button{border:0;background:transparent;border-radius:999px;padding:5px 11px;cursor:pointer;color:var(--text-2);font-size:13px;white-space:nowrap}.seg button[aria-pressed="true"]{background:var(--raise);color:var(--text);box-shadow:inset 0 0 0 1px var(--line-2)}.legend{display:flex;flex-wrap:wrap;gap:4px}.lg{display:inline-flex;align-items:center;gap:7px;border:1px solid transparent;background:transparent;border-radius:999px;padding:4px 10px 4px 8px;cursor:pointer;color:var(--text-2);font-size:13px}.lg:hover{border-color:var(--line-2)}.lg i{width:10px;height:10px;border-radius:50%;flex:none}.lg[aria-pressed="false"]{opacity:.42}.lg[aria-pressed="false"] i{background:transparent!important;box-shadow:inset 0 0 0 1.5px var(--muted)}.switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none}.switch input{appearance:none;width:30px;height:18px;border-radius:999px;background:var(--line-2);position:relative;cursor:pointer;margin:0;transition:background .15s}.switch input::after{content:"";position:absolute;top:3px;left:3px;width:12px;height:12px;border-radius:50%;background:var(--text);transition:transform .15s}.switch input:checked{background:var(--p2)}.switch input:checked::after{transform:translateX(12px)}.filter{background:var(--surface);border:1px solid var(--line-2);border-radius:999px;padding:5px 12px;width:180px;outline:none;font-size:13px}.filter:focus{border-color:var(--ice-2)}.radar-wrap{position:relative;background:radial-gradient(closest-side,#132b43 0%,var(--surface) 70%,var(--page) 100%);border-radius:var(--r-l);border:1px solid var(--line);height:clamp(440px,calc(100vh - 262px),900px);margin:0 auto;width:100%;touch-action:manipulation}.radar-wrap canvas{position:absolute;inset:0;display:block;width:100%;height:100%;cursor:crosshair;border-radius:var(--r-l)}.radar-wrap .corner{position:absolute;font-size:12px;color:var(--muted);pointer-events:none}.corner.tl{top:12px;left:14px}.corner.br{bottom:12px;right:14px;text-align:right}.tip{position:fixed;z-index:60;pointer-events:none;background:#081320;border:1px solid var(--line-2);border-radius:var(--r-m);padding:10px 12px;font-size:13px;max-width:300px;box-shadow:0 12px 32px rgba(0,0,0,.45);opacity:0;transform:translateY(4px);transition:opacity .12s,transform .12s}.tip.on{opacity:1;transform:none}.tip b{display:block;font-weight:600;color:var(--text);margin-bottom:2px}.tip .k{color:var(--muted)}.tip .row{display:flex;justify-content:space-between;gap:14px}/* time machine */.timebar{display:grid;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;margin-top:16px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-l);padding:12px 16px}.play{width:42px;height:42px;border-radius:50%;border:0;background:var(--action);color:var(--action-ink);cursor:pointer;display:grid;place-items:center}.play svg{width:16px;height:16px}.tm-mid{display:grid;gap:4px;min-width:0}.tm-label{display:flex;justify-content:space-between;gap:12px;font-size:13px;color:var(--text-2);flex-wrap:wrap}.tm-label strong{color:var(--text);font-weight:600}input[type=range]{width:100%;appearance:none;background:transparent;height:24px;margin:0;cursor:pointer}input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:4px;background:linear-gradient(90deg,var(--ice-2) var(--fill,0%),var(--line-2) var(--fill,0%))}input[type=range]::-moz-range-track{height:4px;border-radius:4px;background:var(--line-2)}input[type=range]::-moz-range-progress{height:4px;border-radius:4px;background:var(--ice-2)}input[type=range]::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:var(--text);margin-top:-7px;box-shadow:0 0 0 4px rgba(191,224,255,.18)}input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:var(--text);border:0}.tm-count{text-align:right;font-size:13px;color:var(--text-2);min-width:150px}.tm-count b{display:block;font-family:var(--serif);font-size:28px;font-weight:500;color:var(--text);line-height:1}.ticker{display:flex;flex-direction:column;gap:6px;margin-top:10px;min-height:0}.tick{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--text-2);animation:tickin .45s ease-out}.tick i{width:8px;height:8px;border-radius:50%;flex:none}.tick time{color:var(--muted);min-width:78px}@keyframes tickin{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}/* next list */.next{display:grid;gap:4px}.next h2{margin-bottom:6px}.nx{display:grid;grid-template-columns:86px 1fr;gap:12px;align-items:start;padding:12px 10px;border-radius:var(--r-m);cursor:pointer;border:1px solid transparent;background:transparent;text-align:left;width:100%}.nx:hover{background:var(--panel);border-color:var(--line)}.nx .t{font-family:var(--serif);font-size:30px;line-height:.95;font-weight:500;letter-spacing:-.01em}.nx .t small{display:block;font-family:var(--sans);font-size:12px;color:var(--muted);letter-spacing:0;margin-top:4px;font-weight:400}.nx .n{font-weight:600;line-height:1.3}.nx .m{font-size:13px;color:var(--text-2);line-height:1.4;margin-top:2px}.ph{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)}.ph i{width:8px;height:8px;border-radius:50%}.flag{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--text-2);border:1px solid var(--line-2);border-radius:6px;padding:1px 7px}.flag.overdue{border-color:#6b2b2b;color:#f2a29e}.flag svg{width:12px;height:12px}.empty{color:var(--muted);font-size:14px;padding:12px 10px}/* ---------- Landscape ---------- */.kpis{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:8px 0 34px}.kpi{padding:18px 18px 16px;border-left:1px solid var(--line)}.kpi:first-child{border-left:0;padding-left:0}.kpi b{display:block;font-family:var(--serif);font-weight:400;font-size:40px;line-height:1;letter-spacing:-.02em}.kpi span{display:block;font-size:13px;color:var(--text-2);margin-top:8px;max-width:22ch}.land{margin-top:64px;padding-top:28px;border-top:1px solid var(--line)}.land > .chart{margin-bottom:40px}.cgrid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:36px 48px}.studies{margin-top:48px;border:1px solid var(--line);border-radius:var(--r-l);background:var(--surface)}.studies summary{list-style:none;display:flex;align-items:center;gap:12px;padding:18px 22px;cursor:pointer;border-radius:var(--r-l)}.studies summary::-webkit-details-marker{display:none}.studies summary:hover{background:var(--panel)}.studies summary .h{font-family:var(--serif);font-size:23px;font-weight:500}.studies summary .count{color:var(--text-2);font-size:14px;border:1px solid var(--line-2);border-radius:999px;padding:1px 10px}.studies .chev{width:20px;height:20px;margin-left:auto;color:var(--text-2);transition:transform .2s}.studies[open] .chev{transform:rotate(180deg)}.studies-in{padding:0 22px 22px}.studies-in .exports{margin-bottom:6px}.studies-in .note{margin:0 0 12px}.chart{min-width:0}.chart svg{display:block;width:100%;height:auto;overflow:visible}.chart .legend{margin:6px 0 4px}.chart .lg{cursor:default}.chart .lg:hover{border-color:transparent}.ax{fill:var(--muted);font-size:11px;font-family:var(--sans)}.ax-l{fill:var(--text-2);font-size:12px;font-family:var(--sans)}.grid{stroke:var(--line);stroke-width:1}.sect{margin-top:46px}.sect-head{display:flex;justify-content:space-between;align-items:end;gap:16px;flex-wrap:wrap;margin-bottom:12px}.tablewrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--r-m)}table{border-collapse:collapse;width:100%;font-size:13px}th{text-align:left;font-weight:600;color:var(--text-2);padding:10px 12px;border-bottom:1px solid var(--line);white-space:nowrap;background:var(--surface);position:sticky;top:0}th button{all:unset;cursor:pointer}th button:focus-visible{outline:2px solid var(--ice)}td{padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}tbody tr{cursor:pointer}tbody tr:hover,tbody tr:focus-visible{background:var(--panel);outline:none}td .ttl{max-width:46ch}/* ---------- Drawer ---------- */.scrim{position:fixed;inset:0;background:rgba(4,10,18,.55);z-index:40;opacity:0;pointer-events:none;transition:opacity .2s}.scrim.on{opacity:1;pointer-events:auto}.drawer{position:fixed;top:0;right:0;bottom:0;width:min(560px,100%);background:var(--panel);border-left:1px solid var(--line-2);z-index:50;transform:translateX(102%);transition:transform .28s cubic-bezier(.2,.8,.2,1);overflow-y:auto;overscroll-behavior:contain}.drawer.on{transform:none}.dr-in{padding:22px 26px 40px}.dr-top{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px}.x{all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;color:var(--text-2);border:1px solid var(--line-2)}.x:hover{color:var(--text);border-color:var(--ice-2)}.x:focus-visible{outline:2px solid var(--ice)}.dr-title{font-family:var(--serif);font-weight:500;font-size:28px;line-height:1.12;margin:2px 0 6px;letter-spacing:-.01em}.dr-sub{color:var(--text-2);font-size:14px;margin:0 0 16px}.dr-summary{font-size:16px;line-height:1.62;margin:0 0 22px;max-width:62ch}.countdown{display:flex;align-items:baseline;gap:10px;margin:8px 0 18px;padding:14px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.countdown b{font-family:var(--serif);font-size:56px;font-weight:400;line-height:.9;letter-spacing:-.02em}.countdown span{color:var(--text-2);font-size:14px}dl.facts{display:grid;grid-template-columns:150px 1fr;gap:8px 14px;margin:0 0 6px;font-size:14px}dl.facts dt{color:var(--muted)}dl.facts dd{margin:0}.dr-sec{margin-top:26px}.dr-sec h3{margin-bottom:8px}.eplist{list-style:none;margin:0;padding:0;font-size:14px}.eplist li{padding:8px 0;border-top:1px solid var(--line)}.compl{margin-top:22px;font-size:12.5px;color:var(--muted);border-top:1px solid var(--line);padding-top:12px}/* ---------- Modal, toast ---------- */.modal{position:fixed;inset:0;z-index:70;display:none;place-items:center;padding:20px;background:rgba(4,10,18,.66)}.modal.on{display:grid}.modal-card{background:var(--panel);border:1px solid var(--line-2);border-radius:var(--r-l);max-width:640px;width:100%;max-height:88vh;overflow:auto;padding:24px 26px}.modal-card ol{padding-left:20px;color:var(--text-2)}.modal-card li{margin:10px 0}.modal-card li b{color:var(--text)}.modal-card textarea{width:100%;min-height:260px;background:var(--page);border:1px solid var(--line-2);border-radius:var(--r-m);color:var(--text);padding:12px;font-size:12.5px;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px;flex-wrap:wrap}.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,20px);background:var(--action);color:var(--action-ink);padding:10px 16px;border-radius:999px;font-size:14px;font-weight:600;z-index:80;opacity:0;transition:opacity .2s,transform .2s;pointer-events:none}.toast.on{opacity:1;transform:translate(-50%,0)}/* ---------- Responsive ---------- */@media (max-width:1100px){  .radar-grid{grid-template-columns:1fr;grid-template-areas:"lede" "radar" "next";grid-template-rows:auto}  .side-lede{font-size:clamp(23px,3.6vw,32px);max-width:36ch}  .radar-wrap{height:auto;aspect-ratio:1/1;max-height:82vh}  .next{grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}  .next h2{grid-column:1/-1}  .cgrid{grid-template-columns:1fr}  .kpis{grid-template-columns:repeat(3,minmax(0,1fr))}  .kpi:nth-child(4){border-left:0;padding-left:0}  .kpi:nth-child(n+4){border-top:1px solid var(--line)}}@media (max-width:760px){  :root{--pad:16px}  body{font-size:16px}  .top{position:static;gap:10px;padding-top:12px;padding-bottom:12px;backdrop-filter:none}  .mark b{font-size:25px}  .mark span{display:none}  .shield{font-size:12px;padding:5px 10px 5px 8px}  .install:not([hidden])~.shield span,.shield:has(~.install:not([hidden])) span{display:none}  .scan{padding-top:12px;gap:8px}  .scan form{grid-template-columns:1fr;gap:8px}  .field input{font-size:16px;padding:12px 14px}  .scan form .btn.primary{min-height:48px;font-size:16px}  body.has-data .scan form{display:none}  body.has-data .scan.open form{display:grid}  body.has-data .btn.scan-toggle{display:inline-flex;flex:none;min-height:40px}  .scan-meta{flex-wrap:nowrap;align-items:center;gap:10px}  .source{font-size:13px}  .corner.br,.present{display:none}  .side-lede{font-size:23px;margin:6px 0 12px}  .radar-wrap{width:calc(100% + 2 * var(--pad));margin:0 calc(-1 * var(--pad));border-radius:0;border-left:0;border-right:0;max-height:none}  .radar-wrap canvas{border-radius:0}  .radar-col .controls{order:5;margin-top:14px}  .controls{gap:10px}  .seg{overflow-x:auto;max-width:100%;scrollbar-width:none}  .seg::-webkit-scrollbar{display:none}  .seg button{padding:10px 14px;font-size:14px}  .lg{padding:9px 12px;font-size:14px;border-color:var(--line)}  .switch{min-height:44px;font-size:14px}  .filter{width:100%;font-size:16px;padding:10px 14px}  .timebar{grid-template-columns:auto 1fr;padding:12px 14px;gap:12px;margin-top:12px}  .play{width:48px;height:48px}  input[type=range]{height:36px}  input[type=range]::-webkit-slider-thumb{width:24px;height:24px;margin-top:-10px}  .tm-count{grid-column:1/-1;text-align:left;display:flex;gap:10px;align-items:baseline}  .tm-count b{display:inline;font-size:24px}  .ticker .tick time{min-width:0}  .next{grid-template-columns:1fr;margin-top:18px}  .nx{grid-template-columns:72px 1fr;padding:12px 4px}  .land{margin-top:40px}  .kpis{grid-template-columns:repeat(2,minmax(0,1fr))}  .kpi{border-left:0!important;padding-left:0!important;border-top:1px solid var(--line)}  .kpi:nth-child(-n+2){border-top:0}  .kpi b{font-size:34px}  .studies{margin:36px calc(-1 * var(--pad)) 0;border-radius:0;border-left:0;border-right:0}  .studies summary{padding:16px var(--pad);min-height:56px;border-radius:0}  .studies-in{padding:0 var(--pad) 16px}  .studies-in .exports .filter{flex:1 1 100%}  .tablewrap{border:0;border-radius:0;overflow:visible}  #studyTable thead{display:none}  #studyTable,#studyTable tbody{display:block}  #studyTable tr{display:grid;grid-template-columns:auto 1fr;grid-template-areas:"study study" "sponsor sponsor" "phase date" "enr status" "flags flags";gap:4px 14px;padding:14px 0;border-bottom:1px solid var(--line)}  #studyTable td{display:block;padding:0;border:0;font-size:14px}  #studyTable .c-study{grid-area:study;font-size:15px}  #studyTable .c-study .muted{display:none}  #studyTable .c-sponsor{grid-area:sponsor;color:var(--text-2)}  #studyTable .c-phase{grid-area:phase}  #studyTable .c-date{grid-area:date;display:flex;gap:6px}  #studyTable .c-enr{grid-area:enr;color:var(--text-2)}  #studyTable .c-status{grid-area:status;color:var(--text-2)}  #studyTable .c-flags{grid-area:flags}  #studyTable .c-flags:empty,#studyTable .c-enr:empty{display:none}  .m-only{display:inline}  td .ttl{max-width:none}  .drawer{top:auto;left:0;width:100%;max-height:88vh;border-left:0;border-top:1px solid var(--line-2);border-radius:20px 20px 0 0;transform:translateY(102%)}  .drawer::before{content:"";display:block;width:40px;height:4px;border-radius:2px;background:var(--line-2);margin:10px auto 0}  .dr-in{padding:10px 18px calc(32px + env(safe-area-inset-bottom))}  .dr-title{font-size:25px}  .countdown b{font-size:46px}  .dr-summary{font-size:16px}  dl.facts{grid-template-columns:118px 1fr;font-size:15px}  .modal-card{padding:20px}  .toast{bottom:calc(20px + env(safe-area-inset-bottom))}}@media (prefers-reduced-motion:reduce){  *,*::before,*::after{animation-duration:.001ms!important;transition-duration:.001ms!important}}@media print{  .top,.scan,.timebar,.drawer,.scrim,.toast,.exports,.btn,.present{display:none!important}  body{background:#fff;color:#000}}
source/build.py44 lines
#!/usr/bin/env python3"""Assemble T-minus into single-file HTML builds.dist/index.html    self-host build with a hash-based Content Security Policydist/artifact.html preview build (the host applies its own policy)"""import base64, hashlib, pathlibROOT = pathlib.Path(__file__).parentsrc = ROOT / 'src'fonts = ROOT / 'fonts'def b64(p):    return base64.b64encode(p.read_bytes()).decode()font_css = f"""@font-face{{font-family:"Newsreader";font-style:normal;font-weight:200 800;font-display:swap;src:url(data:font/woff2;base64,{b64(fonts/'nr-n.woff2')}) format("woff2")}}@font-face{{font-family:"Newsreader";font-style:italic;font-weight:200 800;font-display:swap;src:url(data:font/woff2;base64,{b64(fonts/'nr-i.woff2')}) format("woff2")}}@font-face{{font-family:"Atkinson Hyperlegible Next";font-style:normal;font-weight:200 800;font-display:swap;src:url(data:font/woff2;base64,{b64(fonts/'at-n.woff2')}) format("woff2")}}"""html = (src / 'index.html').read_text()css = (src / 'style.css').read_text()js = (src / 'app.js').read_text()html = html.replace('/*FONTS*/', font_css).replace('/*CSS*/', css)script_body = '\n' + js + '\n'html = html.replace('<script>\n/*JS*/\n</script>', '<script>' + script_body + '</script>')assert script_body in htmldigest = base64.b64encode(hashlib.sha256(script_body.encode()).digest()).decode()csp = ("default-src 'none'; "       f"script-src 'sha256-{digest}'; "       "style-src 'unsafe-inline'; font-src data:; img-src 'self' data: blob:; "       "manifest-src 'self'; worker-src 'self'; "       "connect-src 'self' https://clinicaltrials.gov; "       "base-uri 'none'; form-action 'none'")selfhost = html.replace('<!--CSP-->', f'<meta http-equiv="Content-Security-Policy" content="{csp}">')preview = html.replace('<!--CSP-->\n', '')out = ROOT / 'dist'out.mkdir(exist_ok=True)(out / 'index.html').write_text(selfhost)(out / 'artifact.html').write_text(preview)(out / 'csp-hash.txt').write_text(f"sha256-{digest}\n")print('index.html', len(selfhost) // 1024, 'KB; artifact.html', len(preview) // 1024, 'KB; script hash', digest[:16] + '…')
ctgov.php81 lines
<?php/** * T−minus relay for the ClinicalTrials.gov API v2 * * Put this file next to index.html. T−minus calls it first; if it is missing, * the page falls back to calling ClinicalTrials.gov directly from the browser. * * Privacy: *  - Forwards only whitelisted query parameters to one fixed endpoint. *  - Sends no visitor IP, cookie, referrer or browser header upstream. *  - Writes nothing to disk and keeps no logs of its own. *    (Your web host may keep standard access logs; check your hosting settings.) */declare(strict_types=1);const UPSTREAM = 'https://clinicaltrials.gov/api/v2/studies';const ALLOWED  = ['format', 'countTotal', 'pageSize', 'pageToken', 'query.cond', 'query.intr',                  'query.term', 'filter.overallStatus', 'filter.advanced', 'fields', 'sort'];header('Content-Type: application/json; charset=utf-8');header('Cache-Control: no-store');header('X-Content-Type-Options: nosniff');header('Referrer-Policy: no-referrer');function fail(int $code, string $msg): void {    http_response_code($code);    echo json_encode(['error' => $msg]);    exit;}if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {    fail(405, 'Only GET is supported.');}// PHP rewrites dots in $_GET keys (query.cond -> query_cond), so parse the raw query string.$pairs = [];foreach (explode('&', $_SERVER['QUERY_STRING'] ?? '') as $chunk) {    if ($chunk === '') continue;    $kv = explode('=', $chunk, 2);    $key = urldecode($kv[0]);    $val = urldecode($kv[1] ?? '');    if (!in_array($key, ALLOWED, true)) continue;    if (strlen($val) > 2000) fail(400, 'Parameter too long.');    $pairs[] = rawurlencode($key) . '=' . rawurlencode($val);}if (!$pairs) fail(400, 'No search parameters.');$url = UPSTREAM . '?' . implode('&', $pairs);$body = false;$status = 0;if (function_exists('curl_init')) {    $ch = curl_init($url);    curl_setopt_array($ch, [        CURLOPT_RETURNTRANSFER => true,        CURLOPT_FOLLOWLOCATION => false,        CURLOPT_CONNECTTIMEOUT => 8,        CURLOPT_TIMEOUT        => 30,        CURLOPT_HTTPHEADER     => ['Accept: application/json'],        CURLOPT_USERAGENT      => 'T-minus readout radar (self-hosted relay)',    ]);    $body = curl_exec($ch);    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);    curl_close($ch);} elseif (ini_get('allow_url_fopen')) {    $ctx = stream_context_create(['http' => [        'method' => 'GET', 'timeout' => 30, 'ignore_errors' => true,        'header' => "Accept: application/json\r\nUser-Agent: T-minus readout radar (self-hosted relay)\r\n",    ]]);    $body = @file_get_contents($url, false, $ctx);    if (isset($http_response_header[0]) && preg_match('#\s(\d{3})\s#', $http_response_header[0], $m)) {        $status = (int) $m[1];    }} else {    fail(501, 'This server cannot make outbound requests (enable cURL or allow_url_fopen).');}if ($body === false || $status === 0) fail(502, 'ClinicalTrials.gov did not respond.');http_response_code($status >= 400 ? $status : 200);echo $body;
sw.js23 lines
/* T-minus service worker: keeps the app shell available offline.   Caches only the app's own files. Registry searches (ctgov.php, clinicaltrials.gov) always go to the   network and are never cached, so results are always fresh and nothing about a search is stored. */const VERSION = 't-minus-1.0.0';const SHELL = ['./', './index.html', './manifest.webmanifest', './icons/icon.svg', './icons/icon-192.png', './icons/icon-512.png', './icons/maskable-512.png', './icons/apple-touch-icon.png'];self.addEventListener('install', e => {  e.waitUntil(caches.open(VERSION).then(c => c.addAll(SHELL)).then(() => self.skipWaiting()));});self.addEventListener('activate', e => {  e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k.startsWith('t-minus-') && k !== VERSION).map(k => caches.delete(k)))).then(() => self.clients.claim()));});self.addEventListener('fetch', e => {  const url = new URL(e.request.url);  if (e.request.method !== 'GET' || url.origin !== location.origin || url.pathname.endsWith('ctgov.php')) return;  if (e.request.mode === 'navigate') {    // Network first so updates arrive; fall back to the cached shell offline.    e.respondWith(fetch(e.request).then(r => { const copy = r.clone(); caches.open(VERSION).then(c => c.put('./index.html', copy)); return r; }).catch(() => caches.match('./index.html')));    return;  }  e.respondWith(caches.match(e.request).then(hit => hit || fetch(e.request)));});
manifest.webmanifest22 lines
{  "id": "./",  "name": "T−minus: Readout radar for Medical Affairs",  "short_name": "T−minus",  "description": "See when every interventional trial in a therapeutic area reaches primary completion. No cookies, no tracking.",  "start_url": "./",  "scope": "./",  "display": "standalone",  "display_override": ["window-controls-overlay", "standalone"],  "orientation": "any",  "background_color": "#0b1724",  "theme_color": "#0b1724",  "lang": "en",  "categories": ["medical", "productivity", "business"],  "icons": [    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },    { "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },    { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }  ]}
.htaccess13 lines
# T-minus: hardening for Apache and LiteSpeed hosts (IONOS, Hostinger and most shared hosting)DirectoryIndex index.htmlAddType application/manifest+json .webmanifest<IfModule mod_headers.c>  Header always set Referrer-Policy "no-referrer"  Header always set X-Content-Type-Options "nosniff"  Header always set X-Frame-Options "SAMEORIGIN"  Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()"  <FilesMatch "^(sw\.js|index\.html|manifest\.webmanifest)$">    Header set Cache-Control "no-cache"  </FilesMatch></IfModule>
LICENSE25 lines
MIT LicenseCopyright (c) 2026 Aitor Martínez RosellPermission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.Fonts (Newsreader, Atkinson Hyperlegible Next) are under the SIL Open Font License 1.1.Registry data: ClinicalTrials.gov, U.S. National Library of Medicine. T-minus is independent and not endorsed by NLM.