Literature Constellations source
Every file that ships, readable right here. MIT licence. Prefer it offline? Download the .zip.
README.md90 lines
# Literature ConstellationsType a PubMed search and watch up to 500 papers arrange themselves into a star map. Papers that share vocabulary drift together, groups become named constellations, and every star shows its study design, so you can see at a glance where the evidence is strong and where it is thin.Live at [mafflab.xyz/apps/literature-constellations/](https://mafflab.xyz/apps/literature-constellations/). Installable, no cookies, no tracking, no generative AI.## What it shows- **The sky:** one star per paper. Stars that sit close together share terms. Big stars with rays are systematic reviews, meta-analyses and randomized trials. Tap a star to read the paper; tap a constellation name to focus it.- **Constellations:** each group is named after its most distinctive terms, with its mix of study designs, papers per year and flags such as *No randomized trials*, *Mostly reviews and opinion* or *Growing fast*.- **Where is the evidence?** A matrix of constellations against study designs. Empty systematic-review or RCT cells in large constellations are marked as possible gaps.- **Papers:** the papers in view, sorted by strongest design or newest, with links to PubMed and the DOI.- **Play the years:** watch the field fill in year by year.- **Exports:** CSV of every paper with its constellation and design, PNG of the map. A search is kept in the address (`#q=…`), so a map can be shared as a link and rebuilt by whoever opens it.## How it works```browser ──► pubmed.php (on mafflab.xyz) ──► NCBI E-utilities (NLM) │ ?q=&n=&sort=&from=&to= esearch.fcgi (PMIDs, "AND hasabstract") │ efetch.fcgi (PubMed XML, in batches of 200) └─ caches public records for 24 h in ../../storage/lit-cache/layout-worker.js (in the browser) TF-IDF vectors ─► cosine similarities ─► t-SNE ─► k-means ─► names, lines, flags```Everything after the download happens in the visitor's browser, in a Web Worker:1. **Text vectors.** Each paper becomes a TF-IDF vector of its title (counted twice), abstract, MeSH headings and author keywords. Single words and adjacent word pairs, light plural stemming, generic and study-method words removed (so "randomized" or "cohort" don't decide the topic), and terms found in fewer than 2 papers or more than 40% of them dropped.2. **Similarity.** Cosine similarity between every pair of papers.3. **Layout.** Exact t-SNE (perplexity 25, early exaggeration, 750 iterations) places similar papers near each other. The animation is the real optimisation, slowed to about five seconds.4. **Constellations.** k-means++ on the map, with k from the number of papers (√(n/4), between 4 and 12), best of four starts. The lines are each constellation's minimum spanning tree.5. **Names.** Class-based TF-IDF: the terms most frequent in a constellation and rare in the others, with no two names sharing a word.6. **Study design.** Rule-based, from PubMed publication types first and then title and abstract wording: systematic review or meta-analysis, randomized trial, other clinical trial, observational study, case report or series, preclinical or laboratory, review or opinion, other.7. **Layout check.** The share of each paper's 10 most similar papers that end up among its 10 nearest stars, shown against what chance would give.Why not an AI embedding model? A good biomedical embedding model is 100 MB or more, breaks the mafflab 5 MB rule and the strict content security policy, and its choices cannot be explained to the reader. TF-IDF and MeSH are classical, fast and transparent: every star can say which terms put it where it is ("Placed here for terms like…").`demo.json` lets the app work offline and in previews: 400 real PubMed records about COVID-19 from the public 2021 PubMed update file `pubmed21n1298` (U.S. National Library of Medicine). It ships titles, metadata and each paper's 30 most distinctive terms, not abstracts.## Setting up the relay1. Copy `pubmed.config.sample.php` to `pubmed.config.php` in the same folder and add a contact e-mail. NCBI asks every application that uses E-utilities for one, so they can reach you before blocking traffic if something goes wrong.2. Optional: add a free NCBI API key (Settings page of an NCBI account). It raises the limit from 3 to 10 requests per second.3. Open `pubmed.php?check=1` on your site. It reports the PHP extensions, whether the cache folder is writable, whether the contact e-mail is set and whether PubMed is reachable.The `.htaccess` in this folder stops `pubmed.config.php` from being served.`pubmed.php` (PHP 7.4+) whitelists and validates every parameter, calls one fixed host, sends no visitor IP, cookie, referrer or browser header upstream and keeps no logs. It sends NCBI's required `tool` name and spaces its calls site-wide (a lock file) to stay under NCBI's rate limit. The cache holds public PubMed records only, keyed by the search; nothing about who searched is stored. Delete the folder at any time.## Files```literature-constellations/├── index.html, app.js, style.css, mafflab-theme.css├── engine.js text vectors, t-SNE, k-means, names, designs (pure functions, also runs in Node)├── layout-worker.js runs the engine off the main thread and streams the layout├── pubmed.php NCBI E-utilities relay├── pubmed.config.sample.php├── demo.json real 2021 COVID-19 records, without abstracts (generated)├── manifest.webmanifest, sw.js, .htaccess, icons/, fonts/└── tests/ ├── engine.test.js tokenizer, designs, layout quality and topic coherence on real records ├── test_relay.py pubmed.php end to end against a mock E-utilities ├── mock_eutils.py stand-in for esearch and efetch, serving real PubMed XML └── make_demo.js builds demo.json```## Tests```python3 tests/test_relay.py FIXTURES_DIRnode tests/engine.test.js records.json````FIXTURES_DIR` holds `covid.xml` and `indexed.xml`, two PubmedArticleSet files cut from `pubmed21n1298.xml.gz` (in the `data/` folder of [github.com/titipata/pubmed_parser](https://github.com/titipata/pubmed_parser)). `records.json` is a `pubmed.php` response saved during the relay test. The relay test checks parsing, the 3-per-second spacing, the cache, error passthrough and that cross-site and malformed requests are refused. The engine test checks that similar papers stay together (about 39% of 10 nearest neighbours kept, against 2% by chance) and that constellations are far more coherent than random groups.## Limits- The map shows shared vocabulary, not citations or scientific agreement. Distances inside a constellation mean more than distances between far-apart ones; the position of a whole constellation on the map has no meaning.- Constellations and names are statistical. A different number of papers or another sort order gives a somewhat different sky.- Study designs are rule-based and will misclassify some papers, especially ones without publication types. Check the paper before relying on its label.- Only papers with abstracts are fetched, at most 500 per search. "Best match" uses PubMed's own relevance ranking.- An aid for orientation, landscape reviews and teaching, not a systematic search. It does not replace a documented search strategy.## LicenceMIT. © 2026 Aitor Martínez Rosell. Records come from PubMed (U.S. National Library of Medicine) through NCBI E-utilities. Literature Constellations is independent and not endorsed by NLM or NCBI.pubmed.php245 lines
<?php/** * Literature Constellations relay for PubMed, through NCBI E-utilities. PHP 7.4 or newer. * * GET endpoints * ?q=<PubMed query>&n=<50-500>&sort=relevance|date&from=<year>&to=<year>&abs=1 * ESearch for the PMIDs, then EFetch for the records, returned as compact JSON * ?check=1 server self-check: extensions, cache folder, contact details, PubMed reachable * * NCBI asks every application to send a tool name and a contact e-mail, and to stay under * 3 requests per second (10 with an API key). Put your e-mail, and optionally an API key, in * pubmed.config.php next to this file (see pubmed.config.sample.php). The relay spaces its * calls site-wide to respect the limit. * * Privacy * - One fixed upstream host; whitelisted, validated parameters. * - Sends no visitor IP, cookie, referrer or browser header upstream. Keeps no logs. * - Caches public PubMed records for 24 hours in ../../storage/lit-cache/ so a repeated * search does not hit NCBI again. Nothing about who searched is stored. */declare(strict_types=1);const TOOL = 'mafflab-literature-constellations';const UA = 'mafflab Literature Constellations (self-hosted relay; +https://mafflab.xyz/lab/literature-constellations/)';const MAX_BODY = 30 * 1024 * 1024;const TTL = 24 * 3600;const BATCH = 200;$UP = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';$CACHE = dirname(__DIR__, 2) . '/storage/lit-cache';$CFG = ['email' => '', 'api_key' => ''];if (is_file(__DIR__ . '/pubmed.config.php')) { $c = include __DIR__ . '/pubmed.config.php'; if (is_array($c)) $CFG = array_merge($CFG, $c); }if (PHP_SAPI === 'cli-server') { // local testing only: never true on a production host if (getenv('LC_UPSTREAM')) $UP = rtrim((string) getenv('LC_UPSTREAM'), '/') . '/'; if (getenv('LC_CACHE')) $CACHE = (string) getenv('LC_CACHE');}header('Content-Type: application/json; charset=utf-8');header('X-Content-Type-Options: nosniff');header('Referrer-Policy: no-referrer');function out(int $code, array $data): void { http_response_code($code); header('Cache-Control: no-store'); echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); exit;}function fail(int $code, string $msg): void { out($code, ['error' => $msg]); }if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') fail(405, 'Only GET is supported.');if (($_SERVER['HTTP_SEC_FETCH_SITE'] ?? '') === 'cross-site') fail(403, 'This relay only serves the Literature Constellations page.');/* ------------------------------------------------------------------ cache */function cache_dir(): ?string { global $CACHE; static $ok = null; if ($ok === null) $ok = (is_dir($CACHE) || @mkdir($CACHE, 0750, true)) && is_writable($CACHE); return $ok ? $CACHE : null;}function cache_get(string $key): ?string { $d = cache_dir(); if (!$d) return null; $f = "$d/$key.json.gz"; if (!is_file($f) || filemtime($f) < time() - TTL) return null; $z = @file_get_contents($f); $s = $z === false ? false : @gzdecode($z); return $s === false ? null : $s;}function cache_put(string $key, string $json): void { $d = cache_dir(); if (!$d) return; $tmp = "$d/.$key." . bin2hex(random_bytes(4)); if (@file_put_contents($tmp, gzencode($json, 6)) !== false) @rename($tmp, "$d/$key.json.gz"); if (mt_rand(1, 50) === 1) prune($d);}function prune(string $d): void { // keep the cache under ~300 files $files = glob("$d/*.json.gz") ?: []; if (count($files) <= 300) return; usort($files, function ($a, $b) { return filemtime($a) <=> filemtime($b); }); foreach (array_slice($files, 0, count($files) - 250) as $f) @unlink($f);}/* ------------------------------------------------------------------ upstream, spaced site-wide *//* Calls are spaced from the END of the previous call, site-wide, so network jitter can never squeeze two requests closer together at NCBI than the limit allows. */function clock(bool $markOnly): void { global $CFG; $gap = $CFG['api_key'] ? 0.11 : 0.35; // seconds: 10 per second with a key, 3 without $d = cache_dir(); $fh = $d ? @fopen("$d/.clock", 'c+') : false; if (!$fh) { if (!$markOnly) usleep((int) ($gap * 1e6)); return; } flock($fh, LOCK_EX); if (!$markOnly) { $wait = (float) stream_get_contents($fh) + $gap - microtime(true); if ($wait > 0) usleep((int) round(min($wait, 5) * 1e6)); } ftruncate($fh, 0); rewind($fh); fwrite($fh, (string) microtime(true)); flock($fh, LOCK_UN); fclose($fh);}function eutil(string $tool, array $params, bool $post = false): string { global $UP, $CFG; $params += ['tool' => TOOL]; if ($CFG['email']) $params['email'] = $CFG['email']; if ($CFG['api_key']) $params['api_key'] = $CFG['api_key']; $url = $UP . $tool . '.fcgi'; $qs = http_build_query($params, '', '&', PHP_QUERY_RFC3986); for ($try = 0; $try < 3; $try++) { clock(false); [$status, $body] = http($post ? $url : "$url?$qs", $post ? $qs : null); clock(true); if ($status === 429 || $status >= 500) { usleep(800000 * ($try + 1)); continue; } // NCBI is busy: back off if ($status >= 400) fail(502, "PubMed answered with an error ($status)."); return $body; } fail(503, 'PubMed is busy right now. Try again in a minute.');}/** @return array{0:int,1:string} */function http(string $url, ?string $postBody): array { $headers = ['Accept: application/json, application/xml, */*']; if ($postBody !== null) $headers[] = 'Content-Type: application/x-www-form-urlencoded'; if (function_exists('curl_init')) { $buf = ''; $ch = curl_init($url); $opt = [CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $headers, CURLOPT_USERAGENT => UA, CURLOPT_ENCODING => '', CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf) { $buf .= $chunk; return strlen($buf) > MAX_BODY ? 0 : strlen($chunk); }]; if ($postBody !== null) { $opt[CURLOPT_POST] = true; $opt[CURLOPT_POSTFIELDS] = $postBody; } curl_setopt_array($ch, $opt); $ok = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($ok === false || $status === 0) fail(502, 'PubMed did not respond. Try again in a moment.'); return [$status, $buf]; } if (!ini_get('allow_url_fopen')) fail(501, 'This server cannot make outbound requests (enable cURL or allow_url_fopen).'); $http = ['method' => $postBody === null ? 'GET' : 'POST', 'timeout' => 60, 'ignore_errors' => true, 'follow_location' => 0, 'header' => implode("\r\n", $headers) . "\r\nUser-Agent: " . UA . "\r\n"]; if ($postBody !== null) $http['content'] = $postBody; $body = @file_get_contents($url, false, stream_context_create(['http' => $http]), 0, MAX_BODY); $status = 0; foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s(\d{3})#', $h, $m)) $status = (int) $m[1]; if ($body === false || $status === 0) fail(502, 'PubMed did not respond. Try again in a moment.'); return [$status, $body];}/* ------------------------------------------------------------------ PubMed XML -> records */function txt(?DOMNode $n): string { return $n ? trim(preg_replace('/\s+/u', ' ', $n->textContent)) : ''; }function parse_records(string $xml): array { libxml_use_internal_errors(true); $dom = new DOMDocument(); if (!$dom->loadXML($xml, LIBXML_NONET | LIBXML_COMPACT | LIBXML_PARSEHUGE)) fail(502, 'PubMed sent records that could not be read.'); $xp = new DOMXPath($dom); $out = []; foreach ($xp->query('/PubmedArticleSet/PubmedArticle') as $pa) { $mc = $xp->query('MedlineCitation', $pa)->item(0); if (!$mc) continue; $art = $xp->query('Article', $mc)->item(0); if (!$art) continue; $abs = []; foreach ($xp->query('Abstract/AbstractText', $art) as $a) { $label = $a instanceof DOMElement ? $a->getAttribute('Label') : ''; $t = txt($a); if ($t !== '') $abs[] = ($label && strtoupper($label) !== 'UNLABELLED' ? ucfirst(strtolower($label)) . ': ' : '') . $t; } $year = txt($xp->query('Journal/JournalIssue/PubDate/Year', $art)->item(0)); if (!$year && preg_match('/\b(19|20)\d{2}\b/', txt($xp->query('Journal/JournalIssue/PubDate/MedlineDate', $art)->item(0)), $m)) $year = $m[0]; if (!$year) $year = txt($xp->query('ArticleDate/Year', $art)->item(0)); $authors = []; foreach ($xp->query('AuthorList/Author', $art) as $au) { $ln = txt($xp->query('LastName', $au)->item(0)); $authors[] = $ln ? trim($ln . ' ' . txt($xp->query('Initials', $au)->item(0))) : txt($xp->query('CollectiveName', $au)->item(0)); if (count($authors) >= 3) break; } $nAuth = $xp->query('AuthorList/Author', $art)->length; $mesh = []; $major = []; foreach ($xp->query('MeshHeadingList/MeshHeading', $mc) as $mh) { $d = $xp->query('DescriptorName', $mh)->item(0); if (!$d) continue; $mesh[] = txt($d); $isMajor = $d instanceof DOMElement && $d->getAttribute('MajorTopicYN') === 'Y'; foreach ($xp->query('QualifierName', $mh) as $q) if ($q instanceof DOMElement && $q->getAttribute('MajorTopicYN') === 'Y') $isMajor = true; if ($isMajor) $major[] = txt($d); } $types = []; foreach ($xp->query('PublicationTypeList/PublicationType', $art) as $p) $types[] = txt($p); $kw = []; foreach ($xp->query('KeywordList/Keyword', $mc) as $k) { $t = txt($k); if ($t !== '') $kw[] = $t; } $doi = txt($xp->query('PubmedData/ArticleIdList/ArticleId[@IdType="doi"]', $pa)->item(0)); $out[] = [ 'pmid' => txt($xp->query('PMID', $mc)->item(0)), 'title' => txt($xp->query('ArticleTitle', $art)->item(0)) ?: txt($xp->query('VernacularTitle', $art)->item(0)), 'abstract' => implode("\n", $abs), 'journal' => txt($xp->query('Journal/ISOAbbreviation', $art)->item(0)) ?: txt($xp->query('Journal/Title', $art)->item(0)), 'year' => $year ? (int) $year : null, 'authors' => implode(', ', array_filter($authors)) . ($nAuth > 3 ? ', et al.' : ''), 'types' => $types, 'mesh' => $mesh, 'major' => $major, 'kw' => array_slice($kw, 0, 12), 'doi' => $doi, ]; } return $out;}/* ------------------------------------------------------------------ routes */if (isset($_GET['check'])) { $r = ['php' => PHP_VERSION, 'curl' => function_exists('curl_init'), 'allow_url_fopen' => (bool) ini_get('allow_url_fopen'), 'dom' => class_exists('DOMDocument'), 'cache' => cache_dir() ? 'writable' : 'not available (works, but every search goes to NCBI)', 'contact_email' => $CFG['email'] ? 'set' : 'missing: add it to pubmed.config.php (NCBI asks for one)', 'api_key' => $CFG['api_key'] ? 'set (10 requests per second)' : 'none (3 requests per second)']; $j = json_decode(eutil('esearch', ['db' => 'pubmed', 'term' => 'aspirin', 'retmax' => '1', 'retmode' => 'json']), true); $r['pubmed'] = isset($j['esearchresult']['count']) ? 'reachable' : 'unexpected answer'; $r['ok'] = $r['dom'] && ($r['curl'] || $r['allow_url_fopen']) && $r['pubmed'] === 'reachable'; out(200, $r);}if (!isset($_GET['q'])) fail(400, 'Use ?q= with a PubMed query.');$q = trim(preg_replace('/\s+/u', ' ', (string) $_GET['q']));$len = function_exists('mb_strlen') ? mb_strlen($q) : strlen($q);if ($len < 2 || $len > 300 || preg_match('/[\x00-\x1f<>{}\\\\]/', $q)) fail(400, 'Type a PubMed query of 2 to 300 characters.');$n = max(50, min(500, (int) ($_GET['n'] ?? 300)));$sort = ($_GET['sort'] ?? '') === 'date' ? 'pub_date' : 'relevance';$from = (int) ($_GET['from'] ?? 0); $to = (int) ($_GET['to'] ?? 0);$from = $from >= 1900 && $from <= 2100 ? $from : 0; $to = $to >= 1900 && $to <= 2100 ? $to : 0;$abs = ($_GET['abs'] ?? '1') !== '0';$key = 'q-' . md5(implode('|', [strtolower($q), $n, $sort, $from, $to, $abs ? 1 : 0]));if (($c = cache_get($key)) !== null) { header('Cache-Control: no-store'); echo $c; exit; }$term = $abs ? "($q) AND hasabstract" : $q;$sp = ['db' => 'pubmed', 'term' => $term, 'retmax' => (string) $n, 'retmode' => 'json', 'sort' => $sort];if ($from || $to) $sp += ['datetype' => 'pdat', 'mindate' => (string) ($from ?: 1900), 'maxdate' => (string) ($to ?: 2100)];$s = json_decode(eutil('esearch', $sp), true);if (!isset($s['esearchresult'])) fail(502, 'PubMed sent an unexpected answer.');if (!empty($s['esearchresult']['ERROR'])) fail(400, 'PubMed could not run that query: ' . (string) $s['esearchresult']['ERROR']);$ids = array_values(array_filter($s['esearchresult']['idlist'] ?? [], function ($x) { return preg_match('/^\d{1,9}$/', (string) $x); }));$papers = [];foreach (array_chunk($ids, BATCH) as $chunk) { $papers = array_merge($papers, parse_records(eutil('efetch', ['db' => 'pubmed', 'id' => implode(',', $chunk), 'retmode' => 'xml'], true)));}$order = array_flip($ids);usort($papers, function ($a, $b) use ($order) { return ($order[$a['pmid']] ?? 0) <=> ($order[$b['pmid']] ?? 0); });$json = json_encode(['q' => $q, 'count' => (int) ($s['esearchresult']['count'] ?? count($ids)), 'sort' => $sort, 'papers' => $papers], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);cache_put($key, $json);header('Cache-Control: no-store');echo $json;pubmed.config.sample.php11 lines
<?php/* Copy this file to pubmed.config.php (same folder) and fill it in. NCBI asks every application that uses E-utilities for a contact e-mail, so they can reach you before blocking traffic if something goes wrong. An API key is optional: it raises the limit from 3 to 10 requests per second (free, from the Settings page of an NCBI account). The .htaccess in this folder stops pubmed.config.php from being served. */return [ 'email' => '', // e.g. 'you@example.com' 'api_key' => '',];engine.js349 lines
/* Literature Constellations engine: text features, similarity, t-SNE layout, clusters, labels and study designs. Pure functions, no DOM; runs in the layout worker and in Node for the tests. MIT. */(function (root) { 'use strict'; /* ================================================================ text */ const STOP = new Set(( 'a about above after again against all also am among an and any are as at be because been before being below between both but by can could ' + 'did do does doing done down during each either et etc few for from further had has have having he her here hers him his how however i if ' + 'in into is it its itself just may me might more most much must my no nor not now of off on once only or other our ours out over own per ' + 'same she should since so some such than that the their theirs them then there these they this those through thus to too under until up ' + 'upon us use used using very via was we were what when where whether which while who whom whose why will with within without would yet you your ' + 'al although analysis analyses approach article associated association based background compared comparison conclusion conclusions data ' + 'demonstrate demonstrated determine developed effect effects evaluate evaluated evidence factor factors finding findings found group groups ' + 'high higher identified important improve improved include included including increase increased level levels low lower main method methods ' + 'new number objective objectives observed obtained outcome outcomes overall paper patient patients performed period population potential present ' + 'presented previous purpose rate rates related report reported research result results review risk role sample showed significant ' + 'significantly studied studies study suggest suggested total two one three four five six year years aim aims case cases clinical treatment ' + 'treated effective respectively well whereas due across available provide provides need needed show shown many several different among ' + 'non less greater compared current recent first second third likely various among given major mean median range ratio ci confidence interval ' + 'odds hazard vs versus p0 respectively among copyright reserved rights elsevier published ltd inc author authors license open access ' + // study-design and search words: design is shown separately, so it should not shape the topics 'systematic metaanalysis metaanalyses randomized randomised randomly trial trials cohort retrospective prospective crosssectional ' + 'observational casecontrol literature searched search databases database pubmed embase medline scopus cochrane prisma eligible inclusion' ).split(/\s+/)); const KEEP_S = new Set(['diabetes', 'herpes', 'rabies', 'series', 'species', 'status', 'mellitus', 'virus', 'sepsis', 'analysis', 'thesis', 'covid', 'aids', 'sars', 'mers', 'ards', 'syndromes']); const CHECK_TAGS = new Set(['humans', 'animals', 'female', 'male', 'adult', 'middle aged', 'aged', 'aged, 80 and over', 'young adult', 'adolescent', 'child', 'child, preschool', 'infant', 'infant, newborn', 'pregnancy', 'mice', 'rats', 'retrospective studies', 'prospective studies', 'cross-sectional studies', 'cohort studies', 'treatment outcome', 'risk factors', 'time factors', 'surveys and questionnaires', 'follow-up studies', 'case-control studies', 'reproducibility of results', 'sensitivity and specificity', 'incidence', 'prevalence', 'united states']); function stem(w) { if (w.length <= 4 || KEEP_S.has(w)) return w; if (w.endsWith('ies') && w.length > 5) return w.slice(0, -3) + 'y'; if (/(ches|shes|sses|xes|zes)$/.test(w)) return w.slice(0, -2); if (w.endsWith('s') && !/(ss|us|is)$/.test(w)) return w.slice(0, -1); return w; } // Tokens with their surface form (original case); bigrams only across words adjacent in the text. function tokenize(text) { const out = []; let pos = 0; // hyphenated words stay whole (X-ray, pro-inflammatory, COVID-19); the key drops the hyphens const words = String(text || '').replace(/[’']/g, '').split(/[^A-Za-z0-9-]+|-{2,}/); for (let orig of words) { orig = orig.replace(/^-+|-+$/g, ''); if (!orig) continue; pos++; const raw = orig.toLowerCase().replace(/-/g, ''); if (raw.length < 3 || /^\d+$/.test(raw) || STOP.has(raw)) continue; const s = stem(raw); if (STOP.has(s)) continue; out.push({ s, raw: orig, pos }); } return out; } /* ================================================================ vectors */ // papers: [{ title, abstract | absTerms, kw: [], mesh: [], major: [] }] function vectorize(papers) { const N = papers.length, docs = [], df = new Map(), surface = new Map(); const addSurface = (term, form) => { let m = surface.get(term); if (!m) surface.set(term, m = new Map()); m.set(form, (m.get(form) || 0) + 1); }; papers.forEach(p => { const tf = new Map(); const add = (t, w) => tf.set(t, (tf.get(t) || 0) + w); // title counts twice; each segment is tokenized on its own so bigrams never span segments const segs = [p.title, p.title].concat(p.abstract ? [p.abstract] : (p.absTerms || [])); segs.forEach(seg => { const toks = tokenize(seg); toks.forEach((t, i) => { add(t.s, 1); addSurface(t.s, t.raw); const nx = toks[i + 1]; if (nx && nx.pos === t.pos + 1) { const b = t.s + ' ' + nx.s; add(b, 1); addSurface(b, t.raw + ' ' + nx.raw); } }); }); const major = new Set((p.major || []).map(x => x.toLowerCase())); (p.mesh || []).concat(p.kw || []).forEach(m => { const k = String(m).toLowerCase().trim(); if (!k || CHECK_TAGS.has(k) || k.length > 60) return; const t = '#' + k; add(t, major.has(k) ? 3 : 2); addSurface(t, String(m).trim()); }); docs.push(tf); tf.forEach((_, t) => df.set(t, (df.get(t) || 0) + 1)); }); const maxDf = Math.max(2, Math.floor(N * 0.4)), minDf = N >= 40 ? 2 : 1; const vocab = new Map(), terms = []; const okWord = t => { const d = df.get(t) || 0; return d >= minDf && d <= maxDf; }; // bigrams survive only when both words do: no "covid patients" when "covid" is in every paper df.forEach((d, t) => { if (d >= minDf && d <= maxDf && (t[0] === '#' || !t.includes(' ') || t.split(' ').every(okWord))) { vocab.set(t, terms.length); terms.push(t); } }); const idf = terms.map(t => Math.log(N / df.get(t))); const vecs = docs.map(tf => { const ix = [], vx = []; tf.forEach((c, t) => { const j = vocab.get(t); if (j !== undefined) { ix.push(j); vx.push((1 + Math.log(c)) * idf[j]); } }); const order = ix.map((_, k) => k).sort((a, b) => ix[a] - ix[b]); const I = new Int32Array(order.map(k => ix[k])), V = new Float32Array(order.map(k => vx[k])); let nrm = 0; for (let k = 0; k < V.length; k++) nrm += V[k] * V[k]; nrm = Math.sqrt(nrm) || 1; for (let k = 0; k < V.length; k++) V[k] /= nrm; return { I, V }; }); const label = terms.map(t => { const m = surface.get(t); let best = t, bc = -1; if (m) m.forEach((c, f) => { const lc = f.toLowerCase(); if (c > bc || (c === bc && f !== lc)) { bc = c; best = f; } }); // a word written in capitals somewhere (IgM, CNN) keeps them; otherwise sentence case if (m && best === best.toLowerCase()) { let up = null, uc = 0; m.forEach((c, f) => { if (f !== f.toLowerCase() && f !== f[0] + f.slice(1).toLowerCase() && c > uc) { uc = c; up = f; } }); if (up && uc * 3 >= bc) best = up; } return best[0].toUpperCase() + best.slice(1); }); return { vecs, terms, label, df: terms.map(t => df.get(t)), N }; } // Cosine similarity for every pair, through an inverted index. function similarity(vecs, nTerms) { const n = vecs.length, S = new Float32Array(n * n), post = Array.from({ length: nTerms }, () => []); vecs.forEach((v, d) => { for (let k = 0; k < v.I.length; k++) post[v.I[k]].push(d, v.V[k]); }); for (const p of post) { for (let a = 0; a < p.length; a += 2) { const i = p[a], wi = p[a + 1]; for (let b = a + 2; b < p.length; b += 2) { const j = p[b], w = wi * p[b + 1]; S[i * n + j] += w; S[j * n + i] += w; } } } for (let i = 0; i < n; i++) S[i * n + i] = 1; return S; } /* ================================================================ random numbers */ function rng(seed) { let s = seed >>> 0 || 1; const u = () => { s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const normal = () => { let a = 0; while (!a) a = u(); return Math.sqrt(-2 * Math.log(a)) * Math.cos(2 * Math.PI * u()); }; return { u, normal }; } /* ================================================================ t-SNE (exact, van der Maaten & Hinton 2008) */ function affinities(S, n, perplexity) { const P = new Float64Array(n * n), target = Math.log(perplexity), row = new Float64Array(n); for (let i = 0; i < n; i++) { let beta = 1, lo = -Infinity, hi = Infinity; for (let it = 0; it < 60; it++) { let sum = 0, hsum = 0; for (let j = 0; j < n; j++) { if (j === i) { row[j] = 0; continue; } const d = 2 - 2 * S[i * n + j]; // squared distance between unit vectors const p = Math.exp(-d * beta); row[j] = p; sum += p; hsum += d * p; } if (sum <= 0) sum = 1e-12; const H = Math.log(sum) + beta * hsum / sum; if (Math.abs(H - target) < 1e-5) break; if (H > target) { lo = beta; beta = hi === Infinity ? beta * 2 : (beta + hi) / 2; } else { hi = beta; beta = lo === -Infinity ? beta / 2 : (beta + lo) / 2; } } let sum = 0; for (let j = 0; j < n; j++) sum += row[j]; for (let j = 0; j < n; j++) P[i * n + j] = row[j] / (sum || 1); } for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) { const v = Math.max((P[i * n + j] + P[j * n + i]) / (2 * n), 1e-12); P[i * n + j] = v; P[j * n + i] = v; } return P; } function tsneInit(S, n, opts) { opts = opts || {}; const perp = Math.max(4, Math.min(opts.perplexity || 25, (n - 1) / 3)); const R = rng(opts.seed || 42); const Y = new Float64Array(2 * n); for (let k = 0; k < 2 * n; k++) Y[k] = R.normal() * 1e-4; return { n, P: affinities(S, n, perp), Y, gains: new Float64Array(2 * n).fill(1), vel: new Float64Array(2 * n), iter: 0, iters: opts.iters || 750, exIters: 120, lr: opts.lr || Math.max(60, n / 6), perplexity: perp, num: new Float64Array(n * n) }; } function tsneStep(st) { const { n, P, Y, gains, vel, num } = st, ex = st.iter < st.exIters ? (st.ex || 4) : 1, mom = st.iter < st.exIters ? 0.5 : 0.8; let Z = 0; for (let i = 0; i < n; i++) { const xi = Y[2 * i], yi = Y[2 * i + 1]; for (let j = i + 1; j < n; j++) { const dx = xi - Y[2 * j], dy = yi - Y[2 * j + 1], q = 1 / (1 + dx * dx + dy * dy); num[i * n + j] = q; Z += 2 * q; } } const G = new Float64Array(2 * n); for (let i = 0; i < n; i++) { const xi = Y[2 * i], yi = Y[2 * i + 1]; for (let j = i + 1; j < n; j++) { const q = num[i * n + j], m = (ex * P[i * n + j] - q / Z) * q, gx = m * (xi - Y[2 * j]), gy = m * (yi - Y[2 * j + 1]); G[2 * i] += gx; G[2 * i + 1] += gy; G[2 * j] -= gx; G[2 * j + 1] -= gy; } } let mx = 0, my = 0; for (let k = 0; k < 2 * n; k++) { const g = 4 * G[k]; gains[k] = Math.sign(g) === Math.sign(vel[k]) ? gains[k] * 0.8 : gains[k] + 0.2; if (gains[k] < 0.01) gains[k] = 0.01; vel[k] = mom * vel[k] - st.lr * gains[k] * g; Y[k] += vel[k]; if (k & 1) my += Y[k]; else mx += Y[k]; } mx /= n; my /= n; for (let i = 0; i < n; i++) { Y[2 * i] -= mx; Y[2 * i + 1] -= my; } st.iter++; return st.iter >= st.iters; } /* ================================================================ clusters */ function kmeans(Y, n, k, seed) { const R = rng(seed), C = new Float64Array(2 * k), A = new Int32Array(n); // k-means++ seeding let first = Math.floor(R.u() * n); C[0] = Y[2 * first]; C[1] = Y[2 * first + 1]; const D = new Float64Array(n).fill(Infinity); for (let c = 1; c < k; c++) { let sum = 0; for (let i = 0; i < n; i++) { const dx = Y[2 * i] - C[2 * (c - 1)], dy = Y[2 * i + 1] - C[2 * (c - 1) + 1]; D[i] = Math.min(D[i], dx * dx + dy * dy); sum += D[i]; } let r = R.u() * sum, pick = n - 1; for (let i = 0; i < n; i++) { r -= D[i]; if (r <= 0) { pick = i; break; } } C[2 * c] = Y[2 * pick]; C[2 * c + 1] = Y[2 * pick + 1]; } for (let it = 0; it < 100; it++) { let moved = false; for (let i = 0; i < n; i++) { let best = 0, bd = Infinity; for (let c = 0; c < k; c++) { const dx = Y[2 * i] - C[2 * c], dy = Y[2 * i + 1] - C[2 * c + 1], d = dx * dx + dy * dy; if (d < bd) { bd = d; best = c; } } if (A[i] !== best) { A[i] = best; moved = true; } } const sx = new Float64Array(k), sy = new Float64Array(k), cnt = new Int32Array(k); for (let i = 0; i < n; i++) { sx[A[i]] += Y[2 * i]; sy[A[i]] += Y[2 * i + 1]; cnt[A[i]]++; } for (let c = 0; c < k; c++) if (cnt[c]) { C[2 * c] = sx[c] / cnt[c]; C[2 * c + 1] = sy[c] / cnt[c]; } if (!moved && it) break; } return A; } function silhouette(Y, n, A, k) { let total = 0; const sums = new Float64Array(k), cnt = new Int32Array(k); for (let i = 0; i < n; i++) cnt[A[i]]++; for (let i = 0; i < n; i++) { sums.fill(0); for (let j = 0; j < n; j++) { if (i === j) continue; const dx = Y[2 * i] - Y[2 * j], dy = Y[2 * i + 1] - Y[2 * j + 1]; sums[A[j]] += Math.sqrt(dx * dx + dy * dy); } const own = cnt[A[i]] > 1 ? sums[A[i]] / (cnt[A[i]] - 1) : 0; let other = Infinity; for (let c = 0; c < k; c++) if (c !== A[i] && cnt[c]) other = Math.min(other, sums[c] / cnt[c]); total += cnt[A[i]] > 1 ? (other - own) / Math.max(own, other) : 0; } return total / n; } // Topic maps of a literature search are a continuum more than islands, so silhouette scores are // flat; a size rule gives a predictable number of constellations: about sqrt(n / 4), 4 to 12. function cluster(Y, n, seed, k) { k = k || Math.max(Math.min(4, n), Math.min(12, Math.round(Math.sqrt(n / 4)))); let best = null; for (let r = 0; r < 4; r++) { // best of four k-means++ starts (lowest inertia) const A = kmeans(Y, n, k, seed + 101 * r), cx = new Float64Array(k), cy = new Float64Array(k), cnt = new Int32Array(k); for (let i = 0; i < n; i++) { cx[A[i]] += Y[2 * i]; cy[A[i]] += Y[2 * i + 1]; cnt[A[i]]++; } let inertia = 0; for (let i = 0; i < n; i++) { const c = A[i], dx = Y[2 * i] - cx[c] / cnt[c], dy = Y[2 * i + 1] - cy[c] / cnt[c]; inertia += dx * dx + dy * dy; } if (!best || inertia < best.inertia) best = { A, inertia }; } const sizes = new Array(k).fill(0); best.A.forEach(c => sizes[c]++); const order = sizes.map((s, c) => [s, c]).sort((a, b) => b[0] - a[0]).map(x => x[1]), remap = new Int32Array(k); order.forEach((c, r) => { remap[c] = r; }); const assign = Array.from(best.A, c => remap[c]); return { k, assign, silhouette: n <= 1500 ? silhouette(Y, n, Int32Array.from(assign), k) : null }; } // Minimum spanning tree inside each cluster: the constellation lines. function constellationEdges(Y, assign, k) { const edges = []; for (let c = 0; c < k; c++) { const m = []; assign.forEach((a, i) => { if (a === c) m.push(i); }); if (m.length < 2) continue; const inT = new Uint8Array(m.length), best = new Float64Array(m.length).fill(Infinity), from = new Int32Array(m.length).fill(-1); best[0] = 0; for (let s = 0; s < m.length; s++) { let u = -1; for (let a = 0; a < m.length; a++) if (!inT[a] && (u < 0 || best[a] < best[u])) u = a; inT[u] = 1; if (from[u] >= 0) edges.push([m[from[u]], m[u], Math.sqrt(best[u])]); for (let a = 0; a < m.length; a++) if (!inT[a]) { const dx = Y[2 * m[u]] - Y[2 * m[a]], dy = Y[2 * m[u] + 1] - Y[2 * m[a] + 1], d = dx * dx + dy * dy; if (d < best[a]) { best[a] = d; from[a] = u; } } } } return edges; } // Distinctive terms per cluster (class-based TF-IDF): weight inside the cluster × share of all weight. function clusterLabels(vz, assign, k, top) { top = top || 3; const T = vz.terms.length, all = new Float64Array(T), per = Array.from({ length: k }, () => new Float64Array(T)); vz.vecs.forEach((v, d) => { const c = assign[d]; for (let q = 0; q < v.I.length; q++) { all[v.I[q]] += v.V[q]; per[c][v.I[q]] += v.V[q]; } }); return per.map(w => { const cand = []; for (let t = 0; t < T; t++) if (w[t] > 0) { const share = w[t] / all[t], phrase = vz.terms[t].startsWith('#') ? 1.25 : vz.terms[t].includes(' ') ? 1.15 : 1; cand.push([w[t] * share * share * phrase, t]); } cand.sort((a, b) => b[0] - a[0]); const chosen = [], words = new Set(), head = cand.slice(0, 15).map(c => vz.terms[c[1]].replace(/^#/, '')); for (const [, t] of cand) { const term = vz.terms[t].replace(/^#/, ''), parts = term.split(' '); if (parts.some(p => words.has(p))) continue; // no two labels sharing a word if (parts.length === 1 && head.some(h => h !== term && h.split(' ').includes(term))) continue; // the phrase reads better if (chosen.some(c => c.term.includes(term) || term.includes(c.term))) continue; chosen.push({ term, label: vz.label[t] }); parts.forEach(p => words.add(p)); if (chosen.length >= top) break; } return chosen.map(c => c.label); }); } /* ================================================================ study designs */ const DESIGNS = [ { k: 'syn', name: 'Systematic reviews and meta-analyses', short: 'SR / MA' }, { k: 'rct', name: 'Randomized trials', short: 'RCT' }, { k: 'ct', name: 'Other clinical trials', short: 'Trial' }, { k: 'obs', name: 'Observational studies', short: 'Obs' }, { k: 'case', name: 'Case reports and series', short: 'Case' }, { k: 'pre', name: 'Preclinical and laboratory', short: 'Lab' }, { k: 'rev', name: 'Reviews, guidance and opinion', short: 'Review' }, { k: 'oth', name: 'Other', short: 'Other' }, ]; function design(p) { const T = (p.types || []).join('|').toLowerCase(), ti = String(p.title || '').toLowerCase(); const tx = (ti + ' ' + String(p.abstract || (p.absTerms || []).join(' ')).toLowerCase()); const mesh = (p.mesh || []).map(m => m.toLowerCase()); if (/meta-analysis|systematic review/.test(T) || /\b(meta-analy[sz]is|systematic review|umbrella review|scoping review|systematic literature review)\b/.test(ti)) return 'syn'; const protocol = /\bprotocol\b/.test(ti); if (!protocol && (/(^|\|)randomized controlled trial(\||$)/.test(T) || (/\brandomi[sz]ed\b/.test(tx) && /\b(trial|controlled|placebo)\b/.test(tx) && !/\breview\b/.test(ti)))) return 'rct'; if (!protocol && (/clinical trial(\||$|, phase)/.test(T) || /\b(single-arm|open-label|non-randomi[sz]ed|phase (i|ii|iii|1|2|3))\b[^.]{0,60}\b(trial|study)\b/.test(tx))) return 'ct'; if (/case reports/.test(T) || /\bcase (report|series)\b/.test(ti)) return 'case'; if (/(^|\|)(review|editorial|comment|letter|news|guideline|practice guideline|consensus development conference)(\||$)/.test(T) || /\b(a review|review of|narrative review|mini-?review|perspective|commentary|viewpoint|editorial|guidance|recommendations|consensus statement)\b/.test(ti)) return 'rev'; if ((mesh.includes('animals') && !mesh.includes('humans')) || /\b(mice|mouse|murine|rats|zebrafish|in vitro|cell lines?|in silico|molecular docking|organoids?)\b/.test(tx)) return 'pre'; if (/observational study|comparative study|multicenter study|evaluation study|validation study/.test(T) || /\b(cohort|case-control|cross-sectional|retrospective(ly)?|prospective(ly)?|registry|population-based|observational|survey|questionnaire|longitudinal|real-world)\b/.test(tx)) return 'obs'; if (/\bwe (report|describe|present) (a|an|the|two|three|four) (case|patient|cases|patients)\b/.test(tx)) return 'case'; return 'oth'; } /* ================================================================ quality of the layout */ // Share of each paper's 10 most similar papers (by text) that are also among its 10 nearest on the map. function neighbourhoodKept(S, Y, n, K) { K = K || 10; let kept = 0; for (let i = 0; i < n; i++) { const hi = [], lo = []; for (let j = 0; j < n; j++) if (j !== i) { hi.push([S[i * n + j], j]); const dx = Y[2 * i] - Y[2 * j], dy = Y[2 * i + 1] - Y[2 * j + 1]; lo.push([dx * dx + dy * dy, j]); } hi.sort((a, b) => b[0] - a[0]); lo.sort((a, b) => a[0] - b[0]); const set = new Set(hi.slice(0, K).map(x => x[1])); kept += lo.slice(0, K).filter(x => set.has(x[1])).length; } return kept / (n * K); } const API = { tokenize, stem, vectorize, similarity, rng, affinities, tsneInit, tsneStep, kmeans, silhouette, cluster, constellationEdges, clusterLabels, DESIGNS, design, neighbourhoodKept, STOP }; root.LC = API; if (typeof module !== 'undefined' && module.exports) module.exports = API;})(typeof self !== 'undefined' ? self : this);layout-worker.js35 lines
/* Literature Constellations layout worker: text features, t-SNE (streamed so the map can be watched forming), then clusters, labels and constellation lines. */'use strict';importScripts('engine.js');let fast = false;self.onmessage = e => { if (e.data.type === 'fast') fast = true; if (e.data.type === 'run') run(e.data);};function run({ papers, seed, minMs }) { fast = false; const t0 = Date.now(), n = papers.length; const vz = LC.vectorize(papers), S = LC.similarity(vz.vecs, vz.terms.length); // each paper's most characteristic terms, to explain its place on the map const tops = vz.vecs.map(v => Array.from(v.I).map((t, q) => [v.V[q], t]).sort((a, b) => b[0] - a[0]).slice(0, 6).map(x => vz.label[x[1]])); self.postMessage({ type: 'vec', terms: vz.terms.length }); const st = LC.tsneInit(S, n, { seed }), tl = Date.now(); const loop = () => { const target = fast ? st.iters : Math.min(st.iters, Math.ceil(st.iters * (Date.now() - tl + 40) / minMs)); const sliceEnd = Date.now() + 40, stop = Math.max(target, st.iter + 1); while (st.iter < stop && Date.now() < sliceEnd) if (LC.tsneStep(st)) break; self.postMessage({ type: 'pos', iter: st.iter, total: st.iters, Y: Float32Array.from(st.Y) }); if (st.iter < st.iters) { setTimeout(loop, fast ? 0 : 16); return; } const cl = LC.cluster(st.Y, n, seed); self.postMessage({ type: 'done', Y: Float32Array.from(st.Y), assign: cl.assign, k: cl.k, labels: LC.clusterLabels(vz, cl.assign, cl.k, 3), edges: LC.constellationEdges(st.Y, cl.assign, cl.k), tops, kept: LC.neighbourhoodKept(S, st.Y, n, 10), terms: vz.terms.length, perplexity: st.perplexity, ms: Date.now() - t0, }); }; loop();}app.js485 lines
/* Literature Constellations: interface. Text and layout logic live in engine.js (run in layout-worker.js); PubMed is reached through pubmed.php. MIT. */'use strict';(() => { const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const LC = window.LC; const COLS = ['#FF2A85', '#00F0FF', '#B57BFF', '#F4FF5A', '#FB923C', '#34D399', '#38BDF8', '#F472B6', '#A3E635', '#2DD4BF', '#C4B5FD', '#FCA5A5']; const DES = LC.DESIGNS, DK = DES.map(d => d.k), DNAME = Object.fromEntries(DES.map(d => [d.k, d.name])), DSHORT = Object.fromEntries(DES.map(d => [d.k, d.short])); const RAD = { syn: 5.6, rct: 5.0, ct: 4.2, obs: 3.3, case: 3.0, rev: 2.9, pre: 2.9, oth: 2.6 }; const RANK = { syn: 0, rct: 1, ct: 2, obs: 3, case: 4, pre: 5, rev: 6, oth: 7 }; const REDUCED = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const int = v => Math.round(v).toLocaleString('en-US'); const OPT = { n: 300, sort: 'relevance' }; let M = null, worker = null, raf = 0, playT = null; /* ================================================================ search */ const msg = $('#msg'); const relayMsg = () => `<div class="msg">Live PubMed search runs on the mafflab server, and this copy of the app cannot reach it. The demo works anywhere.<br><button class="btn btn-s" type="button" data-demo>Open the demo</button></div>`; async function search(q, opts) { q = String(q || '').trim(); if (q.length < 2) { $('#q').focus(); return; } opts = Object.assign({}, OPT, opts || {}); const from = opts.from != null ? opts.from : parseInt($('#from').value, 10), to = opts.to != null ? opts.to : parseInt($('#to').value, 10); showFind(); $('#q').value = q; msg.innerHTML = `<div class="msg">Searching PubMed and reading up to ${int(opts.n)} records… this takes a few seconds.</div>`; const qs = new URLSearchParams({ q, n: opts.n, sort: opts.sort === 'date' ? 'date' : 'relevance' }); if (from >= 1900) qs.set('from', from); if (to >= 1900) qs.set('to', to); let r, j; try { r = await fetch('pubmed.php?' + qs, { headers: { Accept: 'application/json' } }); } catch (e) { msg.innerHTML = `<div class="msg">${navigator.onLine ? 'The lab server could not be reached. Try again in a moment.' : 'You are offline. The demo still works.'}</div>`; return; } if (!(r.headers.get('content-type') || '').includes('json')) { msg.innerHTML = relayMsg(); return; } try { j = await r.json(); } catch (e) { msg.innerHTML = '<div class="msg">PubMed sent something unexpected. Try again.</div>'; return; } if (!r.ok) { msg.innerHTML = `<div class="msg">${esc(j.error || 'Something went wrong.')}</div>`; return; } if ((j.papers || []).length < 20) { msg.innerHTML = `<div class="msg">PubMed found ${int(j.count || 0)} record${j.count === 1 ? '' : 's'} with an abstract for “${esc(q)}”. A map needs at least 20: try a broader query.</div>`; return; } msg.innerHTML = ''; start(j, false, { q, n: opts.n, sort: qs.get('sort'), from: qs.get('from'), to: qs.get('to') }); } async function demo() { showFind(); msg.innerHTML = '<div class="msg">Loading the demo…</div>'; try { const j = await (await fetch('demo.json')).json(); msg.innerHTML = ''; start(j, true, null); } catch (e) { msg.innerHTML = '<div class="msg">The demo could not be loaded.</div>'; } } /* ================================================================ run */ function showFind() { stopPlay(); $('#find').hidden = false; $('#map').hidden = true; $('#share').hidden = true; } function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function start(data, isDemo, params) { stopPlay(); const papers = data.papers; const years = papers.map(p => p.year || null).filter(Boolean); M = { q: data.q, count: data.count, demo: isDemo, source: data.source || '', params, papers, n: papers.length, design: papers.map(p => p.design || LC.design(p)), Y: null, assign: null, k: 0, labels: [], edges: [], tops: [], kept: null, minY: years.length ? Math.min(...years) : null, maxY: years.length ? Math.max(...years) : null, yearMax: null, focus: -1, sel: -1, hover: -1, iter: 0, total: 1, done: false, revealT: 0, auto: true, view: null, target: null, limit: 40, psort: 'design' }; M.yearMax = M.maxY; $('#find').hidden = true; $('#map').hidden = false; $('#share').hidden = isDemo; window.scrollTo(0, 0); renderHead(); renderDetail(); renderLists(); setupYears(); $('#legend-sky').innerHTML = '<span><i class="s1"></i>Systematic review or RCT</span><span><i class="s2"></i>Other designs</span><span>Drag to move · pinch or Ctrl + scroll to zoom</span>'; $('#status').hidden = false; $('#status-t').textContent = `Reading ${int(M.n)} papers…`; $('#status-b').style.width = '0'; $('#a-csv').disabled = $('#a-png').disabled = true; if (worker) worker.terminate(); worker = new Worker('layout-worker.js'); worker.onmessage = onWorker; worker.onerror = () => { $('#status-t').textContent = 'The layout stopped unexpectedly on this device.'; }; worker.postMessage({ type: 'run', seed: hash(String(M.q)), minMs: REDUCED ? 0 : 4600, papers: papers.map(p => ({ title: p.title, abstract: p.abstract, absTerms: p.absTerms, kw: p.kw, mesh: p.mesh, major: p.major })) }); setHash(); loop(); } const me = () => M; function onWorker(e) { const m = e.data, cur = me(); if (!cur) return; if (m.type === 'vec') { $('#status-t').textContent = `Placing ${int(cur.n)} papers by similarity (${int(m.terms)} terms)…`; } if (m.type === 'pos') { cur.Y = m.Y; cur.iter = m.iter; cur.total = m.total; $('#status-b').style.width = `${Math.round(100 * m.iter / m.total)}%`; $('#status-t').textContent = `Placing ${int(cur.n)} papers by similarity · step ${int(m.iter)} of ${int(m.total)}`; loop(); } if (m.type === 'done') { Object.assign(cur, { Y: m.Y, assign: m.assign, k: m.k, labels: m.labels, edges: m.edges.sort((a, b) => a[2] - b[2]), tops: m.tops, kept: m.kept, done: true, revealT: performance.now() }); worker.terminate(); worker = null; $('#status').hidden = true; $('#a-csv').disabled = $('#a-png').disabled = false; renderHead(); renderDetail(); renderLists(); loop(); } } /* ================================================================ canvas */ const cv = $('#c-sky'), sky = $('#sky'); let W = 0, H = 0, DPR = 1, dust = null; const sprites = new Map(); function sprite(col) { if (sprites.has(col)) return sprites.get(col); const c = document.createElement('canvas'); c.width = c.height = 64; const x = c.getContext('2d'), g = x.createRadialGradient(32, 32, 0, 32, 32, 32); g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(0.18, col); g.addColorStop(0.45, col + '55'); g.addColorStop(1, col + '00'); x.fillStyle = g; x.fillRect(0, 0, 64, 64); sprites.set(col, c); return c; } function size() { DPR = Math.min(2.5, window.devicePixelRatio || 1); const r = sky.getBoundingClientRect(); W = Math.max(200, r.width); H = Math.max(200, r.height); cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR); dust = null; } function makeDust() { const c = document.createElement('canvas'); c.width = cv.width; c.height = cv.height; const x = c.getContext('2d'), R = LC.rng(99); for (let i = 0; i < Math.round(W * H / 2600); i++) { x.fillStyle = `rgba(203,213,225,${0.05 + R.u() * 0.18})`; const s = R.u() < 0.1 ? 1.4 : 0.8; x.fillRect(R.u() * c.width, R.u() * c.height, s * DPR, s * DPR); } dust = c; } function bounds() { const n = M.n, xs = [], ys = []; for (let i = 0; i < n; i++) { xs.push(M.Y[2 * i]); ys.push(M.Y[2 * i + 1]); } xs.sort((a, b) => a - b); ys.sort((a, b) => a - b); const q = (a, p) => a[Math.min(a.length - 1, Math.max(0, Math.round(p * (a.length - 1))))]; const x0 = q(xs, 0.01), x1 = q(xs, 0.99), y0 = q(ys, 0.01), y1 = q(ys, 0.99); const bw = Math.max(1e-6, x1 - x0), bh = Math.max(1e-6, y1 - y0); return { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, s: Math.min(W / (bw * 1.22), (H - 30) / (bh * 1.22)) }; } const SX = i => (M.Y[2 * i] - M.view.cx) * M.view.s + W / 2, SY = i => (M.Y[2 * i + 1] - M.view.cy) * M.view.s + H / 2; const visibleYear = i => !M.yearMax || !M.papers[i].year || M.papers[i].year <= M.yearMax; let labelBoxes = []; function draw(now) { const ctx = cv.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, cv.width, cv.height); if (!dust) makeDust(); ctx.drawImage(dust, 0, 0); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); if (!M || !M.Y) return false; let moving = false; if (M.auto) { M.target = bounds(); } if (!M.view) M.view = Object.assign({}, M.target || bounds()); if (M.target) { const v = M.view, t = M.target, k = M.done && !M.auto ? 1 : 0.18; v.cx += (t.cx - v.cx) * k; v.cy += (t.cy - v.cy) * k; v.s += (t.s - v.s) * k; if (Math.abs(t.s - v.s) / t.s > 0.002 || Math.abs(t.cx - v.cx) * v.s > 0.5 || Math.abs(t.cy - v.cy) * v.s > 0.5) moving = true; } const n = M.n, reveal = M.revealT ? Math.min(1, (now - M.revealT) / 1000) : 0; const zf = Math.max(0.7, Math.min(2.2, Math.pow(M.view.s / (M.target ? M.target.s : M.view.s), 0.35))); const focus = M.focus; // constellation lines if (M.done && reveal > 0) { const upto = Math.ceil(M.edges.length * Math.min(1, reveal * 1.4)); ctx.lineWidth = 1; for (let e = 0; e < upto; e++) { const [a, b] = M.edges[e]; if (!visibleYear(a) || !visibleYear(b)) continue; const c = M.assign[a]; if (focus >= 0 && c !== focus) continue; ctx.strokeStyle = COLS[c % 12]; ctx.globalAlpha = (focus >= 0 ? 0.5 : 0.26) * reveal; ctx.beginPath(); ctx.moveTo(SX(a), SY(a)); ctx.lineTo(SX(b), SY(b)); ctx.stroke(); } ctx.globalAlpha = 1; } // stars for (let pass = 0; pass < 2; pass++) for (let i = 0; i < n; i++) { const vis = visibleYear(i), dim = !vis || (focus >= 0 && M.assign && M.assign[i] !== focus); if ((pass === 0) !== dim) continue; const x = SX(i), y = SY(i); if (x < -20 || y < -20 || x > W + 20 || y > H + 20) continue; const d = M.design[i], r = RAD[d] * zf; const col = M.assign && reveal > 0 ? COLS[M.assign[i] % 12] : '#9FD8FF'; ctx.globalAlpha = !vis ? 0.06 : dim ? 0.16 : (M.assign ? 0.55 + 0.45 * reveal : 0.85); const s = r * 4.4; ctx.drawImage(sprite(col), x - s / 2, y - s / 2, s, s); if ((d === 'syn' || d === 'rct') && !dim) { ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 0.9; ctx.globalAlpha *= 0.8; ctx.beginPath(); ctx.moveTo(x - r * 2.6, y); ctx.lineTo(x + r * 2.6, y); ctx.moveTo(x, y - r * 2.6); ctx.lineTo(x, y + r * 2.6); ctx.stroke(); } } ctx.globalAlpha = 1; // selection and hover rings [[M.sel, '#FFFFFF', 2], [M.hover, 'rgba(255,255,255,.6)', 1.2]].forEach(([i, c, w]) => { if (i < 0) return; const x = SX(i), y = SY(i), r = RAD[M.design[i]] * zf * 2.2 + 4; ctx.strokeStyle = c; ctx.lineWidth = w; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.stroke(); }); // labels labelBoxes = []; if (M.done && reveal > 0.25) { ctx.font = '600 12px "Space Grotesk", system-ui, sans-serif'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; for (let c = 0; c < M.k; c++) { if (focus >= 0 && c !== focus) continue; let sx = 0, sy = 0, m = 0; for (let i = 0; i < n; i++) if (M.assign[i] === c && visibleYear(i)) { sx += SX(i); sy += SY(i); m++; } if (m < 2) continue; const text = M.labels[c].slice(0, W < 520 && focus < 0 ? 1 : 2).join(' · ').toUpperCase(), x = sx / m; const tw = ctx.measureText(text).width + 14, bx = Math.max(4, Math.min(W - tw - 4, x - tw / 2)); // labels never overlap: try the centroid, then just above and below, else leave it to the list const hit = yy => labelBoxes.some(([lx, ly, lw, lh]) => bx < lx + lw + 4 && bx + tw + 4 > lx && yy < ly + lh + 3 && yy + 22 + 3 > ly); let by = [sy / m - 11, sy / m - 37, sy / m + 15].find(yy => yy > 2 && yy < H - 24 && !hit(yy)); if (by === undefined) continue; const y = by + 11; ctx.globalAlpha = Math.min(1, (reveal - 0.25) * 2); ctx.fillStyle = 'rgba(8,8,13,.78)'; ctx.fillRect(bx, by, tw, 22); ctx.strokeStyle = COLS[c % 12]; ctx.lineWidth = 1; ctx.strokeRect(bx + 0.5, by + 0.5, tw - 1, 21); ctx.fillStyle = COLS[c % 12]; ctx.fillText(text, bx + tw / 2, y + 0.5); labelBoxes.push([bx, by, tw, 22, c]); } ctx.globalAlpha = 1; } return moving || (M.revealT && reveal < 1) || !M.done; } function loop() { if (raf) return; raf = requestAnimationFrame(function tick(t) { raf = 0; if (draw(t) || playT) loop(); }); } /* ---------- interaction */ const pts = new Map(); let dragMoved = false, pinch = null, lastTap = 0; function nearest(x, y) { if (!M || !M.Y) return -1; let best = -1, bd = 14 * 14; for (let i = 0; i < M.n; i++) { if (!visibleYear(i) || (M.focus >= 0 && M.assign && M.assign[i] !== M.focus)) continue; const dx = SX(i) - x, dy = SY(i) - y, d = dx * dx + dy * dy; if (d < bd) { bd = d; best = i; } } return best; } function zoomAt(f, x, y) { if (!M || !M.view) return; M.auto = false; const v = M.view, wx = (x - W / 2) / v.s + v.cx, wy = (y - H / 2) / v.s + v.cy; const ns = Math.max(M.target.s * 0.4, Math.min(M.target.s * 12, v.s * f)); v.s = ns; v.cx = wx - (x - W / 2) / ns; v.cy = wy - (y - H / 2) / ns; M.target = Object.assign({}, v); loop(); } const local = e => { const r = cv.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }; cv.addEventListener('pointerdown', e => { cv.setPointerCapture(e.pointerId); pts.set(e.pointerId, local(e)); dragMoved = false; if (pts.size === 2) { const [a, b] = [...pts.values()]; pinch = { d: Math.hypot(a[0] - b[0], a[1] - b[1]), s: M && M.view ? M.view.s : 1 }; } cv.classList.add('drag'); }); cv.addEventListener('pointermove', e => { const p = local(e); if (!pts.has(e.pointerId)) { // hover if (e.pointerType === 'mouse' && M && M.Y) { const h = nearest(p[0], p[1]); if (h !== M.hover) { M.hover = h; tip(h, p); loop(); } else if (h >= 0) tip(h, p); } return; } const prev = pts.get(e.pointerId); pts.set(e.pointerId, p); if (!M || !M.view) return; if (pts.size === 2 && pinch) { const [a, b] = [...pts.values()], d = Math.hypot(a[0] - b[0], a[1] - b[1]); zoomAt((pinch.s * d / pinch.d) / M.view.s, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2); dragMoved = true; return; } const dx = p[0] - prev[0], dy = p[1] - prev[1]; if (Math.abs(dx) + Math.abs(dy) > 0.5) { if (!dragMoved && Math.hypot(dx, dy) < 3) return; dragMoved = true; M.auto = false; M.view.cx -= dx / M.view.s; M.view.cy -= dy / M.view.s; M.target = Object.assign({}, M.view); tip(-1); loop(); } }); const up = e => { const p = pts.get(e.pointerId); pts.delete(e.pointerId); if (pts.size < 2) pinch = null; if (!pts.size) cv.classList.remove('drag'); if (!p || dragMoved || !M || !M.Y) return; const lb = labelBoxes.find(([x, y, w, h]) => p[0] >= x && p[0] <= x + w && p[1] >= y && p[1] <= y + h); const i = nearest(p[0], p[1]); if (i >= 0) select(i); else if (lb) setFocus(M.focus === lb[4] ? -1 : lb[4]); else if (M.sel >= 0) select(-1); const now = Date.now(); if (now - lastTap < 300 && i < 0 && !lb) zoomAt(1.8, p[0], p[1]); lastTap = now; }; cv.addEventListener('pointerup', up); cv.addEventListener('pointercancel', e => { pts.delete(e.pointerId); pinch = null; cv.classList.remove('drag'); }); cv.addEventListener('pointerleave', () => { if (M && M.hover >= 0) { M.hover = -1; tip(-1); loop(); } }); cv.addEventListener('wheel', e => { if (!(e.ctrlKey || e.metaKey)) return; e.preventDefault(); const p = local(e); zoomAt(Math.exp(-e.deltaY * 0.0022), p[0], p[1]); }, { passive: false }); $$('.zoom [data-z]').forEach(b => b.addEventListener('click', () => { if (!M || !M.view) return; if (b.dataset.z === 'fit') { M.auto = !M.done; M.target = bounds(); loop(); return; } zoomAt(b.dataset.z === 'in' ? 1.5 : 1 / 1.5, W / 2, H / 2); })); function tip(i, p) { const t = $('#tip'); if (i < 0 || !p) { t.hidden = true; return; } const pp = M.papers[i]; t.innerHTML = `${esc(pp.title)}<small>${esc([pp.year, DSHORT[M.design[i]], pp.journal].filter(Boolean).join(' · '))}</small>`; t.hidden = false; const tw = t.offsetWidth, th = t.offsetHeight; t.style.left = Math.min(W - tw - 8, p[0] + 14) + 'px'; t.style.top = Math.max(8, Math.min(H - th - 8, p[1] + 14)) + 'px'; } $('#status-skip').addEventListener('click', () => { if (worker) worker.postMessage({ type: 'fast' }); }); /* ================================================================ panels */ function renderHead() { const d = M; $('#m-eyebrow').textContent = d.demo ? 'Demo · real PubMed records' : 'PubMed search'; $('#m-title').textContent = d.demo ? 'COVID-19 research, 2021' : d.q; $('#m-sub').textContent = d.demo ? `${int(d.n)} papers` : `${int(d.n)} of ${int(d.count)} PubMed results with an abstract · ${d.params && d.params.sort === 'date' ? 'most recent' : 'best match'}`; $('#m-demo').hidden = !d.demo; $('#m-demo').textContent = d.source; const dc = {}; d.design.forEach(k => dc[k] = (dc[k] || 0) + 1); const kv = []; if (d.done) kv.push(`<span><b>${d.k}</b> constellations</span>`); if (d.minY) kv.push(`<span>${d.minY === d.maxY ? `<b>${d.minY}</b>` : `${d.minY} → <b>${d.maxY}</b>`}</span>`); kv.push(`<span><b>${int(dc.syn || 0)}</b> systematic reviews</span>`, `<span><b>${int(dc.rct || 0)}</b> randomized trials</span>`); $('#m-kv').innerHTML = kv.join(''); } function renderDetail() { const el = $('#detail'), i = M.sel; if (i < 0) { const dc = {}; M.design.forEach(k => dc[k] = (dc[k] || 0) + 1); el.innerHTML = `<h2 class="h2s">How to read the map</h2> <p class="quiet">Each star is a paper. Papers that use similar words and MeSH terms sit close together, and each colour is a constellation of related papers. Distances inside a constellation mean more than distances between far-apart ones.</p> <p class="quiet">Big stars with rays are systematic reviews and randomized trials. Tap a star to read the paper; tap a label to focus a constellation.</p> <div class="gbars">${DK.filter(k => dc[k]).map(k => `<div class="meta"><span><span class="dbadge d-${k}">${esc(DSHORT[k])}</span> ${int(dc[k])} · ${esc(DNAME[k])}</span></div>`).join('')}</div> ${M.kept != null ? `<p class="quiet">Layout check: ${Math.round(M.kept * 100)}% of each paper's 10 most similar papers sit among its 10 nearest stars (by chance: ${Math.max(1, Math.round(1000 / M.n))}%).</p>` : ''}`; return; } const p = M.papers[i], c = M.assign ? M.assign[i] : -1, d = M.design[i]; const abs = p.abstract ? `<p class="abs clip" id="d-abs">${esc(p.abstract)}</p><button class="linkbtn" type="button" id="d-more">Read the whole abstract</button>` : '<p class="quiet">The demo does not include abstracts. Open the paper on PubMed to read it.</p>'; el.innerHTML = `<div><span class="dbadge d-${d}">${esc(DNAME[d])}</span></div> <h3>${esc(p.title)}</h3> <div class="meta"><span>${esc(p.authors || '')}</span><span>${esc([p.journal, p.year].filter(Boolean).join(' · '))}</span><span>PMID ${esc(p.pmid)}</span></div> ${c >= 0 ? `<button class="cc k${c % 12}" type="button" data-c="${c}"><span class="top"><span class="dot"></span><span class="nm">${esc(M.labels[c].join(' · '))}</span></span></button>` : ''} ${M.tops[i] && M.tops[i].length ? `<p class="why">Placed here for terms like <b>${M.tops[i].slice(0, 5).map(esc).join('</b>, <b>')}</b>.</p>` : ''} ${abs} <div class="links"><a class="btn btn-s" href="https://pubmed.ncbi.nlm.nih.gov/${encodeURIComponent(p.pmid)}/" target="_blank" rel="noopener noreferrer">Open in PubMed</a>${p.doi ? `<a class="btn btn-s" href="https://doi.org/${esc(p.doi)}" target="_blank" rel="noopener noreferrer">DOI</a>` : ''}</div>`; const more = $('#d-more'); if (more) more.addEventListener('click', () => { $('#d-abs').classList.remove('clip'); more.remove(); }); } function select(i) { M.sel = i; renderDetail(); loop(); $$('#plist li').forEach(li => li.classList.toggle('sel', +li.dataset.i === i)); if (i >= 0 && innerWidth < 1100) $('#detail').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'nearest' }); } function setFocus(c) { M.focus = c; M.limit = 40; if (c >= 0 && M.sel >= 0 && M.assign[M.sel] !== c) M.sel = -1; renderDetail(); renderLists(); loop(); } function stats() { const k = M.k, rows = Array.from({ length: k }, () => ({ n: 0, vis: 0, d: {}, years: {} })); if (!M.assign) return rows; M.assign.forEach((c, i) => { const r = rows[c]; r.n++; if (visibleYear(i)) r.vis++; const d = M.design[i]; r.d[d] = (r.d[d] || 0) + 1; const y = M.papers[i].year; if (y) r.years[y] = (r.years[y] || 0) + 1; }); const span = M.minY && M.maxY ? M.maxY - M.minY : 0, recent = y => span >= 4 && y >= M.maxY - 1; const allRecent = M.papers.filter(p => p.year && recent(p.year)).length / M.n; rows.forEach(r => { const share = k => (r.d[k] || 0) / r.n, rec = Object.entries(r.years).reduce((s, [y, v]) => s + (recent(+y) ? v : 0), 0) / r.n; r.flags = []; if (r.d.syn) r.flags.push(['good', `${r.d.syn} systematic review${r.d.syn > 1 ? 's' : ''}`]); if (!r.d.rct && r.n >= 8) r.flags.push(['gap', 'No randomized trials']); if (share('rev') >= 0.5) r.flags.push(['', 'Mostly reviews and opinion']); if (share('case') >= 0.3) r.flags.push(['', 'Many case reports']); if (allRecent > 0 && rec >= 1.5 * allRecent && r.n >= 6) r.flags.push(['up', 'Growing fast']); }); return rows; } function designBar(d, n) { let x = 0; return `<svg class="dbar-svg" viewBox="0 0 100 6" preserveAspectRatio="none" width="100%" height="6" aria-hidden="true">${DK.map(k => { const w = 100 * (d[k] || 0) / n; const r = w ? `<rect x="${x}" y="0" width="${w}" height="6" class="b-${k}"/>` : ''; x += w; return r; }).join('')}</svg>`; } function spark(years) { if (!M.minY || M.maxY - M.minY < 3) return ''; const n = M.maxY - M.minY + 1, max = Math.max(1, ...Object.values(years)), w = 100 / n; return `<svg class="spark" viewBox="0 0 100 22" preserveAspectRatio="none" aria-hidden="true">${Array.from({ length: n }, (_, k) => { const v = years[M.minY + k] || 0, h = v ? 2 + 20 * v / max : 0; return h ? `<rect x="${k * w + 0.4}" y="${22 - h}" width="${Math.max(0.6, w - 0.8)}" height="${h}" fill="currentColor" opacity=".75"/>` : ''; }).join('')}</svg>`; } function renderLists() { const cl = $('#clist'), mx = $('#matrix'); if (!M.done) { cl.innerHTML = '<p class="quiet">Constellations appear when the layout settles.</p>'; mx.innerHTML = ''; renderPapers(); return; } const rows = stats(); cl.innerHTML = rows.map((r, c) => `<button class="cc k${c % 12}" type="button" data-c="${c}" aria-pressed="${M.focus === c}"> <span class="top"><span class="dot"></span><span class="nm">${esc(M.labels[c].join(' · '))}</span><span class="n">${r.vis === r.n ? int(r.n) : `${int(r.vis)} / ${int(r.n)}`}</span></span> ${designBar(r.d, r.n)}${spark(r.years)} ${r.flags.length ? `<span class="flags">${r.flags.map(([cls, t]) => `<span class="flag ${cls}">${esc(t)}</span>`).join('')}</span>` : ''}</button>`).join(''); const heat = (v, n) => !v ? 'h0' : v / n < 0.1 ? 'h1' : v / n < 0.25 ? 'h2' : v / n < 0.5 ? 'h3' : 'h4'; mx.innerHTML = `<thead><tr><th>Constellation</th>${DES.map(d => `<th title="${esc(d.name)}">${esc(d.short)}</th>`).join('')}<th>All</th></tr></thead><tbody>${rows.map((r, c) => `<tr data-c="${c}" class="${M.focus === c ? 'foc' : ''}"><td><span class="dot k${c % 12}"></span> ${esc(M.labels[c].slice(0, 2).join(' · '))}</td>${DK.map(k => { const v = r.d[k] || 0, gap = !v && (k === 'rct' || k === 'syn') && r.n >= 8; return `<td class="v ${gap ? 'gapc' : heat(v, r.n)}">${gap ? 'gap' : v || '·'}</td>`; }).join('')}<td class="v">${int(r.n)}</td></tr>`).join('')}</tbody>`; renderPapers(); } function renderPapers() { const idx = []; for (let i = 0; i < M.n; i++) if ((M.focus < 0 || (M.assign && M.assign[i] === M.focus)) && visibleYear(i)) idx.push(i); idx.sort((a, b) => M.psort === 'year' ? (M.papers[b].year || 0) - (M.papers[a].year || 0) || RANK[M.design[a]] - RANK[M.design[b]] : RANK[M.design[a]] - RANK[M.design[b]] || (M.papers[b].year || 0) - (M.papers[a].year || 0)); $('#p-title').textContent = M.focus >= 0 ? `Papers in “${M.labels[M.focus].slice(0, 2).join(' · ')}” (${int(idx.length)})` : `Papers (${int(idx.length)})`; $('#plist').innerHTML = idx.slice(0, M.limit).map(i => { const p = M.papers[i]; return `<li data-i="${i}" class="${i === M.sel ? 'sel' : ''}"><button type="button" data-i="${i}"><span class="y">${esc(p.year || '')}</span><span class="dbadge d-${M.design[i]}">${esc(DSHORT[M.design[i]])}</span><span>${esc(p.title)}<span class="j">${esc(p.journal || '')}</span></span></button></li>`; }).join(''); $('#p-more').hidden = idx.length <= M.limit; } /* ---------- years */ function setupYears() { const row = $('#years'), r = $('#y-range'); row.hidden = !(M.minY && M.maxY - M.minY >= 3); if (row.hidden) return; r.min = M.minY; r.max = M.maxY; r.value = M.maxY; $('#y-now').textContent = `up to ${M.maxY}`; } function setYear(y) { M.yearMax = y; $('#y-range').value = y; $('#y-now').textContent = `up to ${y}`; if (M.sel >= 0 && !visibleYear(M.sel)) { M.sel = -1; renderDetail(); } renderLists(); loop(); } function play() { if (playT) { stopPlay(); return; } let y = M.yearMax >= M.maxY ? M.minY : M.yearMax + 1; setYear(y); $('#y-play').textContent = 'Pause'; playT = setInterval(() => { if (y >= M.maxY) { stopPlay(); return; } setYear(++y); }, 650); } function stopPlay() { if (playT) { clearInterval(playT); playT = null; } const b = $('#y-play'); if (b) b.textContent = 'Play the years'; } $('#y-play').addEventListener('click', play); $('#y-range').addEventListener('input', e => { stopPlay(); setYear(+e.target.value); }); /* ================================================================ exports, links */ function csv() { const esc2 = s => { s = String(s == null ? '' : s); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const rows = [['pmid', 'year', 'title', 'journal', 'authors', 'study_design', 'constellation', 'constellation_terms', 'doi', 'pubmed_url']]; M.papers.forEach((p, i) => rows.push([p.pmid, p.year || '', p.title, p.journal, p.authors, DNAME[M.design[i]], M.assign ? M.assign[i] + 1 : '', M.assign ? M.labels[M.assign[i]].join('; ') : '', p.doi || '', `https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/`])); download(new Blob(['' + rows.map(r => r.map(esc2).join(',')).join('\r\n')], { type: 'text/csv;charset=utf-8' }), `constellations-${slug()}.csv`); } function png() { const out = document.createElement('canvas'); out.width = cv.width; out.height = cv.height; const x = out.getContext('2d'); x.fillStyle = '#08080d'; x.fillRect(0, 0, out.width, out.height); x.drawImage(cv, 0, 0); x.font = `${12 * DPR}px ui-monospace, Menlo, monospace`; x.fillStyle = 'rgba(203,213,225,.7)'; x.textBaseline = 'bottom'; x.fillText(`${M.demo ? 'COVID-19 research, 2021 (demo)' : M.q} · ${M.n} PubMed records · Literature Constellations, mafflab.xyz`, 14 * DPR, out.height - 12 * DPR); out.toBlob(b => b && download(b, `constellations-${slug()}.png`), 'image/png'); } const slug = () => String(M.demo ? 'covid-demo' : M.q).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'map'; function download(blob, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); toast('Downloaded ' + name); } function setHash() { let h = ''; if (M.demo) h = '#demo'; else if (M.params) { const p = new URLSearchParams(); Object.entries(M.params).forEach(([k, v]) => { if (v != null && v !== '') p.set(k, v); }); h = '#' + p.toString(); } try { history.replaceState(null, '', location.pathname + location.search + h); } catch (e) { } } function toast(t) { const el = $('#toast'); el.textContent = t; el.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => el.classList.remove('on'), 2200); } async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { const ta = document.createElement('textarea'); ta.value = text; ta.className = 'sr-only'; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (e2) { } ta.remove(); return ok; } } /* ================================================================ events */ $('#f-search').addEventListener('submit', e => { e.preventDefault(); search($('#q').value); }); $$('#opts [data-n]').forEach(b => b.addEventListener('click', () => { OPT.n = +b.dataset.n; $$('#opts [data-n]').forEach(x => x.setAttribute('aria-pressed', String(x === b))); })); $$('#opts [data-sort]').forEach(b => b.addEventListener('click', () => { OPT.sort = b.dataset.sort; $$('#opts [data-sort]').forEach(x => x.setAttribute('aria-pressed', String(x === b))); })); $$('[data-ps]').forEach(b => b.addEventListener('click', () => { M.psort = b.dataset.ps; $$('[data-ps]').forEach(x => x.setAttribute('aria-pressed', String(x === b))); renderPapers(); })); document.addEventListener('click', e => { const q = e.target.closest('[data-q]'); if (q) { search(q.dataset.q); return; } if (e.target.closest('[data-demo]')) { demo(); return; } const c = e.target.closest('[data-c]'); if (c && M) { const k = +c.dataset.c; setFocus(M.focus === k ? -1 : k); if (innerWidth < 1100) sky.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' }); return; } const pi = e.target.closest('#plist [data-i]'); if (pi && M) { select(+pi.dataset.i); } }); $('#p-more').addEventListener('click', () => { M.limit = 1e9; renderPapers(); }); $('#a-csv').addEventListener('click', csv); $('#a-png').addEventListener('click', png); $('#a-new').addEventListener('click', () => { if (worker) { worker.terminate(); worker = null; } M = null; try { history.replaceState(null, '', location.pathname); } catch (e) { } showFind(); $('#q').focus(); }); $('#share').addEventListener('click', async () => { setHash(); toast(await copyText(location.href) ? 'Link copied: it runs this search again' : 'Copy the address bar to share'); }); new ResizeObserver(() => { size(); if (M) loop(); else draw(0); }).observe(sky); document.addEventListener('keydown', e => { if (!M || $('#map').hidden || /INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)) return; if (e.key === 'Escape') { if (M.sel >= 0) select(-1); else if (M.focus >= 0) setFocus(-1); } }); /* ================================================================ boot */ function route() { const h = location.hash.slice(1); if (h === 'demo') { demo(); return true; } const p = new URLSearchParams(h), q = p.get('q'); if (q) { const n = [150, 300, 500].includes(+p.get('n')) ? +p.get('n') : 300, sort = p.get('sort') === 'date' ? 'date' : 'relevance'; OPT.n = n; OPT.sort = sort; $$('#opts [data-n]').forEach(x => x.setAttribute('aria-pressed', String(+x.dataset.n === n))); $$('#opts [data-sort]').forEach(x => x.setAttribute('aria-pressed', String(x.dataset.sort === sort))); if (p.get('from')) $('#from').value = p.get('from'); if (p.get('to')) $('#to').value = p.get('to'); search(q, { n, sort, from: parseInt(p.get('from'), 10) || null, to: parseInt(p.get('to'), 10) || null }); return true; } return false; } route(); /* ---------- PWA: offline + install ---------- */ const off = $('#offline'); const net = () => { off.hidden = navigator.onLine; }; addEventListener('online', net); addEventListener('offline', net); net(); if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('sw.js', { scope: './' }).catch(() => { }); let prompt = null; const ib = $('#install'); addEventListener('beforeinstallprompt', e => { e.preventDefault(); prompt = e; ib.hidden = false; }); ib.addEventListener('click', async () => { if (!prompt) return; prompt.prompt(); try { await prompt.userChoice; } catch (e) { } prompt = null; ib.hidden = true; }); addEventListener('appinstalled', () => { ib.hidden = true; });})();index.html122 lines
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'"><meta name="referrer" content="no-referrer"><title>Literature Constellations · a PubMed search as a map of topics</title><meta name="description" content="Search PubMed and watch a few hundred papers arrange themselves into a star map of topics. See which constellations have randomized trials, which only have reviews, and where the field is growing."><meta name="theme-color" content="#0D0D12"><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"><link rel="stylesheet" href="mafflab-theme.css"><link rel="stylesheet" href="style.css"><script src="engine.js" defer></script><script src="app.js" defer></script></head><body><a class="skip" href="#main">Skip to content</a><header class="app-head"> <span class="lc-logo" aria-hidden="true"><svg viewBox="0 0 32 32"><path d="M5 22 12 9l8 6 7-9" fill="none" stroke="currentColor" stroke-width="1.6" opacity=".7"/><circle cx="5" cy="22" r="2.6" fill="currentColor"/><circle cx="12" cy="9" r="2.2" fill="currentColor"/><circle cx="20" cy="15" r="3" fill="currentColor"/><circle cx="27" cy="6" r="2" fill="currentColor"/><circle cx="24" cy="25" r="1.6" fill="currentColor" opacity=".6"/></svg></span> <a class="app-name home" href="./">Literature Constellations</a> <span class="app-tag">A PubMed search as a map of topics</span> <span class="spacer"></span> <span class="offline" id="offline" hidden>Offline: only the demo works</span> <button class="btn btn-s" id="share" type="button" hidden title="Copy a link that runs this search again">Copy link</button> <button class="btn btn-s" id="install" type="button" hidden>Install</button></header><main id="main" class="lc-main"> <section class="find" id="find" aria-labelledby="find-title"> <div class="find-copy"> <h1 id="find-title">Map a literature search</h1> <p>Search PubMed, then watch up to 500 papers arrange themselves by what they talk about. Similar papers gather into constellations; the brightest stars are randomized trials and systematic reviews. See at a glance where the evidence is strong, where it is thin, and what is growing.</p> </div> <form class="search" id="f-search" role="search" autocomplete="off"> <label class="sr-only" for="q">PubMed query</label> <input class="input" id="q" type="search" maxlength="300" placeholder="Any PubMed query, e.g. GLP-1 receptor agonists AND obesity" enterkeyhint="search"> <button class="btn btn-mag" type="submit">Map it</button> </form> <div class="opts" id="opts"> <div class="seg" role="group" aria-label="Number of papers"><button type="button" data-n="150">150</button><button type="button" data-n="300" aria-pressed="true">300</button><button type="button" data-n="500">500</button></div> <div class="seg" role="group" aria-label="Which papers"><button type="button" data-sort="relevance" aria-pressed="true">Best match</button><button type="button" data-sort="date">Most recent</button></div> <label class="yr">From <input class="input" id="from" type="number" inputmode="numeric" min="1900" max="2100" placeholder="year"></label> <label class="yr">to <input class="input" id="to" type="number" inputmode="numeric" min="1900" max="2100" placeholder="year"></label> </div> <div class="chips" aria-label="Examples"> <span>Try</span> <button type="button" data-q="GLP-1 receptor agonists obesity">GLP-1 and obesity</button> <button type="button" data-q="CAR-T cell therapy lymphoma">CAR-T in lymphoma</button> <button type="button" data-q="long COVID fatigue">long COVID</button> <button type="button" data-q="psoriasis biologics">psoriasis biologics</button> <button type="button" class="demo" data-demo>Demo: COVID-19, 2021</button> </div> <div id="msg" aria-live="polite"></div> </section> <section class="map" id="map" hidden aria-labelledby="m-title"> <div class="card head"> <div class="head-top"> <div> <p class="eyebrow" id="m-eyebrow">PubMed search</p> <h1 id="m-title"></h1> <p class="sub" id="m-sub"></p> </div> <div class="actions"> <button class="btn btn-s" id="a-csv" type="button" disabled>Papers (CSV)</button> <button class="btn btn-s" id="a-png" type="button" disabled>Map (PNG)</button> <button class="btn btn-s" id="a-new" type="button">New search</button> </div> </div> <div class="kv" id="m-kv"></div> <div class="note" id="m-demo" hidden></div> </div> <div class="stage-row"> <div class="card stage"> <div class="sky" id="sky"> <canvas id="c-sky" aria-label="Map of papers: similar papers sit close together" role="img"></canvas> <div class="status" id="status"><span id="status-t">Reading papers…</span><div class="pbar"><i id="status-b"></i></div><button class="linkbtn" type="button" id="status-skip">Skip the animation</button></div> <div class="zoom" role="group" aria-label="Zoom"><button type="button" data-z="in" aria-label="Zoom in">+</button><button type="button" data-z="out" aria-label="Zoom out">−</button><button type="button" data-z="fit">Fit</button></div> <div class="tip" id="tip" hidden></div> <div class="legend-sky" id="legend-sky"></div> </div> <div class="years" id="years" hidden> <button class="btn btn-mag btn-s" id="y-play" type="button">Play the years</button> <input type="range" id="y-range" aria-label="Show papers published up to this year"> <span class="now" id="y-now"></span> </div> </div> <aside class="card detail" id="detail" aria-live="polite"></aside> </div> <div class="card"> <div class="bar"><h2 class="h2s">Constellations</h2><span class="hint-r" id="c-hint">named after their most distinctive terms</span></div> <div class="clist" id="clist"></div> </div> <div class="card"> <div class="bar"><h2 class="h2s">Where is the evidence?</h2><span class="hint-r">papers per constellation and study design</span></div> <div class="tbl-wrap"><table class="matrix" id="matrix"></table></div> <p class="small">Designs come from PubMed publication types and, when those are missing, from words in the title and abstract. This maps what your query returned, not everything published: a gap here is a question to check, not a conclusion.</p> </div> <div class="card"> <div class="bar"><h2 class="h2s" id="p-title">Papers</h2> <div class="seg small" role="group" aria-label="Sort papers"><button type="button" data-ps="design" aria-pressed="true">Strongest design</button><button type="button" data-ps="year">Newest</button></div></div> <ol class="plist" id="plist"></ol> <button class="btn btn-s" id="p-more" type="button" hidden>Show all</button> </div> </section></main><footer class="app-foot"> <span>Records: <a href="https://pubmed.ncbi.nlm.nih.gov/" rel="noopener noreferrer" target="_blank">PubMed</a>, U.S. National Library of Medicine, through NCBI E-utilities. Literature Constellations is independent and not endorsed by NLM.</span> <span>A map for exploring, not a systematic review. Part of <a href="https://mafflab.xyz/" rel="noopener">mafflab</a> · MIT.</span></footer><div class="toast" id="toast" role="status" aria-live="polite"></div></body></html>style.css149 lines
/* Literature Constellations: layout and components on top of mafflab-theme.css */:root{--c0:#FF2A85;--c1:#00F0FF;--c2:#B57BFF;--c3:#F4FF5A;--c4:#FB923C;--c5:#34D399;--c6:#38BDF8;--c7:#F472B6;--c8:#A3E635;--c9:#2DD4BF;--c10:#C4B5FD;--c11:#FCA5A5}body{overflow-x:hidden}.skip{position:absolute;left:-999px;top:8px;z-index:50;background:var(--bg-2);color:var(--white);padding:8px 12px;border:1px solid var(--cyan);border-radius:6px}.skip:focus{left:8px}.app-head{flex-wrap:wrap;gap:10px 14px;padding-top:12px;padding-bottom:12px}.app-name.home{text-decoration:none}.lc-logo{width:30px;height:30px;color:var(--cyan);filter:drop-shadow(0 0 6px rgba(0,240,255,.6))}.lc-logo svg{width:30px;height:30px}.lc-main{max-width:1320px;margin:0 auto;padding:clamp(18px,3vw,34px) var(--pad) 50px;display:grid;gap:20px}.card:hover{box-shadow:none}.h2s{margin:0}.bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;margin-bottom:12px}.hint-r{font-size:.76rem;color:var(--slate)}.small{margin-top:10px;font-size:.78rem;color:var(--slate)}.linkbtn{background:none;border:0;padding:0;color:var(--cyan);font:inherit;text-decoration:underline;cursor:pointer}/* search */.find{display:grid;gap:16px;padding:clamp(10px,4vw,40px) 0 10px}.find-copy{max-width:780px}.find h1{font-size:clamp(1.8rem,5vw,3.2rem);margin-bottom:12px;text-shadow:-2px 0 rgba(255,42,133,.5),2px 0 rgba(0,240,255,.4)}.find-copy p{font-size:1.05rem}.search{display:grid;grid-template-columns:1fr auto;gap:10px;max-width:780px}.search .input{font-size:1rem;padding:14px 16px}.opts{display:flex;flex-wrap:wrap;gap:10px;align-items:center;max-width:780px}.opts .seg button{font-size:.72rem}.yr{display:inline-flex;align-items:center;gap:6px;font-family:var(--f-mono);font-size:.72rem;font-weight:400;color:var(--slate)}.yr .input{width:92px;padding:7px 9px;font-family:var(--f-mono);font-size:.78rem}.chips{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:.8rem;color:var(--slate)}.chips button{font-family:var(--f-mono);font-size:.76rem;padding:7px 11px;border-radius:999px;border:1px solid var(--line-c);background:rgba(13,13,18,.6);color:var(--silver);cursor:pointer}.chips button:hover{color:var(--white);border-color:var(--cyan)}.chips button.demo{border-color:var(--line-m);color:var(--mag)}.msg{padding:14px 16px;border:1px solid var(--line-m);border-radius:var(--r);background:rgba(255,42,133,.07);color:var(--white);font-size:.9rem;max-width:780px}.msg button{margin-top:10px}/* head */.head{display:grid;gap:12px}.head-top{display:flex;flex-wrap:wrap;justify-content:space-between;gap:14px}.eyebrow{font-family:var(--f-mono);font-size:.7rem;letter-spacing:.12em;text-transform:uppercase;color:var(--cyan)}#m-title{font-size:clamp(1.3rem,3.2vw,2rem);margin:4px 0 6px;overflow-wrap:anywhere}.sub{font-size:.86rem;color:var(--silver)}.actions{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start}.kv{display:flex;flex-wrap:wrap;gap:8px}.kv span{font-family:var(--f-mono);font-size:.74rem;padding:5px 9px;border:1px solid var(--line);border-radius:4px;background:rgba(13,13,18,.5)}.kv b{color:var(--white);font-weight:600}.note{padding:10px 12px;border:1px dashed var(--warn);border-radius:var(--r);color:var(--warn);font-size:.84rem}/* sky */.stage-row{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:20px;align-items:start}.stage{padding:0;overflow:hidden}.sky{position:relative;height:min(72vh,720px);min-height:420px;background:radial-gradient(ellipse at 50% 40%,rgba(122,0,255,.16),transparent 70%),#08080d;border-radius:var(--r) var(--r) 0 0}.sky canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab}.sky canvas.drag{cursor:grabbing}.status{position:absolute;left:16px;top:14px;right:16px;max-width:420px;display:grid;gap:6px;padding:10px 12px;border:1px solid var(--line-c);border-radius:6px;background:rgba(8,8,13,.82);font-family:var(--f-mono);font-size:.74rem;color:var(--cyan)}.status[hidden]{display:none}.pbar{height:3px;border-radius:2px;background:rgba(0,240,255,.12);overflow:hidden}.pbar i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--purple),var(--mag),var(--cyan));box-shadow:0 0 10px var(--mag)}.status .linkbtn{justify-self:start;font-size:.72rem}.zoom{position:absolute;right:12px;top:12px;display:grid;gap:6px}.zoom button{width:36px;height:34px;border:1px solid var(--line-c);border-radius:6px;background:rgba(8,8,13,.8);color:var(--white);font-family:var(--f-mono);font-size:.8rem;cursor:pointer}.zoom button:hover{border-color:var(--cyan)}.tip{position:absolute;pointer-events:none;max-width:300px;padding:8px 10px;border:1px solid var(--line-c);border-radius:6px;background:rgba(8,8,13,.94);color:var(--white);font-size:.8rem;line-height:1.4;z-index:5}.tip small{display:block;margin-top:4px;font-family:var(--f-mono);font-size:.68rem;color:var(--slate)}.legend-sky{position:absolute;left:14px;bottom:12px;display:flex;flex-wrap:wrap;gap:4px 14px;font-family:var(--f-mono);font-size:.66rem;color:var(--silver);pointer-events:none;text-shadow:0 0 4px #000}.legend-sky span{display:inline-flex;align-items:center;gap:6px}.legend-sky i{display:inline-block;border-radius:50%;background:#fff;box-shadow:0 0 6px #fff}.legend-sky .s1{width:10px;height:10px}.legend-sky .s2{width:7px;height:7px}.legend-sky .s3{width:4px;height:4px;opacity:.7}.years{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:12px 16px;border-top:1px solid var(--line)}.years[hidden]{display:none}.now{font-family:var(--f-mono);font-size:.8rem;color:var(--white);white-space:nowrap}input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:28px;background:none;margin:0;cursor:pointer}input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:linear-gradient(90deg,var(--purple),var(--mag))}input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:linear-gradient(90deg,var(--purple),var(--mag))}input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;margin-top:-7px;border-radius:50%;background:var(--white);border:2px solid var(--mag);box-shadow:0 0 12px rgba(255,42,133,.75)}input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--white);border:2px solid var(--mag)}/* detail */.detail{position:sticky;top:76px;display:grid;gap:12px;max-height:calc(100vh - 96px);overflow:auto;overscroll-behavior:contain}.detail h3{font-family:var(--f-display);font-size:1.05rem;line-height:1.3;color:var(--white)}.detail .meta{font-family:var(--f-mono);font-size:.72rem;color:var(--silver);display:grid;gap:3px}.detail .abs{font-size:.86rem;line-height:1.6;color:var(--silver);white-space:pre-line}.detail .abs.clip{display:-webkit-box;-webkit-line-clamp:9;-webkit-box-orient:vertical;overflow:hidden}.detail .why{font-size:.8rem;color:var(--slate)}.detail .why b{color:var(--white);font-weight:500}.detail .links{display:flex;flex-wrap:wrap;gap:8px}.quiet{font-size:.86rem;color:var(--slate)}.dbadge{display:inline-flex;align-items:center;font-family:var(--f-mono);font-size:.64rem;letter-spacing:.06em;text-transform:uppercase;padding:3px 7px;border-radius:3px;border:1px solid currentColor;white-space:nowrap}.d-syn{color:var(--warn)}.d-rct{color:var(--cyan)}.d-ct{color:#7FDBFF}.d-obs{color:var(--silver)}.d-case{color:#FCA5A5}.d-pre{color:var(--ok)}.d-rev{color:#C4B5FD}.d-oth{color:var(--slate)}.dot{display:inline-block;width:10px;height:10px;border-radius:50%;flex:none;box-shadow:0 0 8px currentColor;background:currentColor}.k0{color:var(--c0)}.k1{color:var(--c1)}.k2{color:var(--c2)}.k3{color:var(--c3)}.k4{color:var(--c4)}.k5{color:var(--c5)}.k6{color:var(--c6)}.k7{color:var(--c7)}.k8{color:var(--c8)}.k9{color:var(--c9)}.k10{color:var(--c10)}.k11{color:var(--c11)}/* constellations */.clist{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}.cc{display:grid;gap:8px;text-align:left;width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:var(--r);background:rgba(13,13,18,.55);color:var(--silver);font:inherit;cursor:pointer}.cc:hover,.cc[aria-pressed="true"]{border-color:currentColor}.cc .top{display:flex;align-items:center;gap:10px}.cc .nm{font-family:var(--f-display);font-weight:600;color:var(--white);font-size:.95rem;line-height:1.25}.cc .n{margin-left:auto;font-family:var(--f-mono);font-size:.74rem;color:var(--silver)}.cc .flags{display:flex;flex-wrap:wrap;gap:6px}.flag{font-family:var(--f-mono);font-size:.62rem;padding:2px 6px;border-radius:3px;border:1px solid var(--line);color:var(--silver)}.flag.gap{border-color:rgba(255,42,133,.5);color:#ff9ac4}.flag.good{border-color:rgba(244,255,90,.5);color:var(--warn)}.flag.up{border-color:rgba(59,255,158,.5);color:var(--ok)}.dbar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:rgba(255,255,255,.04)}.dbar i{display:block;height:100%}.b-syn{background:var(--warn)}.b-rct{background:var(--cyan)}.b-ct{background:#7FDBFF}.b-obs{background:#94A3B8}.b-case{background:#FCA5A5}.b-pre{background:var(--ok)}.b-rev{background:#8B7FD6}.b-oth{background:#334155}.spark{display:block;width:100%;height:22px}/* matrix */.tbl-wrap{overflow-x:auto}.matrix{width:100%;border-collapse:collapse;font-size:.8rem}.matrix th,.matrix td{padding:7px 9px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}.matrix th{font-family:var(--f-mono);font-size:.64rem;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--slate)}.matrix th:first-child,.matrix td:first-child{text-align:left;white-space:normal;min-width:180px}.matrix td:first-child{color:var(--white)}.matrix td.v{font-family:var(--f-mono)}.matrix td.h0{color:var(--slate)}.matrix td.h1{background:rgba(0,240,255,.06)}.matrix td.h2{background:rgba(0,240,255,.13)}.matrix td.h3{background:rgba(0,240,255,.22);color:var(--white)}.matrix td.h4{background:rgba(0,240,255,.34);color:var(--white)}.matrix td.gapc{color:#ff9ac4;background:rgba(255,42,133,.07)}.matrix tr.foc td{outline:1px solid rgba(255,42,133,.35)}.matrix tr{cursor:pointer}/* papers */.plist{list-style:none;margin:0;padding:0;display:grid}.plist li button{display:grid;grid-template-columns:52px 86px 1fr;gap:12px;align-items:start;width:100%;padding:9px 6px;border:0;border-bottom:1px solid var(--line);background:none;color:var(--white);font:inherit;font-size:.86rem;text-align:left;cursor:pointer}.plist li button:hover,.plist li.sel button{background:rgba(0,240,255,.05)}.plist .y{font-family:var(--f-mono);font-size:.74rem;color:var(--slate)}.plist .j{display:block;font-size:.74rem;color:var(--slate);margin-top:2px}.seg.small button{font-size:.68rem;padding:6px 9px}.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,20px);opacity:0;pointer-events:none;z-index:60;padding:10px 16px;border:1px solid var(--line-c);border-radius:6px;background:rgba(13,13,18,.95);color:var(--white);font-family:var(--f-mono);font-size:.8rem;transition:opacity .2s,transform .2s}.toast.on{opacity:1;transform:translate(-50%,0)}.app-foot{display:flex;flex-wrap:wrap;gap:6px 24px;justify-content:space-between;max-width:1320px}.btn:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}@media (max-width:1100px){.stage-row{grid-template-columns:1fr}.detail{position:static;max-height:none}}@media (max-width:640px){ .app-tag{display:none} .search{grid-template-columns:1fr} .sky{height:58vh;min-height:360px} .years{grid-template-columns:1fr auto}.years .btn{grid-column:1 / -1;justify-self:start} .plist li button{grid-template-columns:44px 1fr}.plist li button .dbadge{grid-column:2;justify-self:start}.plist li button span:last-child{grid-column:1 / -1}}@media (pointer:coarse){.card,.app-head{backdrop-filter:none;-webkit-backdrop-filter:none}.search .input{font-size:16px}}.dbar-svg{display:block;width:100%;height:6px;border-radius:3px;background:rgba(255,255,255,.04)}.dbar-svg .b-syn{fill:#F4FF5A}.dbar-svg .b-rct{fill:#00F0FF}.dbar-svg .b-ct{fill:#7FDBFF}.dbar-svg .b-obs{fill:#94A3B8}.dbar-svg .b-case{fill:#FCA5A5}.dbar-svg .b-pre{fill:#3BFF9E}.dbar-svg .b-rev{fill:#8B7FD6}.dbar-svg .b-oth{fill:#334155}.gbars{display:grid;gap:6px}.lc-main>*,.map>*,.stage-row>*{min-width:0}.map{display:grid;gap:20px}.map[hidden]{display:none}.cc.k0{color:var(--c0)}.cc.k1{color:var(--c1)}.cc.k2{color:var(--c2)}.cc.k3{color:var(--c3)}.cc.k4{color:var(--c4)}.cc.k5{color:var(--c5)}.cc.k6{color:var(--c6)}.cc.k7{color:var(--c7)}.cc.k8{color:var(--c8)}.cc.k9{color:var(--c9)}.cc.k10{color:var(--c10)}.cc.k11{color:var(--c11)}sw.js22 lines
/* Literature Constellations service worker: the app shell and the demo work offline. Searches through pubmed.php always go to the network and are never stored here, so nothing about what you searched is kept on the device. Change VERSION on every release. */const VERSION = 'literature-constellations-1.0.0';const SHELL = ['./', './index.html', './app.js', './engine.js', './layout-worker.js', './style.css', './mafflab-theme.css', './demo.json', './manifest.webmanifest', './fonts/space-grotesk.woff2', './fonts/inter.woff2', './icons/icon.svg', './icons/icon-192.png', './icons/icon-512.png', './icons/maskable-512.png', './icons/apple-touch-icon.png'];const PREFIX = 'literature-constellations-';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(ks => Promise.all(ks.filter(k => k.startsWith(PREFIX) && 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('.php')) return; if (e.request.mode === 'navigate') { e.respondWith(fetch(e.request).then(r => { const c = r.clone(); caches.open(VERSION).then(x => x.put('./index.html', c)); return r; }).catch(() => caches.match('./index.html'))); return; } e.respondWith(caches.match(e.request, { ignoreSearch: true }).then(hit => hit || fetch(e.request)));});manifest.webmanifest21 lines
{ "id": "./", "name": "Literature Constellations: a PubMed search as a map of topics", "short_name": "Constellations", "description": "Search PubMed and watch papers arrange themselves into constellations of topics, with the strength of evidence in each.", "start_url": "./", "scope": "./", "display": "standalone", "orientation": "any", "background_color": "#0D0D12", "theme_color": "#0D0D12", "lang": "en", "categories": ["medical", "education", "productivity"], "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" } ]}tests/engine.test.js45 lines
/* Engine checks on real PubMed records saved from pubmed.php: node tests/engine.test.js records.json */'use strict';const LC = require('../engine.js'), fs = require('fs');let fails = 0, n = 0;const ok = (c, label, d) => { n++; if (!c) fails++; console.log(`${c ? 'PASS' : 'FAIL'} ${label}${d !== undefined ? ' ' + d : ''}`); };ok(LC.stem('vaccines') === 'vaccine' && LC.stem('studies') === 'study' && LC.stem('diabetes') === 'diabetes' && LC.stem('sars') === 'sars', 'plural stemming with exceptions');const tk = LC.tokenize('The mental health of patients in intensive care units');ok(tk.map(t => t.s).join(',') === 'mental,health,intensive,care,unit', 'tokenizer drops stopwords and keeps word positions', tk.map(t => t.s).join(','));ok(LC.design({ title: 'Efficacy of X: a randomized, placebo-controlled trial', types: ['Journal Article'] }) === 'rct' && LC.design({ title: 'X and Y: a systematic review and meta-analysis', types: [] }) === 'syn' && LC.design({ title: 'A rare presentation: case report', types: [] }) === 'case' && LC.design({ title: 'Outcomes in a retrospective cohort', types: [] }) === 'obs' && LC.design({ title: 'Mechanisms in mice', abstract: 'We treated mice', types: [] }) === 'pre' && LC.design({ title: 'Lessons for practice', types: ['Editorial'] }) === 'rev', 'study design rules on simple titles');const file = process.argv[2];if (file) { const papers = JSON.parse(fs.readFileSync(file, 'utf8')).papers, N = papers.length; let t0 = Date.now(); const vz = LC.vectorize(papers), S = LC.similarity(vz.vecs, vz.terms.length); const tVec = Date.now() - t0; t0 = Date.now(); const st = LC.tsneInit(S, N, { seed: 7 }); while (!LC.tsneStep(st)); const tT = Date.now() - t0; t0 = Date.now(); const cl = LC.cluster(st.Y, N, 7), edges = LC.constellationEdges(st.Y, cl.assign, cl.k), labels = LC.clusterLabels(vz, cl.assign, cl.k); const tC = Date.now() - t0; console.log(` ${N} papers, ${vz.terms.length} terms; vectors ${tVec} ms, t-SNE ${st.iters} iterations ${tT} ms, clusters ${tC} ms`); const kept = LC.neighbourhoodKept(S, st.Y, N, 10); ok(kept > 0.3, 'map keeps text neighbours together (10 nearest, random ≈ 0.02)', kept.toFixed(3)); ok(cl.k >= 4 && cl.silhouette > 0.3, 'clusters are coherent regions of the map', `k ${cl.k}, silhouette ${cl.silhouette.toFixed(2)}`); ok(edges.length === N - cl.k, 'constellation lines form a tree inside each cluster', `${edges.length} edges`); const sizes = Array.from({ length: cl.k }, (_, c) => cl.assign.filter(a => a === c).length); labels.forEach((l, c) => console.log(` ${String(sizes[c]).padStart(3)} ${l.join(' · ')}`)); // topic coherence: papers with a distinctive word in the title should gather in few clusters // topic coherence: papers with a distinctive word in the title should gather in one or two of the k clusters const coh = w => { const idx = papers.map((p, i) => [p, i]).filter(([p]) => new RegExp(w, 'i').test(p.title)).map(x => x[1]); const c = {}; idx.forEach(i => c[cl.assign[i]] = (c[cl.assign[i]] || 0) + 1); const v = Object.values(c).sort((a, b) => b - a); return [idx.length, ((v[0] || 0) + (v[1] || 0)) / (idx.length || 1)]; }; for (const w of ['vaccin', 'anxiety|depress|mental', 'child|pediatric|paediatric']) { const [m, share] = coh(w); ok(m < 8 || share > Math.min(0.9, 5 / cl.k), `papers about /${w}/ concentrate in two of ${cl.k} clusters (over 2.5 times chance)`, `${m} papers, ${(share * 100).toFixed(0)}% in the top two (by chance ≈ ${Math.round(200 / cl.k)}%)`); } const ds = {}; papers.forEach(p => { const d = LC.design(p); ds[d] = (ds[d] || 0) + 1; }); console.log(' designs:', JSON.stringify(ds));}console.log(`\n${n - fails}/${n} passed`);process.exit(fails ? 1 : 0);tests/test_relay.py82 lines
"""End-to-end test of pubmed.php against tests/mock_eutils.py. Needs php (7.4+) and python3. python3 tests/test_relay.py FIXTURES_DIR"""import json, os, shutil, subprocess, sys, tempfile, time, urllib.request, urllib.errorfrom pathlib import PathAPP = Path(__file__).resolve().parent.parentFIX = Path(sys.argv[1]).resolve()fails = 0def check(ok, label, detail=''): global fails fails += 0 if ok else 1 print(f"{'PASS' if ok else 'FAIL'} {label}{(' ' + str(detail)) if detail != '' else ''}")def get(url, headers=None): try: with urllib.request.urlopen(urllib.request.Request(url, headers=headers or {}), timeout=120) as r: return r.status, json.loads(r.read().decode()) except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode() or '{}')def wait(port): for _ in range(60): try: urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=1); return except urllib.error.HTTPError: return except Exception: time.sleep(0.1)procs = [subprocess.Popen([sys.executable, str(APP / 'tests' / 'mock_eutils.py'), str(FIX), '8911'])]cache = tempfile.mkdtemp(prefix='lc-cache-')try: wait(8911) env = dict(os.environ, LC_UPSTREAM='http://127.0.0.1:8911/entrez/eutils/', LC_CACHE=cache) procs.append(subprocess.Popen(['php', '-S', '127.0.0.1:8912', '-t', str(APP)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) wait(8912) R = 'http://127.0.0.1:8912/pubmed.php?' s, j = get(R + 'check=1') check(s == 200 and j.get('ok') is True and 'missing' in j.get('contact_email', ''), 'self-check ok, and flags the missing contact e-mail', j) t0 = time.time() s, j = get(R + 'q=covid-19&n=450&sort=relevance') first = time.time() - t0 ps = j.get('papers', []) check(s == 200 and len(ps) == 450, 'search returns 450 records (efetch in batches of 200)', len(ps)) check(j.get('count') == 1202 * 7, 'total PubMed count passed through', j.get('count')) p = ps[0] check(p['pmid'].isdigit() and p['title'] and p['abstract'] and p['journal'] and isinstance(p['year'], int), 'record has PMID, title, abstract, journal, year', {k: str(p[k])[:40] for k in ('pmid', 'title', 'journal', 'year')}) check(all(x['abstract'] for x in ps), 'every record has its abstract') labelled = next((x for x in ps if ': ' in x['abstract'].split('\n')[0][:30]), None) check(labelled is not None, 'structured abstracts keep their labels', labelled['abstract'][:60] if labelled else '') check(sum(1 for x in ps if x['types']) == len(ps), 'publication types read') check(any(x['authors'].endswith('et al.') for x in ps), 'authors shortened to three plus et al.') stats = json.loads(urllib.request.urlopen('http://127.0.0.1:8911/stats').read()) check(stats['min_gap'] is not None and stats['min_gap'] >= 0.33, 'calls to NCBI spaced at 3 per second or slower', f"{stats['calls']} calls, min gap {stats['min_gap']:.3f} s") seen = stats['seen'][-1] check(seen['tool'] == 'mafflab-literature-constellations' and not seen['email'] and 'mafflab' in seen['ua'] and not seen['cookie'] and not seen['xff'], 'tool name sent; no e-mail until configured; no visitor headers', seen) t0 = time.time(); s, j2 = get(R + 'q=covid-19&n=450&sort=relevance'); second = time.time() - t0 check(j2 == j, 'repeat search served from cache', f'{first:.2f} s then {second*1000:.0f} ms') s, j = get(R + 'q=aspirin&n=50') idx = j['papers'] check(s == 200 and any(x['mesh'] for x in idx) and any(x['major'] for x in idx), 'MeSH headings and major topics read on indexed records', idx[0]['mesh'][:4]) s, j = get(R + 'q=badquery+here') check(s == 400 and 'PubMed could not run' in j.get('error', ''), 'query errors from PubMed are passed on', j) s, j = get(R + 'q=%3Cscript%3E') check(s == 400, 'markup in the query refused', s) s, j = get(R + 'q=covid', {'Sec-Fetch-Site': 'cross-site'}) check(s == 403, 'cross-site browser requests refused') print(f" 450 records: {len(json.dumps(ps))/1024:.0f} KB JSON")finally: for p in procs: p.terminate() shutil.rmtree(cache, ignore_errors=True)print('\nall checks passed' if not fails else f'\n{fails} FAILED')sys.exit(1 if fails else 0)tests/mock_eutils.py76 lines
"""A stand-in for NCBI E-utilities (esearch and efetch for PubMed), for testing pubmed.php offline.It serves real PubMed XML records from FIXTURES_DIR (covid.xml and indexed.xml: PubmedArticleSetfiles cut from a public PubMed update file), and records how fast it was called, so the test cancheck that the relay respects NCBI's limit of 3 requests per second. python3 tests/mock_eutils.py FIXTURES_DIR PORT"""import json, re, sys, timeimport xml.etree.ElementTree as ETfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerfrom pathlib import Pathfrom urllib.parse import urlparse, parse_qsFIX, PORT = Path(sys.argv[1]), int(sys.argv[2])REC = {}SETS = {}for name in ('covid', 'indexed'): ids = [] for el in ET.parse(FIX / f'{name}.xml').getroot(): pmid = el.find('MedlineCitation/PMID').text REC[pmid] = ET.tostring(el, encoding='unicode') ids.append(pmid) SETS[name] = idsCALLS, SEEN = [], []class H(BaseHTTPRequestHandler): def log_message(self, *a): pass def send(self, code, body, ctype='application/json'): b = body.encode() if isinstance(body, str) else body self.send_response(code) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(b))) self.end_headers() self.wfile.write(b) def params(self): u = urlparse(self.path) q = parse_qs(u.query) if self.command == 'POST': q.update(parse_qs(self.rfile.read(int(self.headers.get('Content-Length', 0))).decode())) return u.path, {k: v[0] for k, v in q.items()} def handle_any(self): path, q = self.params() if path == '/stats': gaps = [b - a for a, b in zip(CALLS, CALLS[1:])] return self.send(200, json.dumps({'calls': len(CALLS), 'min_gap': min(gaps) if gaps else None, 'seen': SEEN[-5:]})) if not path.endswith('.fcgi'): return self.send(404, '{}') CALLS.append(time.time()) SEEN.append({'tool': q.get('tool'), 'email': q.get('email'), 'ua': self.headers.get('User-Agent', ''), 'cookie': self.headers.get('Cookie'), 'xff': self.headers.get('X-Forwarded-For')}) if path.endswith('/esearch.fcgi'): term = q.get('term', '').lower() ids = SETS['covid'] if 'covid' in term else SETS['indexed'] if ('aspirin' in term or 'indexed' in term) else [] n = int(q.get('retmax', 20)) body = {'header': {}, 'esearchresult': {'count': str(len(ids) * 7), 'retmax': str(min(n, len(ids))), 'idlist': ids[:n]}} if 'badquery' in term: body['esearchresult']['ERROR'] = 'Invalid query' return self.send(200, json.dumps(body)) if path.endswith('/efetch.fcgi'): ids = [i for i in q.get('id', '').split(',') if i] xml = '<?xml version="1.0" ?>\n<!DOCTYPE PubmedArticleSet PUBLIC "-//NLM//DTD PubMedArticle, 1st January 2019//EN" "https://dtd.nlm.nih.gov/ncbi/pubmed/out/pubmed_190101.dtd">\n<PubmedArticleSet>' + ''.join(REC[i] for i in ids if i in REC) + '</PubmedArticleSet>' return self.send(200, xml, 'text/xml') self.send(404, '{}') do_GET = handle_any do_POST = handle_anyThreadingHTTPServer(('127.0.0.1', PORT), H).serve_forever()tests/make_demo.js22 lines
/* Builds demo.json from records saved by pubmed.php (here: real PubMed records about COVID-19 from the public 2021 update file pubmed21n1298, served through tests/mock_eutils.py). Abstracts are NOT shipped: each record keeps its title, journal, year, authors, types, MeSH and keywords, its study design (computed from the full abstract) and its 30 most distinctive terms. node tests/make_demo.js records.json [count] */'use strict';const LC = require('../engine.js'), fs = require('fs'), path = require('path');const all = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')).papers, want = +(process.argv[3] || 400);const R = LC.rng(2021), pick = all.map(p => [R.u(), p]).sort((a, b) => a[0] - b[0]).slice(0, want).map(x => x[1]);const vz = LC.vectorize(pick);const out = pick.map((p, i) => { const v = vz.vecs[i]; const terms = Array.from(v.I).map((t, q) => [v.V[q], t]).filter(x => !vz.terms[x[1]].startsWith('#')).sort((a, b) => b[0] - a[0]).slice(0, 30).map(x => vz.label[x[1]]); return { pmid: p.pmid, title: p.title, journal: p.journal, year: p.year, authors: p.authors, types: p.types, mesh: p.mesh, major: p.major, kw: p.kw, doi: p.doi, design: LC.design(p), absTerms: terms };});const demo = { q: 'COVID-19 (demo)', count: out.length, demo: true, source: 'Real PubMed records about COVID-19 from the public 2021 PubMed update file pubmed21n1298 (U.S. National Library of Medicine). Abstracts are not included in the demo.', papers: out };const file = path.join(__dirname, '..', 'demo.json');fs.writeFileSync(file, JSON.stringify(demo));console.log(`${out.length} records, ${(fs.statSync(file).size / 1024).toFixed(0)} KB -> demo.json`);.htaccess17 lines
# Literature Constellations: security headers for Apache and LiteSpeed (Hostinger, IONOS 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># The optional pubmed.config.php holds your NCBI contact details: never serve it.<Files "pubmed.config.php"> Require all denied</Files>LICENSE22 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.