1<!doctype html> 2<html lang="en"> 3<head> 4<meta charset="utf-8" /> 5<meta name="viewport" content="width=device-width, initial-scale=1" /> 6<title>Policy Citations Dashboard Builder</title> 7<link rel="preconnect" href="https://fonts.googleapis.com"> 8<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],400;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
9<script src="https://cdn.plot.ly/plotly-2.32.0.min.js"></script>
9 10<style id="dashboard-style"> 11:root{ 12 --ink:#121A30; 13 --ink-card:#1B2747; 14 --ink-card-2:#202E54; 15 --hairline:rgba(212,162,76,0.22); 16 --gridline:rgba(244,240,230,0.07); 17 --gold:#D4A24C; 18 --teal:#3FA092; 19 --brick:#C1654A; 20 --muted:#93A0C2; 21 --paper:#F4F0E6; 22 --white:#FFFDF7; 23 --display:'Fraunces', Georgia, serif; 24 --body:'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 25 --mono:'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; 26} 27*{box-sizing:border-box;} 28body{ 29 margin:0; background:var(--ink); color:var(--paper); 30 font-family:var(--body); -webkit-font-smoothing:antialiased; 31} 32a{color:var(--gold);} 33a:hover{color:var(--teal);} 34button,input{font:inherit;} 35.masthead{border-bottom:1px solid var(--hairline);} 36.masthead-inner{max-width:1320px; margin:0 auto; padding:28px clamp(20px,5vw,56px) 0;} 37.masthead-top{display:flex; justify-content:space-between; align-items:flex-start; gap:24px; flex-wrap:wrap;} 38.eyebrow{font-family:var(--mono); font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:var(--gold); margin:0 0 10px;} 39h1.title{font-family:var(--display); font-weight:600; font-size:clamp(29px,3.4vw,44px); margin:0 0 6px; letter-spacing:0;} 40.subtitle{color:var(--muted); font-size:15px; margin:0 0 22px; max-width:720px; line-height:1.5;} 41.masthead-meta{font-family:var(--mono); font-size:11px; color:var(--muted); text-align:right; line-height:1.6;} 42.masthead-meta b{color:var(--paper);} 43main{max-width:1320px; margin:0 auto; padding:28px clamp(20px,5vw,56px) 64px;} 44.builder-grid{display:grid; grid-template-columns:minmax(280px,390px) 1fr; gap:18px; align-items:start; margin-bottom:18px;} 45@media (max-width:980px){.builder-grid{grid-template-columns:1fr;}} 46.panel,.card{background:var(--ink-card); border:1px solid var(--hairline); border-radius:6px; overflow:hidden;} 47.panel-head,.card-head{ 48 font-family:var(--mono); font-size:11px; letter-spacing:.08em; text-transform:uppercase; 49 color:var(--muted); padding:13px 16px 11px; border-bottom:1px solid var(--hairline); 50} 51.panel-body,.card-body{padding:16px;} 52.upload-zone{ 53 border:1px dashed rgba(244,240,230,.34); border-radius:6px; padding:22px; 54 background:rgba(255,255,255,.025); transition:border-color .15s, background .15s; 55} 56.upload-zone.dragover{border-color:var(--gold); background:rgba(212,162,76,.08);} 57.file-row{display:flex; align-items:center; gap:10px; flex-wrap:wrap;} 58.file-input{position:absolute; inline-size:1px; block-size:1px; opacity:0; pointer-events:none;} 59.btn{ 60 display:inline-flex; align-items:center; justify-content:center; gap:8px; 61 min-height:38px; border:1px solid var(--hairline); border-radius:5px; padding:8px 12px; 62 background:var(--ink-card-2); color:var(--paper); cursor:pointer; text-decoration:none; 63 transition:background .15s, border-color .15s, transform .15s; 64} 65.btn:hover{background:#26365F; border-color:rgba(212,162,76,.45); color:var(--paper);} 66.btn:active{transform:translateY(1px);} 67.btn.primary{background:var(--gold); border-color:var(--gold); color:#18213B; font-weight:700;} 68.btn.primary:hover{background:#E2B565; color:#18213B;} 69.btn.ghost{background:transparent;} 70.btn:disabled{opacity:.45; cursor:not-allowed; transform:none;} 71.icon-btn{inline-size:34px; min-height:32px; padding:6px; border-radius:5px;} 72.chart-tools{display:flex; gap:6px; flex-wrap:wrap;} 73.muted{color:var(--muted);} 74.fineprint{font-size:12px; line-height:1.5; color:var(--muted); margin:12px 0 0;} 75.status{font-family:var(--mono); font-size:12px; color:var(--muted); margin-top:12px; min-height:18px;} 76.status.error{color:#F4A69A;} 77.settings{display:grid; gap:12px; margin-top:16px;} 78.field label{display:block; font-family:var(--mono); font-size:11px; letter-spacing:.07em; text-transform:uppercase; color:var(--muted); margin-bottom:7px;} 79.field input{ 80 width:100%; min-height:38px; border:1px solid var(--hairline); border-radius:5px; 81 background:var(--ink); color:var(--paper); padding:8px 10px; 82} 83.theme-grid{display:grid; grid-template-columns:repeat(2,1fr); gap:10px;} 84.color-field{ 85 display:grid; grid-template-columns:36px 1fr; gap:10px; align-items:center; 86 border:1px solid var(--hairline); border-radius:5px; padding:9px; background:rgba(255,255,255,.025); 87} 88.color-field input{ 89 width:36px; height:32px; min-height:32px; padding:0; border:0; background:transparent; cursor:pointer; 90} 91.color-field span{font-family:var(--mono); font-size:11px; color:var(--muted);} 92.stats-strip{display:grid; grid-template-columns:repeat(4,1fr); gap:12px;} 93@media (max-width:720px){.stats-strip{grid-template-columns:repeat(2,1fr);}} 94.mini-stat{background:rgba(255,255,255,.03); border:1px solid var(--hairline); border-radius:6px; padding:14px;} 95.mini-label{font-family:var(--mono); font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--muted);} 96.mini-value{font-family:var(--display); font-size:2rem; line-height:1; margin-top:6px;}
97.right-stack{display:grid; gap:18px;} 98.quick-guide{display:grid; gap:10px; color:var(--muted); font-size:13px; line-height:1.45;} 99.guide-step{display:grid; grid-template-columns:26px 1fr; gap:10px; align-items:start;} 100.guide-step b{ 101 display:inline-flex; align-items:center; justify-content:center; width:26px; height:26px; 102 border:1px solid var(--hairline); border-radius:50%; color:var(--gold); font-family:var(--mono); font-size:12px; 103} 104.guide-arrow{color:var(--gold); text-align:center; margin-top:4px;} 105.guide-arrow span{display:inline-block; font-family:var(--display); font-size:2.8rem; line-height:1; transform:scaleX(2.4);} 106.tabs{display:flex; gap:2px; margin-top:6px; overflow-x:auto;} 107.tab-btn{ 108 font-family:var(--mono); font-size:12px; letter-spacing:.08em; text-transform:uppercase; 109 background:none; border:none; color:var(--muted); padding:12px 18px; 110 cursor:pointer; border-bottom:2px solid transparent; white-space:nowrap; 111} 112.tab-btn:hover{color:var(--paper);} 113.tab-btn.active{color:var(--gold); border-bottom-color:var(--gold);} 114.page{display:none;} 115.page.active{display:block; animation:fade .25s ease;} 116@keyframes fade{from{opacity:0; transform:translateY(4px);} to{opacity:1; transform:translateY(0);}} 117.row{display:grid; gap:16px; margin-bottom:16px;} 118.row.cols-4{grid-template-columns:repeat(4,1fr);} 119.row.cols-2-55-45{grid-template-columns:55% 1fr;} 120.row.cols-2-38-62{grid-template-columns:38% 1fr;} 121@media (max-width:880px){ 122 .row.cols-4{grid-template-columns:repeat(2,1fr);} 123 .row.cols-2-55-45,.row.cols-2-38-62{grid-template-columns:1fr;} 124} 125.vbox{padding:18px 18px 16px; position:relative; border-left:3px solid var(--muted);} 126.vbox .icon{position:absolute; top:16px; right:16px; opacity:.35; font-size:20px;} 127.vbox .vlabel{font-family:var(--mono); font-size:11px; letter-spacing:.07em; text-transform:uppercase; color:var(--muted);} 128.vbox .vvalue{font-family:var(--display); font-weight:600; font-size:2.3rem; margin-top:6px; line-height:1;} 129.vbox.gold{border-left-color:var(--gold);} 130.vbox.teal{border-left-color:var(--teal);} 131.vbox.brick{border-left-color:var(--brick);} 132.vbox.muted2{border-left-color:var(--muted);} 133table{width:100%; border-collapse:collapse; font-size:13.5px;} 134thead th{ 135 font-family:var(--mono); font-size:10.5px; letter-spacing:.06em; text-transform:uppercase; 136 color:var(--muted); text-align:left; padding:9px 10px; border-bottom:1px solid var(--hairline); 137 position:sticky; top:0; background:var(--ink-card); 138} 139tbody td{padding:9px 10px; border-bottom:1px solid rgba(255,255,255,.06); vertical-align:top;} 140tbody tr:hover{background:rgba(255,255,255,.025);} 141.scroll-table{max-height:420px; overflow-y:auto;} 142.mono{font-family:var(--mono); font-size:12px; color:var(--muted);} 143.num{font-family:var(--mono); text-align:right; color:var(--paper);} 144.rank{font-family:var(--mono); color:var(--gold); width:34px;} 145.seal{display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:7px;} 146.seal.gold{background:var(--gold);} .seal.teal{background:var(--teal);} 147.seal.brick{background:var(--brick);} .seal.muted2{background:var(--muted);} 148.legend{display:flex; gap:16px; flex-wrap:wrap; padding:10px 16px 4px; font-size:12px; color:var(--muted);} 149.legend span{display:inline-flex; align-items:center;} 150.plot{width:100%; min-height:180px;} 151.empty-state{ 152 min-height:360px; display:grid; place-items:center; text-align:center; padding:32px; 153 border:1px solid var(--hairline); border-radius:6px; background:rgba(255,255,255,.02); 154} 155.empty-state h2{font-family:var(--display); font-size:clamp(26px,3vw,38px); margin:0 0 10px; letter-spacing:0;} 156.empty-state p{color:var(--muted); max-width:620px; line-height:1.55; margin:0;} 157.dashboard-actions{display:flex; gap:10px; flex-wrap:wrap; margin-top:12px;} 158.foot-note{font-family:var(--mono); font-size:11px; color:var(--muted); margin:22px 0 0; padding:0 2px;} 159</style> 160</head> 161<body> 162<header class="masthead"> 163 <div class="masthead-inner"> 164 <div class="masthead-top"> 165 <div> 166 <p class="eyebrow">Sage Policy Profiles · Dashboard Builder</p> 167 <h1 class="title">Policy Citations Dashboard Builder</h1> 168 <p class="subtitle">Upload an exported policy-impact CSV, preview the dashboard instantly, then download a standalone HTML dashboard or export each chart as PNG or JPG.</p> 169 </div> 170 <div class="masthead-meta" id="meta-block"> 171 <div>No registration required</div> 172 <div>Runs in your browser</div> 173 </div> 174 </div> 175 </div> 176</header> 177 178<!-- Google tag (gtag.js) -->
vendor: 64 bytes, lines 178-179
178 179<script async src="https://www.googletagmanager.com/gtag/js?id=
179G-44T7968K50
vendor: 12 bytes, line 179
179"></script>
180<script> 181
vendor: 134 bytes, lines 181-185
181window.dataLayer = window.dataLayer || []; 182 function gtag(){dataLayer.push(arguments);} 183 gtag('js', new Date()); 184 185 gtag('config', '
185G-44T7968K50
vendor: 4 bytes, line 185
185');
186</script>
186 187 188<!-- 189https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-setup 190-->
191<script type="text/javascript"> 192 (function(c,l,a,r,i,t,y){ 193 c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; 194 t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i; 195 y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); 196 })(window, document, "clarity", "script", "ktfar1v3c9"); 197</script>
197 198 199<main> 200 <section class="builder-grid" aria-label="Dashboard builder"> 201 <div class="panel"> 202 <div class="panel-head">Upload export</div> 203 <div class="panel-body"> 204 <div class="upload-zone" id="drop-zone"> 205 <div class="file-row"> 206 <input class="file-input" id="csv-file" type="file" accept=".csv,text/csv" /> 207 <label class="btn primary" for="csv-file">Choose CSV</label> 208 <button class="btn ghost" id="load-sample" type="button">Load sample</button> 209 </div> 210 <p class="fineprint">Use the CSV exported from Sage Policy Profiles. The file is processed locally in this browser session.</p> 211 <div class="status" id="status">Waiting for a CSV export.</div> 212 </div> 213 214 <div class="settings"> 215 <div class="field"> 216 <label for="dashboard-title">Dashboard title</label> 217 <input id="dashboard-title" value="Policy Citations" /> 218 </div> 219 <div class="field"> 220 <label for="dashboard-subtitle">Subtitle</label> 221 <input id="dashboard-subtitle" value="Where our research reaches policy makers" /> 222 </div> 223 <div class="field"> 224 <label for="source-label">Data source label</label> 225 <input id="source-label" value="Sage Policy Profiles" /> 226 </div> 227 <div class="field"> 228 <label for="source-url">Data source URL</label> 229 <input id="source-url" value="https://policyprofiles.sagepub.com/profile/35156/gang-he" /> 230 </div> 231 <div class="field"> 232 <label>Main theme colors</label> 233 <div class="theme-grid" id="theme-grid"> 234 <label class="color-field"><input id="theme-ink" type="color" value="#121A30" /><span>Background</span></label> 235 <label class="color-field"><input id="theme-card" type="color" value="#1B2747" /><span>Cards</span></label> 236 <label class="color-field"><input id="theme-gold" type="color" value="#D4A24C" /><span>Primary</span></label> 237 <label class="color-field"><input id="theme-teal" type="color" value="#3FA092" /><span>Secondary</span></label> 238 <label class="color-field"><input id="theme-brick" type="color" value="#C1654A" /><span>Tertiary</span></label> 239 <label class="color-field"><input id="theme-paper" type="color" value="#F4F0E6" /><span>Text</span></label> 240 </div> 241 </div> 242 </div> 243 244 <div class="dashboard-actions"> 245 <button class="btn primary" id="download-html" type="button" disabled>Download HTML</button> 246 <button class="btn" id="refresh-dashboard" type="button" disabled>Refresh</button> 247 <button class="btn" id="reset-settings" type="button">Default</button> 248 </div> 249 </div> 250 </div> 251 252 <div class="right-stack"> 253 <div class="panel"> 254 <div class="panel-head">Current dashboard</div> 255 <div class="panel-body"> 256 <div class="stats-strip" id="stats-strip"> 257 <div class="mini-stat"><div class="mini-label">Mentions</div><div class="mini-value">0</div></div> 258 <div class="mini-stat"><div class="mini-label">Organizations</div><div class="mini-value">0</div></div> 259 <div class="mini-stat"><div class="mini-label">Countries/IGOs</div><div class="mini-value">0</div></div> 260 <div class="mini-stat"><div class="mini-label">Papers</div><div class="mini-value">0</div></div> 261 </div> 262 </div> 263 </div> 264 <div class="panel"> 265 <div class="panel-head">How it works</div> 266 <div class="panel-body"> 267 <div class="quick-guide"> 268 <div class="guide-step"><b>1</b><span>Upload a Sage Policy Profiles CSV export or click <strong>Load sample</strong> to see a sample dashboard.</span></div> 269 <div class="guide-step"><b>2</b><span>Customize the title, source, and theme colors, click <strong>Default</strong> to back to the default settings.</span></div> 270 <div class="guide-step"><b>3</b><span>Select <strong>Refresh</strong>, check the dashboard below, then <strong>Download</strong>.</span></div>
271 <div class="guide-arrow" aria-hidden="true"><span>â</span></div> 272 </div> 273 </div> 274 </div> 275 </div> 276 </section> 277 278 <section id="dashboard-shell"> 279 <div class="empty-state"> 280 <div> 281 <h2>Upload a CSV to generate the dashboard</h2> 282 <p>The builder will create the same policy citation dashboard, plus chart download controls and a standalone HTML export for sharing or archiving.</p> 283 </div> 284 </div> 285 </section> 286</main> 287
288<script src="data/dashboard-data.js"></script>
vendor: 1 bytes, line 288
288
289<script> 290(function(){ 291 const DEFAULT_THEME = { 292 ink: '#121A30', 293 card: '#1B2747', 294 card2: '#202E54', 295 gold: '#D4A24C', 296 teal: '#3FA092', 297 brick: '#C1654A', 298 muted: '#93A0C2', 299 paper: '#F4F0E6' 300 }; 301 const DEFAULT_SETTINGS = { 302 title: 'Policy Citations', 303 subtitle: 'Where our research reaches policy makers', 304 sourceLabel: 'Sage Policy Profiles', 305 sourceUrl: 'https://policyprofiles.sagepub.com/profile/35156/gang-he' 306 }; 307 let COLORS = { ...DEFAULT_THEME }; 308 const TYPE_COLOR = { 'think tank': 'gold', igo: 'teal', government: 'brick', other: 'muted2' }; 309 let TYPE_HEX = { 'think tank': COLORS.gold, igo: COLORS.teal, government: COLORS.brick, other: COLORS.muted }; 310 const FIELD_MAP = { 311 mention_type: 'Type', 312 document_id: 'Policy Document ID', 313 title: 'Title', 314 translated_title: 'Translated title', 315 org: 'Source title', 316 country: 'Source country', 317 source_type: 'Source type', 318 source_subtype: 'Source sub-type', 319 published_on: 'Published on', 320 paper: 'Cited research titles', 321 doi: 'Cited research DOIs', 322 topics: 'Topics', 323 doc_url: 'Document URL', 324 text: 'Text' 325 }; 326 const state = { data: null, filename: '', rendered: {}, charts: [] }; 327 328 const el = { 329 file: document.getElementById('csv-file'), 330 drop: document.getElementById('drop-zone'), 331 status: document.getElementById('status'), 332 sample: document.getElementById('load-sample'), 333 shell: document.getElementById('dashboard-shell'), 334 stats: document.getElementById('stats-strip'), 335 download: document.getElementById('download-html'), 336 refresh: document.getElementById('refresh-dashboard'), 337 resetSettings: document.getElementById('reset-settings'), 338 title: document.getElementById('dashboard-title'), 339 subtitle: document.getElementById('dashboard-subtitle'), 340 sourceLabel: document.getElementById('source-label'), 341 sourceUrl: document.getElementById('source-url'), 342 theme: { 343 ink: document.getElementById('theme-ink'), 344 card: document.getElementById('theme-card'), 345 gold: document.getElementById('theme-gold'), 346 teal: document.getElementById('theme-teal'), 347 brick: document.getElementById('theme-brick'), 348 paper: document.getElementById('theme-paper') 349 } 350 }; 351 352 function clean(value){ return String(value || '').trim().replace(/^\uFEFF/, ''); } 353 function normalizeSourceType(value){ return clean(value).toLowerCase() || 'other'; } 354 function splitList(value){ return clean(value).split(/\s*[ï¼,;|]\s*/).map(s => s.trim()).filter(Boolean); } 355 function parseDateValue(value){ 356 value = clean(value); 357 if (!value) return ''; 358 const iso = value.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/); 359 if (iso) return `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`; 360 const slash = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/); 361 if (slash){ 362 let year = slash[3]; 363 if (year.length === 2) year = Number(year) >= 70 ? `19${year}` : `20${year}`; 364 return `${year}-${slash[1].padStart(2, '0')}-${slash[2].padStart(2, '0')}`; 365 } 366 return value; 367 } 368 function yearFromDate(value){ const match = clean(value).match(/\d{4}/); return match ? match[0] : ''; } 369 function truncate(value, n){ value = clean(value); return value.length > n ? value.slice(0, n - 1).trimEnd() + 'â¦' : value; } 370 function escapeHtml(value){ 371 return clean(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); 372 } 373 function safeUrl(value){ return clean(value).replace(/"/g, '%22'); } 374 function slug(value){ 375 return clean(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'policy-citations'; 376 } 377 function normalizeHex(value, fallback){ 378 value = clean(value); 379 return /^#[0-9a-f]{6}$/i.test(value) ? value.toUpperCase() : fallback; 380 } 381 function hexToRgb(value){ 382 value = normalizeHex(value, '#000000').slice(1); 383 return { 384 r: parseInt(value.slice(0, 2), 16), 385 g: parseInt(value.slice(2, 4), 16), 386 b: parseInt(value.slice(4, 6), 16) 387 }; 388 } 389 function rgba(value, alpha){ 390 const rgb = hexToRgb(value); 391 return `rgba(${rgb.r},${rgb.g},${rgb.b},${alpha})`; 392 }
393 function currentTheme(){ 394 return { 395 ink: normalizeHex(el.theme.ink.value, DEFAULT_THEME.ink), 396 card: normalizeHex(el.theme.card.value, DEFAULT_THEME.card), 397 card2: normalizeHex(el.theme.card.value, DEFAULT_THEME.card), 398 gold: normalizeHex(el.theme.gold.value, DEFAULT_THEME.gold), 399 teal: normalizeHex(el.theme.teal.value, DEFAULT_THEME.teal), 400 brick: normalizeHex(el.theme.brick.value, DEFAULT_THEME.brick), 401 muted: DEFAULT_THEME.muted, 402 paper: normalizeHex(el.theme.paper.value, DEFAULT_THEME.paper) 403 }; 404 } 405 function applyTheme(theme){ 406 COLORS = { ...DEFAULT_THEME, ...theme }; 407 TYPE_HEX = { 'think tank': COLORS.gold, igo: COLORS.teal, government: COLORS.brick, other: COLORS.muted }; 408 const root = document.documentElement; 409 root.style.setProperty('--ink', COLORS.ink); 410 root.style.setProperty('--ink-card', COLORS.card); 411 root.style.setProperty('--ink-card-2', COLORS.card2); 412 root.style.setProperty('--hairline', rgba(COLORS.gold, 0.22)); 413 root.style.setProperty('--gridline', rgba(COLORS.paper, 0.07)); 414 root.style.setProperty('--gold', COLORS.gold); 415 root.style.setProperty('--teal', COLORS.teal); 416 root.style.setProperty('--brick', COLORS.brick); 417 root.style.setProperty('--paper', COLORS.paper); 418 } 419 function themeStyle(theme){ 420 theme = { ...DEFAULT_THEME, ...theme }; 421 return `:root{--ink:${theme.ink};--ink-card:${theme.card};--ink-card-2:${theme.card2 || theme.card};--hairline:${rgba(theme.gold, 0.22)};--gridline:${rgba(theme.paper, 0.07)};--gold:${theme.gold};--teal:${theme.teal};--brick:${theme.brick};--muted:${theme.muted};--paper:${theme.paper};}`; 422 } 423 424 function parseCSV(text){ 425 const rows = []; 426 let row = [], cell = '', inQuotes = false; 427 for (let i = 0; i < text.length; i++){ 428 const ch = text[i], next = text[i + 1]; 429 if (ch === '"'){ 430 if (inQuotes && next === '"'){ cell += '"'; i++; } 431 else inQuotes = !inQuotes; 432 } else if (ch === ',' && !inQuotes){ 433 row.push(cell); cell = ''; 434 } else if ((ch === '\n' || ch === '\r') && !inQuotes){ 435 if (ch === '\r' && next === '\n') i++; 436 row.push(cell); 437 if (row.some(v => clean(v) !== '')) rows.push(row); 438 row = []; cell = ''; 439 } else { 440 cell += ch; 441 } 442 } 443 row.push(cell); 444 if (row.some(v => clean(v) !== '')) rows.push(row); 445 if (!rows.length) return []; 446 447 const headers = rows[0].map(clean); 448 return rows.slice(1).map(values => { 449 const out = {}; 450 headers.forEach((header, index) => { out[header] = values[index] || ''; }); 451 return out; 452 }); 453 } 454 455 function splitCitedPairs(titleValue, doiValue){ 456 const titles = clean(titleValue).includes('ï¼') ? splitList(titleValue) : [clean(titleValue)].filter(Boolean); 457 const dois = clean(doiValue).includes('ï¼') ? splitList(doiValue) : [clean(doiValue)].filter(Boolean); 458 return titles.map((title, index) => ({ 459 paper: title, 460 doi: dois[index] || (dois.length === 1 ? dois[0] : '') 461 })).filter(pair => pair.paper); 462 } 463 464 function sortedCountRows(counter, keyName, countName){ 465 return Object.entries(counter) 466 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 467 .map(([key, count]) =>
467 ({ [keyName]: key, [countName]: count })); 468 } 469 470 function addCount(counter, key, amount = 1){ 471 key = clean(key) || 'Unknown'; 472 counter[key] = (counter[key] || 0) + amount; 473 } 474 475 function buildPayload(rows){ 476 if (!rows.length) throw new Error('The CSV does not contain data rows.'); 477 const first = rows[0]; 478 const missing = ['Source title', 'Source country', 'Published on'].filter(name => !(name in first)); 479 if (missing.length) throw new Error('Missing expected column(s): ' + missing.join(', ')); 480 481 const paperCounts = {}, paperDois = {}, topicCounts = {}, countryCounts = {}; 482 const typeCounts = {}, mentionCounts = {}, timelineCounts = {}, orgs = {}; 483 484 rows.forEach(row => { 485 const item = {}; 486 Object.entries(FIELD_MAP).forEach(([key, field]) => { item[key] = clean(row[field]); }); 487 item.source_type = normalizeSourceType(item.source_type); 488 item.published_on = parseDateValue(item.published_on); 489 const org = item.org || 'Unknown'; 490 const country = item.country || 'Unknown'; 491 const year = yearFromDate(item.published_on); 492 493 addCount(countryCounts, country); 494 addCount(typeCounts, item.source_type); 495 addCount(mentionCounts, item.mention_type || 'Unknown'); 496 if (year) addCount(timelineCounts, year); 497 498 splitCitedPairs(item.paper, item.doi).forEach(pair => { 499 addCount(paperCounts, pair.paper); 500 if (pair.doi && !paperDois[pair.paper]) paperDois[pair.paper] = pair.doi; 501 }); 502 splitList(item.topics).forEach(topic => addCount(topicCounts, topic)); 503 504 if (!orgs[org]){ 505 orgs[org] = { org, country, source_type: item.source_type, citations: 0, latest: item.published_on, doc_url: item.doc_url }; 506 } 507 orgs[org].citations += 1; 508 if (item.published_on && item.published_on > (orgs[org].latest || '')){ 509 orgs[org].latest = item.published_on; 510 orgs[org].doc_url = item.doc_url; 511 } 512 }); 513 514 const topPapers = Object.entries(paperCounts) 515 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 516 .map(([paper, citations]) => ({ paper, citations, doi: paperDois[paper] || '' })); 517 const orgRows = Object.values(orgs).sort((a, b) => b.citations - a.citations || a.org.localeCompare(b.org)); 518 const timeline = Object.keys(timelineCounts).sort().map(year => ({ year, citations: timelineCounts[year] })); 519 520 return { 521 summary: { 522 total: rows.length, 523 orgs: orgRows.length, 524 countries: Object.keys(countryCounts).length, 525 papers: topPapers.length 526 }, 527 top_papers: topPapers, 528 orgs: orgRows, 529 topics: sortedCountRows(topicCounts, 'topic', 'count').slice(0, 40), 530 timeline, 531 countries: sortedCountRows(countryCounts, 'country', 'citations'), 532 types: sortedCountRows(typeCounts, 'source_type', 'citations'), 533 mention_types: sortedCountRows(mentionCounts, 'type', 'count') 534 }; 535 } 536 537 function getOptions(){ 538 const theme = currentTheme(); 539 applyTheme(theme); 540 return { 541 title: clean(el.title.value) || DEFAULT_SETTINGS.title, 542 subtitle: clean(el.subtitle.value) || DEFAULT_SETTINGS.subtitle, 543 sourceLabel: clean(el.sourceLabel.value) || DEFAULT_SETTINGS.sourceLabel, 544 sourceUrl: clean(el.sourceUrl.value) || DEFAULT_SETTINGS.sourceUrl, 545 theme 546 }; 547 } 548 549 function updateStats(data){ 550 const s = data.summary; 551 el.stats.innerHTML = [ 552 ['Mentions', s.total], 553 ['Organizations', s.orgs], 554 ['Countries/IGOs', s.countries], 555 ['Papers', s.papers] 556 ].map(([label, value]) => `<div class="mini-stat"><div class="mini-label">${label}</div><div class="mini-value">${value}</div></div>`).join(''); 557 } 558 559 function baseLayout(extra){ 560 return Object.assign({ 561 paper_bgcolor: COLORS.card, 562 plot_bgcolor: COLORS.card, 563 showlegend: false, 564 font: { family: 'Inter, sans-serif', color: COLORS.paper, size: 12 }, 565 margin: { l: 10, r: 14, t: 10, b: 30 }, 566 xaxis: { title: '', gridcolor: rgba(COLORS.paper, 0.07), zerolinecolor: rgba(COLORS.paper, 0.07), automargin: true }, 567 yaxis: { title: '', gridcolor: rgba(COLORS.paper, 0.07), zerolinecolor: rgba(COLORS.paper, 0.07), automargin: true } 568 }, extra); 569 } 570 571 function drawPlot(id, traces, layoutExtra){ 572 const node = document.getElementById(id); 573 if (!node || !window.Plotly) return; 574 if (node.dataset.plotted) { try { Plotly.purge(node); } catch(e){} } 575 Plotly.newPlot(id, traces, baseLayout(layoutExtra), { displayModeBar: false, responsive: true }); 576 node.dataset.plotted = '1'; 577 } 578 function wordCloudTrace(topics){ 579 const items = topics.slice(0, 32); 580 if (!items.length){ 581 return { 582 type: 'scatter', 583 mode: 'text', 584 x: [50], 585 y: [50], 586 text: ['No topics'], 587 hoverinfo: 'skip', 588 textfont: { size: [16], color: [COLORS.muted] }, 589 textposition: 'middle center' 590 }; 591 } 592 const counts = items.map(t => t.count); 593 const max = Math.max(...counts, 1); 594 const min = Math.min(...counts, max); 595 const spread = Math.max(max - min, 1); 596 const colors = [COLORS.gold, COLORS.teal, COLORS.brick, COLORS.paper, COLORS.muted]; 597 const words = items.map((topic, index) => { 598 const size = 12 + ((topic.count - min) / spread) * 22; 599 return { 600 topic, 601 index, 602 size, 603 width: Math.min(42, Math.max(10, topic.topic.length * size * 0.18 + 4)) 604 }; 605 }); 606 const rows = []; 607 const maxWidth = 96; 608 words.forEach(word => { 609 let row = rows[rows.length - 1]; 610 if (!row || row.width + word.width + 3 > maxWidth){ 611 row = { words: [], width: 0 }; 612 rows.push(row); 613 } 614 row.words.push(word); 615 row.width += word.width + (row.words.length > 1 ? 3 : 0); 616 }); 617 const points = []; 618 const rowOffsets = rows.map((_, index) => { 619 if (index === 0) return 0; 620 return index % 2 ? Math.ceil(index / 2) : -(index / 2); 621 }); 622 const maxOffset = Math.max(...rowOffsets.map(offset => Math.abs(offset)), 1); 623 const rowGap = 42 / maxOffset; 624 rows.forEach((row, rowIndex) => { 625 let x = (100 - row.width) / 2; 626 const y = 50 + rowOffsets[rowIndex] * rowGap; 627 row.words.forEach(word => { 628 x += word.width / 2; 629 points.push({ ...word, x, y }); 630 x += word.width / 2 + 3; 631 }); 632 }); 633 return { 634 type: 'scatter', 635 mode: 'text', 636 x: points.map(point => point.x), 637 y: points.map(point => point.y), 638 text: points.map(point => point.topic.topic), 639 hovertext: points.map(point => `${point.topic.topic}: ${point.topic.count}`), 640 hoverinfo: 'text', 641 textfont: { 642 size: points.map(point => point.size), 643 color: points.map(point => colors[point.index % colors.length]) 644 }, 645 textposition: 'middle center' 646 }; 647 } 648 649 function chartCard(title, id){ 650 return `<div class="card"> 651 <div class="card-head" style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
652 <span>${title}</span> 653 <span class="chart-tools"> 654 <button class="btn icon-btn" type="button" data-download-chart="${id}" data-format="png" title="Download PNG">PNG</button> 655 <button class="btn icon-btn" type="button" data-download-chart="${id}" data-format="jpeg" title="Download JPG">JPG</button> 656 </span> 657 </div> 658 <div class="card-body"><div id="${id}" class="plot"></div></div> 659 </div>`; 660 } 661 662 function renderDashboard(data, options){ 663 state.data = data; 664 state.rendered = {}; 665 state.charts = []; 666 updateStats(data); 667 el.download.disabled = false; 668 el.refresh.disabled = false; 669 670 el.shell.innerHTML = ` 671 <header class="masthead" style="margin-bottom:0;"> 672 <div class="masthead-inner" style="padding-left:0;padding-right:0;"> 673 <div class="masthead-top"> 674 <div> 675 <p class="eyebrow">Research â Policy · Citations</p> 676 <h1 class="title">${escapeHtml(options.title)}</h1> 677 <p class="subtitle">${escapeHtml(options.subtitle)}</p> 678 </div> 679 <div class="masthead-meta"><div><b>${data.summary.total}</b> mentions logged</div><div>${data.summary.orgs} organizations · ${data.summary.countries} countries/IGOs</div></div> 680 </div> 681 <nav class="tabs" id="tabs"></nav> 682 </div> 683 </header> 684 <div id="pages" style="padding-top:28px;"></div> 685 <p class="foot-note">Data source: <a href="${safeUrl(options.sourceUrl)}" target="_blank" rel="noopener">${escapeHtml(options.sourceLabel)}</a> · Built with <a href="https://policy-citations-dashboard.vercel.app" target="_blank" rel="noopener">Policy Citations Dashboard Builder</a></p>`; 686 687 const pages = [ 688 { id: 'overview', label: 'Overview', build: buildOverview }, 689 { id: 'papers', label: 'By Paper', build: buildPapers }, 690 { id: 'orgs', label: 'By Organization', build: buildOrgs }, 691 { id: 'topics', label: 'Topics & Reach', build: buildTopics } 692 ]; 693 const tabsEl = document.getElementById('tabs'); 694 const pagesEl = document.getElementById('pages'); 695 pages.forEach((page, index) => { 696 const button = document.createElement('button'); 697 button.className = 'tab-btn' + (index === 0 ? ' active' : ''); 698 button.textContent = page.label; 699 button.dataset.target = page.id; 700 button.addEventListener('click', () => showPage(page.id)); 701 tabsEl.appendChild(button); 702 const section = document.createElement('section'); 703 section.className = 'page' + (index === 0 ? ' active' : ''); 704 section.id = 'page-' + page.id; 705 pagesEl.appendChild(section); 706 }); 707 708 function showPage(id){ 709 document.querySelectorAll('.tab-btn').forEach(button => button.classList.toggle('active', button.dataset.target === id)); 710 document.querySelectorAll('.page').forEach(page => page.classList.toggle('active', page.id === 'page-' + id)); 711 const page = pages.find(item => item.id === id); 712 if (page && !state.rendered[id]){ page.build(data); state.rendered[id] = true; } 713 else window.dispatchEvent(new Event('resize')); 714 } 715 716 pages[0].build(data); 717 state.rendered.overview = true; 718 el.status.className = 'status'; 719 el.status.textContent = `${state.filename || 'CSV'} loaded: ${data.summary.total} rows converted.`; 720 } 721 722 function buildOverview(data){ 723 document.getElementById('page-overview').innerHTML = ` 724 <div class="row cols-4"> 725 ${valueBox('Policy Mentions', data.summary.total, 'gold', 'ð')} 726 ${valueBox('Citing Organizations', data.summary.orgs, 'teal', 'ð')} 727 ${valueBox('Countries & IGOs Reached', data.summary.countries, 'brick', 'ð')} 728 ${valueBox('Papers With Policy Reach', data.summary.papers, 'muted2', 'ð')} 729 </div> 730 <div class="row cols-2-38-62"> 731 ${chartCard('Mentions by source type', 'chart-type')} 732 ${chartCard('Mentions by country / IGO', 'chart-country')} 733 </div> 734 <div class="row">${chartCard('Top citing organizations', 'chart-top-orgs')}</div>`; 735 736 drawPlot('chart-type', [{ type:'pie', hole:.55, labels:data.types.map(t => t.source_type), values:data.types.map(t => t.citations), marker:{ colors:data.types.map(t => TYPE_HEX[t.source_type] || COLORS.muted), line:{ color:COLORS.card, width:2 } }, textinfo:'label+value', textposition:'outside', textfont:{ size:11 } }], { height:300, margin:{ l:30, r:30, t:30, b:30 } }); 737 const ctop = data.countries.slice(0, 12).reverse(); 738 drawPlot('chart-country', [{ type:'bar', orientation:'h', x:ctop.map(c => c.citations), y:ctop.map(c => truncate(c.country, 22)), hovertext:ctop.map(c => c.country), hoverinfo:'text+x', marker:{ color:COLORS.gold } }], { height:300 }); 739 const otop = data.orgs.slice(0, 12).reverse(); 740 const groups = [...new Set(otop.map(o => o.source_type))]; 741 drawPlot('chart-top-orgs', groups.map(st => ({ type:'bar', orientation:'h', name:st, x:otop.map(o => o.source_type === st ? o.citations : 0), y:otop.map(o => truncate(o.org, 30)), hovertext:otop.map(o => o.org), hoverinfo:'text+x', marker:{ color:TYPE_HEX[st] || COLORS.muted } })), { height:360, barmode:'stack', showlegend:true, legend:{ orientation:'h', y:1.1, font:{ size:11 } }, margin:{ l:10, r:14, t:36, b:30 } }); 742 state.charts.push('chart-type', 'chart-country', 'chart-top-orgs'); 743 } 744 745 function buildPapers(data){ 746 document.getElementById('page-papers').innerHTML = ` 747 <div class="row">${chartCard('Citations per paper', 'chart-papers')}</div> 748 <div class="row"><div class="card"><div class="card-head">Paper registry â ranked by policy citations</div><div class="card-body tight scroll-table"><table id="table-papers"></table></div></div></div>`; 749 const tp = data.top_papers.slice().reverse(); 750 drawPlot('chart-papers', [{ type:'bar', orientation:'h', x:tp.map(p => p.citations), y:tp.map(p => truncate(p.paper, 60)), hovertext:tp.map(p => p.paper), hoverinfo:'text+x', marker:{ color:COLORS.gold } }], { height:460, margin:{ l:10, r:14, t:10, b:30 } }); 751 document.getElementById('table-papers').innerHTML = `<thead><tr><th>
751No.</th><th>Cited paper</th><th>DOI</th><th>Citations</th></tr></thead><tbody>${data.top_papers.map((p, i) => `<tr><td class="rank">${String(i + 1).padStart(2, '0')}</td><td>${escapeHtml(p.paper)}</td><td class="mono">${p.doi ? `<a href="https://doi.org/${safeUrl(p.doi)}" target="_blank" rel="noopener">${escapeHtml(p.doi)}</a>` : 'â'}</td><td class="num">${p.citations}</td></tr>`).join('')}</tbody>`; 752 state.charts.push('chart-papers'); 753 } 754 755 function buildOrgs(data){ 756 document.getElementById('page-orgs').innerHTML = ` 757 <div class="row cols-2-55-45">${chartCard('Reach by organization (top 15)', 'chart-orgs')}${chartCard('Organization type à citations', 'chart-treemap')}</div> 758 <div class="row"><div class="card"><div class="card-head">Organization registry</div><div class="legend"><span><span class="seal gold"></span>think tank</span><span><span class="seal teal"></span>igo</span><span><span class="seal brick"></span>government</span><span><span class="seal muted2"></span>other</span></div><div class="card-body tight scroll-table"><table id="table-orgs"></table></div></div></div>`; 759 const o15 = data.orgs.slice(0, 15).reverse(); 760 const groups = [...new Set(o15.map(o => o.source_type))]; 761 drawPlot('chart-orgs', groups.map(st => ({ type:'bar', orientation:'h', name:st, x:o15.map(o => o.source_type === st ? o.citations : 0), y:o15.map(o => truncate(o.org, 30)), hovertext:o15.map(o => o.org), hoverinfo:'text+x', marker:{ color:TYPE_HEX[st] || COLORS.muted } })), { height:440, barmode:'stack', showlegend:false, margin:{ l:10, r:14, t:10, b:30 } }); 762 const typeOrder = [...new Set(data.orgs.map(o => o.source_type))]; 763 const totals = {}; 764 data.orgs.forEach(o => { totals[o.source_type] = (totals[o.source_type] || 0) + o.citations; }); 765 const total = Object.values(totals).reduce((a, b) => a + b, 0); 766 drawPlot('chart-treemap', [{ type:'treemap', ids:['root', ...typeOrder, ...data.orgs.map((o, i) => `org-${i}`)], labels:['All organizations', ...typeOrder, ...data.orgs.map(o => truncate(o.org, 24))], parents:['', ...typeOrder.map(() => 'root'), ...data.orgs.map(o => o.source_type)], values:[total, ...typeOrder.map(t => totals[t]), ...data.orgs.map(o => o.citations)], hovertext:['All organizations', ...typeOrder, ...data.orgs.map(o => o.org)], hoverinfo:'text+value', marker:{ colors:['rgba(0,0,0,0)', ...typeOrder.map(t => TYPE_HEX[t] || COLORS.muted), ...data.orgs.map(o => TYPE_HEX[o.source_type] || COLORS.muted)] }, textfont:{ size:11 }, branchvalues:'total' }], { height:440, margin:{ l:4, r:4, t:10, b:4 } }); 767 document.getElementById('table-orgs').innerHTML = `<thead><tr><th>Organization</th><th>Country</th><th>Type</th><th>Citations</th><th>Most recent</th><th>Document</th></tr></thead><tbody>${data.orgs.map(o => `<tr><td><span class="seal ${TYPE_COLOR[o.source_type] || 'muted2'}"></span>${escapeHtml(o.org)}</td><td>${escapeHtml(o.country)}</td><td class="mono">${escapeHtml(o.source_type)}</td><td class="num">${o.citations}</td><td class="mono">${escapeHtml(o.latest || 'â')}</td><td>${o.doc_url ? `<a href="${safeUrl(o.doc_url)}" target="_blank" rel="noopener">link</a>` : 'â'}</td></tr>`).join('')}</tbody>`; 768 state.charts.push('chart-orgs', 'chart-treemap'); 769 } 770 771 function buildTopics(data){ 772 document.getElementById('page-topics').innerHTML = ` 773 <div class="row cols-2-55-45">${chartCard('Most-linked topics', 'chart-topics')}${chartCard('Policy mentions over time (by year)', 'chart-timeline')}</div> 774 <div class="row">${chartCard('Mention type â policy citation vs. name mention', 'chart-mtype')}</div>`; 775 drawPlot('chart-topics', [wordCloudTrace(data.topics)], { height:380, margin:{ l:8, r:8, t:8, b:8 }, xaxis:{ visible:false, range:[0, 100], fixedrange:true }, yaxis:{ visible:false, range:[0, 100], fixedrange:true } }); 776 drawPlot('chart-timeline', [{ type:'scatter', mode:'lines', fill:'tozeroy', x:data.timeline.map(t => t.year), y:data.timeline.map(t => t.citations), line:{ color:COLORS.gold }, fillcolor:rgba(COLORS.gold, 0.18) }], { height:380, xaxis:{ gridcolor:rgba(COLORS.paper, 0.07), automargin:true } }); 777 drawPlot('chart-mtype', [{ type:'bar', orientation:'h', x:data.mention_types.map(t => t.count), y:data.mention_types.map(t => t.type), marker:{ color:data.mention_types.map(t => t.type === 'Policy citation' ? COLORS.gold : COLORS.muted) } }], { height:180 }); 778 state.charts.push('chart-topics', 'chart-timeline', 'chart-mtype'); 779 } 780 781 function valueBox(label, value, cls, icon){ 782 return `<div class="card vbox ${cls}"><div class="vlabel">${label}</div><div class="vvalue">${value}</div><div class="icon">${icon}</div></div>`; 783 } 784 785 function handleFile(file){ 786 if (!file) return; 787 state.filename = file.name; 788 el.status.className = 'status'; 789 el.status.textContent = `Reading ${file.name}â¦`; 790 const reader = new FileReader(); 791 reader.onload = () => {
792 try { 793 const data = buildPayload(parseCSV(String(reader.result || ''))); 794 renderDashboard(data, getOptions()); 795 } catch (error){ 796 el.status.className = 'status error'; 797 el.status.textContent = error.message; 798 } 799 }; 800 reader.onerror = () => { 801 el.status.className = 'status error'; 802 el.status.textContent = 'Could not read this file.'; 803 }; 804 reader.readAsText(file); 805 } 806 807 function downloadBlob(content, filename, type){ 808 const blob = new Blob([content], { type }); 809 const url = URL.createObjectURL(blob); 810 const anchor = document.createElement('a'); 811 anchor.href = url; 812 anchor.download = filename; 813 document.body.appendChild(anchor); 814 anchor.click(); 815 anchor.remove(); 816 URL.revokeObjectURL(url); 817 } 818 819 function standaloneHtml(data, options){ 820 const styles = document.getElementById('dashboard-style').textContent; 821 const dataJson = JSON.stringify(data).replace(/</g, '\\u003c'); 822 const optsJson = JSON.stringify(options).replace(/</g, '\\u003c'); 823 return `<!doctype html> 824<html lang="en"> 825<head> 826<meta charset="utf-8" /> 827<meta name="viewport" content="width=device-width, initial-scale=1" /> 828<title>${escapeHtml(options.title)}</title> 829<link rel="preconnect" href="https://fonts.googleapis.com"> 830<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],400;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
831<script src="https://cdn.plot.ly/plotly-2.32.0.min.js"><\/script> 832<style>${styles} ${themeStyle(options.theme)} .builder-grid,.dashboard-actions,.chart-tools{display:none!important;} main{padding-top:28px;}</style> 833</head> 834<body> 835<main><section id="dashboard-shell"></section></main>
836<script> 837window.__POLICY_DATA__ = ${dataJson}; 838window.__POLICY_OPTIONS__ = ${optsJson}; 839${standaloneRenderer()} 840<\/script> 841</body> 842</html>`; 843 } 844 845 function standaloneRenderer(){ 846 return String.raw` 847(function(){ 848 const data = window.__POLICY_DATA__, options = window.__POLICY_OPTIONS__; 849 const DEFAULT_THEME = { ink:'#121A30', card:'#1B2747', card2:'#202E54', gold:'#D4A24C', teal:'#3FA092', brick:'#C1654A', muted:'#93A0C2', paper:'#F4F0E6' }; 850 const COLORS = Object.assign({}, DEFAULT_THEME, options.theme || {}); 851 const TYPE_COLOR = { 'think tank': 'gold', igo: 'teal', government: 'brick', other: 'muted2' }; 852 const TYPE_HEX = { 'think tank': COLORS.gold, igo: COLORS.teal, government: COLORS.brick, other: COLORS.muted }; 853 const clean = value => String(value || '').trim(); 854 const escapeHtml = value => clean(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); 855 const safeUrl = value => clean(value).replace(/"/g, '%22'); 856 const truncate = (value, n) => { value = clean(value); return value.length > n ? value.slice(0, n - 1).trimEnd() + 'â¦' : value; }; 857 const hexToRgb = value => { value = (value || '#000000').slice(1); return { r:parseInt(value.slice(0,2),16), g:parseInt(value.slice(2,4),16), b:parseInt(value.slice(4,6),16) }; }; 858 const rgba = (value, alpha) => { const rgb = hexToRgb(value); return 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' + alpha + ')'; }; 859 const baseLayout = extra => Object.assign({ paper_bgcolor:COLORS.card, plot_bgcolor:COLORS.card, showlegend:false, font:{ family:'Inter, sans-serif', color:COLORS.paper, size:12 }, margin:{ l:10, r:14, t:10, b:30 }, xaxis:{ title:'', gridcolor:rgba(COLORS.paper, .07), zerolinecolor:rgba(COLORS.paper, .07), automargin:true }, yaxis:{ title:'', gridcolor:rgba(COLORS.paper, .07), zerolinecolor:rgba(COLORS.paper, .07), automargin:true } }, extra); 860 const drawPlot = (id, traces, layoutExtra) => Plotly.newPlot(id, traces, baseLayout(layoutExtra), { displayModeBar:false, responsive:true }); 861 const chartCard = (title, id) => '<div class="card"><div class="card-head">' + title + '</div><div class="card-body"><div id="' + id + '" class="plot"></div></div></div>'; 862 const valueBox = (label, value, cls, icon) => '<div class="card vbox ' + cls + '"><div class="vlabel">' + label + '</div><div class="vvalue">' + value + '</div><div class="icon">' + icon + '</div></div>'; 863 function wordCloudTrace(topics){ const items = topics.slice(0, 32); if (!items.length) return { type:'scatter', mode:'text', x:[50], y:[50], text:['No topics'], hoverinfo:'skip', textfont:{ size:[16], color:[COLORS.muted] }, textposition:'middle center' }; const counts = items.map(t => t.count), max = Math.max(...counts, 1), min = Math.min(...counts, max), spread = Math.max(max - min, 1), colors = [COLORS.gold, COLORS.teal, COLORS.brick, COLORS.paper, COLORS.muted], words = items.map((topic, index) => { const size = 12 + ((topic.count - min) / spread) * 22; return { topic, index, size, width:Math.min(42, Math.max(10, topic.topic.length * size * 0.18 + 4)) }; }), rows = [], maxWidth = 96; words.forEach(word => { let row = rows[rows.length - 1]; if (!row || row.width + word.width + 3 > maxWidth){ row = { words:[], width:0 }; rows.push(row); } row.words.push(word); row.width += word.width + (row.words.length > 1 ? 3 : 0); }); const points = [], rowOffsets = rows.map((_, index) => index === 0 ? 0 : (index % 2 ? Math.ceil(index / 2) : -(index / 2))), maxOffset = Math.max(...rowOffsets.map(offset => Math.abs(offset)), 1), rowGap = 42 / maxOffset; rows.forEach((row, rowIndex) => { let x = (100 - row.width) / 2; const y = 50 + rowOffsets[rowIndex] * rowGap; row.words.forEach(word => { x += word.width / 2; points.push(Object.assign({}, word, { x, y })); x += word.width / 2 + 3; }); }); return { type:'scatter', mode:'text', x:points.map(point => point.x), y:points.map(point => point.y), text:points.map(point => point.topic.topic), hovertext:points.map(point => point.topic.topic + ': ' + point.topic.count), hoverinfo:'text', textfont:{ size:points.map(point => point.size), color:points.map(point => colors[point.index % colors.length]) }, textposition:'middle center' }; } 864 const shell = document.getElementById('dashboard-shell'); 865 shell.innerHTML = '<header class="masthead"><div class="masthead-inner" style="padding-left:0;padding-right:0;"><div class="masthead-top"><div><p class="eyebrow">Research â Policy · Citations</p><h1 class="title">' + escapeHtml(options.title) + '</h1><p class="subtitle">' + escapeHtml(options.subtitle) + '</p></div><div class="masthead-meta"><div><b>' + data.summary.total + '</b> mentions logged</div><div>' + data.summary.orgs + ' organizations · ' + data.summary.countries + ' countries/IGOs</div></div></div><nav class="tabs" id="tabs"></nav></div></header><div id="pages" style="padding-top:28px;"></div><p class="foot-note">Data source: <a href="' + safeUrl(options.sourceUrl) + '" target="_blank" rel="noopener">' + escapeHtml(options.sourceLabel) + '</a> · Built with <a href="https://policy-citations-dashaboard.vercel.app" target="_blank" rel="noopener">
865Policy Citations Dashboard Builder</a></p>'; 866 const pages = [{ id:'overview', label:'Overview', build:buildOverview }, { id:'papers', label:'By Paper', build:buildPapers }, { id:'orgs', label:'By Organization', build:buildOrgs }, { id:'topics', label:'Topics & Reach', build:buildTopics }]; 867 const tabsEl = document.getElementById('tabs'), pagesEl = document.getElementById('pages'), rendered = {}; 868 pages.forEach((page, i) => { const b = document.createElement('button'); b.className = 'tab-btn' + (i === 0 ? ' active' : ''); b.textContent = page.label; b.dataset.target = page.id; b.onclick = () => showPage(page.id); tabsEl.appendChild(b); const s = document.createElement('section'); s.className = 'page' + (i === 0 ? ' active' : ''); s.id = 'page-' + page.id; pagesEl.appendChild(s); }); 869 function showPage(id){ document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.target === id)); document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === 'page-' + id)); const page = pages.find(p => p.id === id); if (page && !rendered[id]) { page.build(); rendered[id] = true; } else window.dispatchEvent(new Event('resize')); } 870 function buildOverview(){ document.getElementById('page-overview').innerHTML = '<div class="row cols-4">' + valueBox('Policy Mentions', data.summary.total, 'gold', 'ð') + valueBox('Citing Organizations', data.summary.orgs, 'teal', 'ð') + valueBox('Countries & IGOs Reached', data.summary.countries, 'brick', 'ð') + valueBox('Papers With Policy Reach', data.summary.papers, 'muted2', 'ð') + '</div><div class="row cols-2-38-62">' + chartCard('Mentions by source type', 'chart-type') + chartCard('Mentions by country / IGO', 'chart-country') + '</div><div class="row">' + chartCard('Top citing organizations', 'chart-top-orgs') + '</div>'; drawOverviewCharts(); } 871 function drawOverviewCharts(){ drawPlot('chart-type', [{ type:'pie', hole:.55, labels:data.types.map(t => t.source_type), values:data.types.map(t => t.citations), marker:{ colors:data.types.map(t => TYPE_HEX[t.source_type] || COLORS.muted), line:{ color:COLORS.card, width:2 } }, textinfo:'label+value', textposition:'outside', textfont:{ size:11 } }], { height:300, margin:{ l:30, r:30, t:30, b:30 } }); const ctop = data.countries.slice(0, 12).reverse(); drawPlot('chart-country', [{ type:'bar', orientation:'h', x:ctop.map(c => c.citations), y:ctop.map(c => truncate(c.country, 22)), hovertext:ctop.map(c => c.country), hoverinfo:'text+x', marker:{ color:COLORS.gold } }], { height:300 }); const otop = data.orgs.slice(0, 12).reverse(); const groups = [...new Set(otop.map(o => o.source_type))]; drawPlot('chart-top-orgs', groups.map(st => ({ type:'bar', orientation:'h', name:st, x:otop.map(o => o.source_type === st ? o.citations : 0), y:otop.map(o => truncate(o.org, 30)), hovertext:otop.map(o => o.org), hoverinfo:'text+x', marker:{ color:TYPE_HEX[st] || COLORS.muted } })), { height:360, barmode:'stack', showlegend:true, legend:{ orientation:'h', y:1.1, font:{ size:11 } }, margin:{ l:10, r:14, t:36, b:30 } }); } 872 function buildPapers(){ document.getElementById('page-papers').innerHTML = '<div class="row">' + chartCard('Citations per paper', 'chart-papers') + '</div><div class="row"><div class="card"><div class="card-head">Paper registry â ranked by policy citations</div><div class="card-body tight scroll-table"><table id="table-papers"></table></div></div></div>'; const tp = data.top_papers.slice().reverse(); drawPlot('chart-papers', [{ type:'bar', orientation:'h', x:tp.map(p => p.citations), y:tp.map(p => truncate(p.paper, 60)), hovertext:tp.map(p => p.paper), hoverinfo:'text+x', marker:{ color:COLORS.gold } }], { height:460, margin:{ l:10, r:14, t:10, b:30 } }); document.getElementById('table-papers').innerHTML = '<thead><tr><th>No.</th><th>Cited paper</th><th>DOI</th><th>Citations</th></tr></thead><tbody>' + data.top_papers.map((p, i) => '<tr><td class="rank">' + String(i + 1).padStart(2, '0') + '</td><td>' + escapeHtml(p.paper) + '</td><td class="mono">' + (p.doi ? '<a href="https://doi.org/' + safeUrl(p.doi) + '" target="_blank" rel="noopener">' + escapeHtml(p.doi) + '</a>' : 'â') + '</td><td class="num">' + p.citations + '</td></tr>').join('') + '</tbody>'; } 873 function buildOrgs(){ document.getElementById('page-orgs').innerHTML = '<div class="row cols-2-55-45">' + chartCard('Reach by organization (top 15)', 'chart-orgs') + chartCard('Organization type à citations', 'chart-treemap') + '</div><div class="row"><div class="card"><div class="card-head">Organization registry</div><div class="legend"><span><span class="seal gold"></span>think tank</span><span><span class="seal teal"></span>igo</span><span><span class="seal brick"></span>government</span><span><span class="seal muted2"></span>other</span></div><div class="card-body tight scroll-table"><table id="table-orgs"></table></div></div></div>'; const o15 = data.orgs.slice(0, 15).reverse(); const groups = [...new Set(o15.map(o => o.source_type))]; drawPlot('chart-orgs', groups.map(st => ({ type:'bar', orientation:'h', name:st, x:o15.map(o => o.source_type === st ? o.citations : 0), y:o15.map(o => truncate(o.org, 30)), hovertext:o15.map(o => o.org), hoverinfo:'text+x', marker:{ color:TYPE_HEX[st] || COLORS.muted } })), { height:440, barmode:'stack', showlegend:false, margin:{ l:10, r:14, t:10, b:30 } }); const typeOrder = [...new Set(data.orgs.map(o => o.source_type))], totals = {};
873 data.orgs.forEach(o => { totals[o.source_type] = (totals[o.source_type] || 0) + o.citations; }); const total = Object.values(totals).reduce((a, b) => a + b, 0); drawPlot('chart-treemap', [{ type:'treemap', ids:['root', ...typeOrder, ...data.orgs.map((o, i) => 'org-' + i)], labels:['All organizations', ...typeOrder, ...data.orgs.map(o => truncate(o.org, 24))], parents:['', ...typeOrder.map(() => 'root'), ...data.orgs.map(o => o.source_type)], values:[total, ...typeOrder.map(t => totals[t]), ...data.orgs.map(o => o.citations)], hovertext:['All organizations', ...typeOrder, ...data.orgs.map(o => o.org)], hoverinfo:'text+value', marker:{ colors:['rgba(0,0,0,0)', ...typeOrder.map(t => TYPE_HEX[t] || COLORS.muted), ...data.orgs.map(o => TYPE_HEX[o.source_type] || COLORS.muted)] }, textfont:{ size:11 }, branchvalues:'total' }], { height:440, margin:{ l:4, r:4, t:10, b:4 } }); document.getElementById('table-orgs').innerHTML = '<thead><tr><th>Organization</th><th>Country</th><th>Type</th><th>Citations</th><th>Most recent</th><th>Document</th></tr></thead><tbody>' + data.orgs.map(o => '<tr><td><span class="seal ' + (TYPE_COLOR[o.source_type] || 'muted2') + '"></span>' + escapeHtml(o.org) + '</td><td>' + escapeHtml(o.country) + '</td><td class="mono">' + escapeHtml(o.source_type) + '</td><td class="num">' + o.citations + '</td><td class="mono">' + escapeHtml(o.latest || 'â') + '</td><td>' + (o.doc_url ? '<a href="' + safeUrl(o.doc_url) + '" target="_blank" rel="noopener">link</a>' : 'â') + '</td></tr>').join('') + '</tbody>'; } 874 function buildTopics(){ document.getElementById('page-topics').innerHTML = '<div class="row cols-2-55-45">' + chartCard('Most-linked topics', 'chart-topics') + chartCard('Policy mentions over time (by year)', 'chart-timeline') + '</div><div class="row">' + chartCard('Mention type â policy citation vs. name mention', 'chart-mtype') + '</div>'; drawPlot('chart-topics', [wordCloudTrace(data.topics)], { height:380, margin:{ l:8, r:8, t:8, b:8 }, xaxis:{ visible:false, range:[0, 100], fixedrange:true }, yaxis:{ visible:false, range:[0, 100], fixedrange:true } }); drawPlot('chart-timeline', [{ type:'scatter', mode:'lines', fill:'tozeroy', x:data.timeline.map(t => t.year), y:data.timeline.map(t => t.citations), line:{ color:COLORS.gold }, fillcolor:rgba(COLORS.gold, .18) }], { height:380, xaxis:{ gridcolor:rgba(COLORS.paper, .07), automargin:true } }); drawPlot('chart-mtype', [{ type:'bar', orientation:'h', x:data.mention_types.map(t => t.count), y:data.mention_types.map(t => t.type), marker:{ color:data.mention_types.map(t => t.type === 'Policy citation' ? COLORS.gold : COLORS.muted) } }], { height:180 }); } 875 buildOverview(); rendered.overview = true; window.addEventListener('resize', () => document.querySelectorAll('.plot').forEach(p => { if (p.id) Plotly.Plots.resize(p); })); 876})();`; 877 } 878 879 applyTheme(currentTheme()); 880 function resetThemeControls(){ 881 el.theme.ink.value = DEFAULT_THEME.ink; 882 el.theme.card.value = DEFAULT_THEME.card; 883 el.theme.gold.value = DEFAULT_THEME.gold; 884 el.theme.teal.value = DEFAULT_THEME.teal; 885 el.theme.brick.value = DEFAULT_THEME.brick; 886 el.theme.paper.value = DEFAULT_THEME.paper; 887 } 888 function resetSettingControls(){ 889 el.title.value = DEFAULT_SETTINGS.title; 890 el.subtitle.value = DEFAULT_SETTINGS.subtitle; 891 el.sourceLabel.value = DEFAULT_SETTINGS.sourceLabel; 892 el.sourceUrl.value = DEFAULT_SETTINGS.sourceUrl; 893 resetThemeControls(); 894 } 895 el.file.addEventListener('change', event => handleFile(event.target.files[0])); 896 Object.values(el.theme).forEach(input => { 897 input.addEventListener('input', () => { 898 const options = getOptions(); 899 if (state.data) renderDashboard(state.data, options); 900 }); 901 }); 902 ['dragenter', 'dragover'].forEach(name => el.drop.addEventListener(name, event => { event.preventDefault(); el.drop.classList.add('dragover'); })); 903 ['dragleave', 'drop'].forEach(name => el.drop.addEventListener(name, event => { event.preventDefault(); el.drop.classList.remove('dragover'); })); 904 el.drop.addEventListener('drop', event => handleFile(event.dataTransfer.files[0])); 905 el.sample.addEventListener('click', () => { 906 if (window.POLICY_CITATION_DATA){ 907 state.filename = 'sample policy-impact-export.csv'; 908 renderDashboard(window.POLICY_CITATION_DATA, getOptions()); 909 } else { 910 el.status.className = 'status error'; 911 el.status.textContent = 'Sample data file is not available.'; 912 } 913 }); 914 el.refresh.addEventListener('click', () => { 915 if (state.data) renderDashboard(state.data, getOptions()); 916 }); 917 el.resetSettings.addEventListener('click', () => { 918 resetSettingControls(); 919 const options = getOptions(); 920 if (state.data) renderDashboard(state.data, options); 921 }); 922 el.download.addEventListener('click', () => { 923 if (!state.data) return; 924 const options = getOptions(); 925 downloadBlob(standaloneHtml(state.data, options), `${slug(options.title)}-dashboard.html`, 'text/html;charset=utf-8'); 926 }); 927 document.addEventListener('click', event => { 928 const button = event.target.closest('[data-download-chart]'); 929 if (!button) return; 930 const chartId = button.dataset.downloadChart; 931 const format = button.dataset.format; 932 const chart = document.getElementById(chartId); 933 if (!chart || !window.Plotly) return; 934 Plotly.downloadImage(chart, { 935 format, 936 filename: `${slug(getOptions().title)}-${chartId}`, 937 width: 1400, 938 height: Math.max(700, chart.clientHeight * 2), 939 scale: 1 940 }); 941 }); 942 window.addEventListener('resize', () => { 943 document.querySelectorAll('.plot').forEach(node => { if (node.id && node.dataset.plotted && window.Plotly) Plotly.Plots.resize(node); }); 944 }); 945})(); 946</script>
946 947</body> 948</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.