PageSourceSearch

https://policy-citations-dashboard.vercel.app/

html policy-citations-dashboard.vercel.app collected 2026-10-03 07:30:27 UTC 59,158 bytes, 948 lines download raw bytes

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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.