compare.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. (function () {
  2. 'use strict';
  3. // Security: validate any member id before fetch / DOM use.
  4. var ID_RE = /^[A-Z]\d{6}$|^S\d{3,4}$/;
  5. var MAX_SELECTED = 6;
  6. var PARTY_COLORS = { R: '#d9534f', D: '#337ab7', I: '#5cb85c' };
  7. var PALETTE = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b'];
  8. var ALIGN_KEYS = ['Helped Republicans', 'Helped Democrats', 'Helped Both', 'Helped Neither'];
  9. var state = {
  10. manifest: null,
  11. membersById: {},
  12. BASE: './data/',
  13. selectedIds: [],
  14. memberCache: {},
  15. colorById: {},
  16. currentAlignClass: 'Helped Republicans',
  17. skipPush: false
  18. };
  19. var els = {};
  20. var charts = {};
  21. document.addEventListener('DOMContentLoaded', init);
  22. function init() {
  23. var root = document.getElementById('polisci-root');
  24. if (!root) return;
  25. state.BASE = root.dataset.base || './data/';
  26. els.root = root;
  27. els.search = document.getElementById('member-search');
  28. els.results = document.getElementById('member-results');
  29. els.pills = document.getElementById('selected-pills');
  30. els.emptyHint = document.getElementById('compare-empty-hint');
  31. els.lastGen = document.getElementById('last-generated');
  32. els.filterChamber = document.getElementById('filter-chamber');
  33. els.filterParty = document.getElementById('filter-party');
  34. els.filterState = document.getElementById('filter-state-select');
  35. els.filterReset = document.getElementById('filter-reset');
  36. els.sidebarToggle = document.getElementById('sidebar-toggle');
  37. els.sidebar = document.getElementById('sidebar');
  38. els.alignSwitcher = document.getElementById('align-class-switcher');
  39. var inline = document.getElementById('polisci-manifest');
  40. if (inline && inline.textContent) {
  41. try { onManifest(JSON.parse(inline.textContent)); return; }
  42. catch (e) { /* fall through */ }
  43. }
  44. fetch(state.BASE + 'manifest.json', { credentials: 'omit' })
  45. .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
  46. .then(onManifest)
  47. .catch(function (e) { showFatal('Failed to load manifest: ' + e.message + ' — if you opened this file directly, serve it over HTTP.'); });
  48. }
  49. function showFatal(msg) {
  50. els.root.replaceChildren();
  51. var p = document.createElement('p');
  52. p.style.padding = '20px';
  53. p.style.color = '#c0392b';
  54. p.textContent = msg;
  55. els.root.appendChild(p);
  56. }
  57. function onManifest(m) {
  58. state.manifest = m;
  59. var members = m.members || [];
  60. for (var i = 0; i < members.length; i++) {
  61. state.membersById[members[i].id] = members[i];
  62. }
  63. els.lastGen.textContent = m.generated_at || '';
  64. populateStateOptions(members);
  65. wireEvents();
  66. buildCharts();
  67. initFromURL();
  68. window.addEventListener('popstate', onPopState);
  69. }
  70. function populateStateOptions(members) {
  71. var states = {};
  72. for (var i = 0; i < members.length; i++) states[members[i].s] = true;
  73. var keys = Object.keys(states).sort();
  74. els.filterState.replaceChildren();
  75. for (var j = 0; j < keys.length; j++) {
  76. var opt = document.createElement('option');
  77. opt.value = keys[j];
  78. opt.textContent = keys[j];
  79. opt.selected = true;
  80. els.filterState.appendChild(opt);
  81. }
  82. }
  83. function wireEvents() {
  84. var chamberInputs = els.filterChamber.querySelectorAll('input[type=checkbox]');
  85. var partyInputs = els.filterParty.querySelectorAll('input[type=checkbox]');
  86. chamberInputs.forEach(function (i) { i.addEventListener('change', refreshFilter); });
  87. partyInputs.forEach(function (i) { i.addEventListener('change', refreshFilter); });
  88. els.filterState.addEventListener('change', refreshFilter);
  89. els.filterReset.addEventListener('click', resetFilters);
  90. els.search.addEventListener('input', refreshFilter);
  91. els.search.addEventListener('focus', refreshFilter);
  92. document.addEventListener('click', function (e) {
  93. if (!els.results.contains(e.target) && e.target !== els.search) {
  94. els.results.classList.add('is-hidden');
  95. els.search.setAttribute('aria-expanded', 'false');
  96. }
  97. });
  98. if (els.sidebarToggle) {
  99. els.sidebarToggle.addEventListener('click', function () {
  100. var open = els.sidebar.classList.toggle('is-open');
  101. els.sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
  102. });
  103. }
  104. els.alignSwitcher.addEventListener('change', function () {
  105. state.currentAlignClass = els.alignSwitcher.value;
  106. renderAlignmentTime();
  107. });
  108. }
  109. function resetFilters() {
  110. var inputs = els.filterChamber.querySelectorAll('input[type=checkbox]');
  111. inputs.forEach(function (i) { i.checked = true; });
  112. inputs = els.filterParty.querySelectorAll('input[type=checkbox]');
  113. inputs.forEach(function (i) { i.checked = true; });
  114. for (var j = 0; j < els.filterState.options.length; j++) {
  115. els.filterState.options[j].selected = true;
  116. }
  117. els.search.value = '';
  118. refreshFilter();
  119. }
  120. function getCheckedValues(group) {
  121. var out = [];
  122. group.querySelectorAll('input[type=checkbox]:checked').forEach(function (i) { out.push(i.value); });
  123. return out;
  124. }
  125. function getSelectedStates() {
  126. var out = [];
  127. for (var i = 0; i < els.filterState.options.length; i++) {
  128. if (els.filterState.options[i].selected) out.push(els.filterState.options[i].value);
  129. }
  130. return out;
  131. }
  132. function initialsOf(name) {
  133. var parts = name.split(/\s+/);
  134. var out = '';
  135. for (var i = 0; i < parts.length; i++) {
  136. if (parts[i]) out += parts[i].charAt(0).toUpperCase();
  137. }
  138. return out;
  139. }
  140. function refreshFilter() {
  141. var chambers = getCheckedValues(els.filterChamber);
  142. var parties = getCheckedValues(els.filterParty);
  143. var states = getSelectedStates();
  144. var q = (els.search.value || '').trim();
  145. var qLower = q.toLowerCase();
  146. var qUpper = q.toUpperCase();
  147. var initialsMode = q.length > 0 && q.length <= 4 && /^[A-Za-z]+$/.test(q);
  148. var members = state.manifest.members;
  149. var matches = [];
  150. for (var i = 0; i < members.length && matches.length < 50; i++) {
  151. var m = members[i];
  152. if (chambers.indexOf(m.c) < 0) continue;
  153. if (parties.indexOf(m.p) < 0) continue;
  154. if (states.indexOf(m.s) < 0) continue;
  155. if (q) {
  156. var nameLower = m.n.toLowerCase();
  157. var hit = nameLower.indexOf(qLower) >= 0;
  158. if (!hit && initialsMode) {
  159. if (initialsOf(m.n) === qUpper) hit = true;
  160. }
  161. if (!hit) continue;
  162. }
  163. matches.push(m);
  164. }
  165. matches.sort(function (a, b) { return a.n.localeCompare(b.n); });
  166. renderResults(matches, q);
  167. }
  168. function renderResults(matches, q) {
  169. els.results.replaceChildren();
  170. if (!q && matches.length === state.manifest.members.length) {
  171. els.results.classList.add('is-hidden');
  172. els.search.setAttribute('aria-expanded', 'false');
  173. return;
  174. }
  175. if (!matches.length) {
  176. var li0 = document.createElement('li');
  177. li0.textContent = 'No members match.';
  178. li0.setAttribute('aria-disabled', 'true');
  179. els.results.appendChild(li0);
  180. } else {
  181. for (var i = 0; i < matches.length; i++) {
  182. var m = matches[i];
  183. var li = document.createElement('li');
  184. li.setAttribute('role', 'option');
  185. li.setAttribute('data-id', m.id);
  186. var alreadyIn = state.selectedIds.indexOf(m.id) >= 0;
  187. var atCap = state.selectedIds.length >= MAX_SELECTED;
  188. li.textContent = m.n + ' (' + m.p + '-' + m.s + ', ' + (m.c === 'H' ? 'House' : 'Senate') + ')' +
  189. (alreadyIn ? ' — selected' : (atCap ? ' — max 6' : ''));
  190. if (alreadyIn || atCap) {
  191. li.setAttribute('aria-disabled', 'true');
  192. } else {
  193. li.addEventListener('click', onResultClick);
  194. }
  195. els.results.appendChild(li);
  196. }
  197. }
  198. els.results.classList.remove('is-hidden');
  199. els.search.setAttribute('aria-expanded', 'true');
  200. }
  201. function onResultClick(e) {
  202. var id = e.currentTarget.getAttribute('data-id');
  203. if (!isValidId(id)) return;
  204. els.results.classList.add('is-hidden');
  205. els.search.setAttribute('aria-expanded', 'false');
  206. els.search.value = '';
  207. addMember(id);
  208. }
  209. function isValidId(id) {
  210. return typeof id === 'string' && ID_RE.test(id) &&
  211. Object.prototype.hasOwnProperty.call(state.membersById, id);
  212. }
  213. function pickColor() {
  214. var used = {};
  215. for (var k in state.colorById) {
  216. if (Object.prototype.hasOwnProperty.call(state.colorById, k)) used[state.colorById[k]] = true;
  217. }
  218. for (var i = 0; i < PALETTE.length; i++) {
  219. if (!used[PALETTE[i]]) return PALETTE[i];
  220. }
  221. return PALETTE[0];
  222. }
  223. function addMember(id) {
  224. if (!isValidId(id)) return;
  225. if (state.selectedIds.indexOf(id) >= 0) return;
  226. if (state.selectedIds.length >= MAX_SELECTED) return;
  227. var version = state.manifest.version || '';
  228. var url = state.BASE + 'members/' + encodeURIComponent(id) + '.json?v=' + encodeURIComponent(version);
  229. fetch(url, { credentials: 'omit' })
  230. .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
  231. .then(function (member) {
  232. if (state.selectedIds.indexOf(id) >= 0) return;
  233. if (state.selectedIds.length >= MAX_SELECTED) return;
  234. state.memberCache[id] = member;
  235. state.colorById[id] = pickColor();
  236. state.selectedIds.push(id);
  237. renderPills();
  238. pushUrlState();
  239. renderAllCharts();
  240. })
  241. .catch(function (e) {
  242. // Silent for one member load failure — surface via empty hint area.
  243. els.emptyHint.textContent = 'Failed to load member ' + id + ': ' + e.message;
  244. });
  245. }
  246. function removeMember(id) {
  247. var idx = state.selectedIds.indexOf(id);
  248. if (idx < 0) return;
  249. state.selectedIds.splice(idx, 1);
  250. delete state.memberCache[id];
  251. delete state.colorById[id];
  252. renderPills();
  253. pushUrlState();
  254. renderAllCharts();
  255. }
  256. function renderPills() {
  257. els.pills.replaceChildren();
  258. for (var i = 0; i < state.selectedIds.length; i++) {
  259. var id = state.selectedIds[i];
  260. var member = state.memberCache[id];
  261. if (!member) continue;
  262. var color = state.colorById[id] || '#888';
  263. var li = document.createElement('li');
  264. li.className = 'compare-pill';
  265. li.style.setProperty('--pill-color', color);
  266. li.style.borderColor = color;
  267. li.style.backgroundColor = color + '22';
  268. var swatch = document.createElement('span');
  269. swatch.className = 'compare-pill-swatch';
  270. swatch.style.backgroundColor = color;
  271. swatch.setAttribute('aria-hidden', 'true');
  272. li.appendChild(swatch);
  273. var nameBtn = document.createElement('button');
  274. nameBtn.type = 'button';
  275. nameBtn.className = 'compare-pill-name';
  276. nameBtn.textContent = member.name + ' (' + member.party + '-' + member.state + ')';
  277. nameBtn.title = 'Open ' + member.name + ' dashboard in new tab';
  278. (function (mid) {
  279. nameBtn.addEventListener('click', function () {
  280. window.open('app.html?id=' + encodeURIComponent(mid), '_blank', 'noopener');
  281. });
  282. })(id);
  283. li.appendChild(nameBtn);
  284. var rm = document.createElement('button');
  285. rm.type = 'button';
  286. rm.className = 'compare-pill-remove';
  287. rm.setAttribute('aria-label', 'Remove ' + member.name);
  288. rm.textContent = '×';
  289. (function (mid) {
  290. rm.addEventListener('click', function () { removeMember(mid); });
  291. })(id);
  292. li.appendChild(rm);
  293. els.pills.appendChild(li);
  294. }
  295. if (state.selectedIds.length === 0) {
  296. els.emptyHint.textContent = 'Pick up to 6 members to compare. Click a pill to open the member’s full dashboard in a new tab.';
  297. } else {
  298. els.emptyHint.textContent = state.selectedIds.length + ' of ' + MAX_SELECTED +
  299. ' selected. Click a pill name to open that member’s full dashboard in a new tab.';
  300. }
  301. }
  302. // ---- Charts ----
  303. function buildCharts() {
  304. if (typeof Chart === 'undefined') return;
  305. var common = { responsive: true, maintainAspectRatio: false };
  306. charts.alignmentTime = new Chart(document.getElementById('cmp-chart-alignment-time'), {
  307. type: 'line',
  308. data: { labels: [], datasets: [] },
  309. options: Object.assign({}, common, {
  310. plugins: { legend: { position: 'bottom' } },
  311. scales: { y: { beginAtZero: true } }
  312. })
  313. });
  314. charts.againstOwn = new Chart(document.getElementById('cmp-chart-against-own'), {
  315. type: 'line',
  316. data: { labels: [], datasets: [] },
  317. options: Object.assign({}, common, {
  318. plugins: { legend: { position: 'bottom' } },
  319. scales: { y: { beginAtZero: true } }
  320. })
  321. });
  322. charts.kpi = new Chart(document.getElementById('cmp-chart-kpi'), {
  323. type: 'bar',
  324. data: {
  325. labels: ['% against GOP', '% against Dem', 'Lone Wolf %', 'Participation %', 'Blocked Dem', 'Blocked GOP'],
  326. datasets: []
  327. },
  328. options: Object.assign({}, common, {
  329. plugins: {
  330. legend: { position: 'bottom' },
  331. tooltip: {
  332. callbacks: {
  333. label: function (ctx) {
  334. var v = Number(ctx.parsed.y);
  335. if (isNaN(v)) v = Number(ctx.parsed) || 0;
  336. return String(ctx.dataset.label) + ': ' + v.toFixed(1);
  337. }
  338. }
  339. }
  340. },
  341. scales: { y: { beginAtZero: true } }
  342. })
  343. });
  344. charts.defection = new Chart(document.getElementById('cmp-chart-defection'), {
  345. type: 'scatter',
  346. data: { datasets: [] },
  347. options: Object.assign({}, common, {
  348. plugins: {
  349. legend: { display: false },
  350. tooltip: {
  351. callbacks: {
  352. label: function (ctx) {
  353. var p = ctx.raw || {};
  354. return String(ctx.dataset.label) + ': (' +
  355. (Number(p.x) || 0).toFixed(1) + '% R, ' +
  356. (Number(p.y) || 0).toFixed(1) + '% D)';
  357. }
  358. }
  359. }
  360. },
  361. scales: {
  362. x: { beginAtZero: true, title: { display: true, text: '% against GOP majority' } },
  363. y: { beginAtZero: true, title: { display: true, text: '% against Dem majority' } }
  364. }
  365. })
  366. });
  367. charts.voteDist = new Chart(document.getElementById('cmp-chart-vote-dist'), {
  368. type: 'bar',
  369. data: { labels: ['Yea', 'Nay', 'Present', 'Not Voting'], datasets: [] },
  370. options: Object.assign({}, common, {
  371. plugins: { legend: { position: 'bottom' } },
  372. scales: { y: { beginAtZero: true } }
  373. })
  374. });
  375. }
  376. function renderAllCharts() {
  377. renderAlignmentTime();
  378. renderAgainstOwn();
  379. renderKpi();
  380. renderDefection();
  381. renderVoteDist();
  382. }
  383. function unionMonths() {
  384. var set = {};
  385. for (var i = 0; i < state.selectedIds.length; i++) {
  386. var m = state.memberCache[state.selectedIds[i]];
  387. var months = m && m.metrics && Array.isArray(m.metrics.months) ? m.metrics.months : [];
  388. for (var j = 0; j < months.length; j++) set[months[j]] = true;
  389. }
  390. return Object.keys(set).sort();
  391. }
  392. function replaceArray(target, source) {
  393. target.length = 0;
  394. for (var i = 0; i < source.length; i++) target.push(source[i]);
  395. }
  396. function monthlySeries(member, alignClass, months) {
  397. var mMonths = (member.metrics && member.metrics.months) || [];
  398. var arr = (member.metrics && member.metrics.monthly && member.metrics.monthly[alignClass]) || [];
  399. var idx = {};
  400. for (var i = 0; i < mMonths.length; i++) idx[mMonths[i]] = i;
  401. return months.map(function (mo) {
  402. var k = idx[mo];
  403. return (k != null && arr[k] != null) ? Number(arr[k]) : 0;
  404. });
  405. }
  406. function renderAlignmentTime() {
  407. if (!charts.alignmentTime) return;
  408. var months = unionMonths();
  409. replaceArray(charts.alignmentTime.data.labels, months);
  410. var datasets = [];
  411. for (var i = 0; i < state.selectedIds.length; i++) {
  412. var id = state.selectedIds[i];
  413. var member = state.memberCache[id];
  414. if (!member) continue;
  415. var color = state.colorById[id];
  416. datasets.push({
  417. label: member.name,
  418. data: monthlySeries(member, state.currentAlignClass, months),
  419. borderColor: color,
  420. backgroundColor: color + '33',
  421. tension: 0.2,
  422. fill: false
  423. });
  424. }
  425. replaceArray(charts.alignmentTime.data.datasets, datasets);
  426. charts.alignmentTime.update('none');
  427. }
  428. // "Voted against own party" per month is NOT directly emitted by analyze.py.
  429. // We use monthly['Helped Neither'] as a proxy; caveat noted in NOTES.md item 6.
  430. function renderAgainstOwn() {
  431. if (!charts.againstOwn) return;
  432. var months = unionMonths();
  433. replaceArray(charts.againstOwn.data.labels, months);
  434. var datasets = [];
  435. for (var i = 0; i < state.selectedIds.length; i++) {
  436. var id = state.selectedIds[i];
  437. var member = state.memberCache[id];
  438. if (!member) continue;
  439. var color = state.colorById[id];
  440. datasets.push({
  441. label: member.name,
  442. data: monthlySeries(member, 'Helped Neither', months),
  443. borderColor: color,
  444. backgroundColor: color + '33',
  445. tension: 0.2,
  446. fill: false
  447. });
  448. }
  449. replaceArray(charts.againstOwn.data.datasets, datasets);
  450. charts.againstOwn.update('none');
  451. }
  452. function kpiValues(member) {
  453. var x = (member && member.metrics) || {};
  454. var voting = Number(x.voting) || 0;
  455. var total = Number(x.total) || 0;
  456. var againstGop = Number(x.voted_against_gop) || 0;
  457. var withGop = Number(x.voted_with_gop) || 0;
  458. var againstDem = Number(x.voted_against_dem) || 0;
  459. var withDem = Number(x.voted_with_dem) || 0;
  460. var lone = Number(x.lone_wolf) || 0;
  461. var denomGop = againstGop + withGop;
  462. var denomDem = againstDem + withDem;
  463. return [
  464. denomGop > 0 ? (100 * againstGop / denomGop) : 0,
  465. denomDem > 0 ? (100 * againstDem / denomDem) : 0,
  466. voting > 0 ? (100 * lone / voting) : 0,
  467. total > 0 ? (100 * voting / total) : 0,
  468. Number(x.blocked_dem_count) || 0,
  469. Number(x.blocked_rep_count) || 0
  470. ];
  471. }
  472. function renderKpi() {
  473. if (!charts.kpi) return;
  474. var datasets = [];
  475. for (var i = 0; i < state.selectedIds.length; i++) {
  476. var id = state.selectedIds[i];
  477. var member = state.memberCache[id];
  478. if (!member) continue;
  479. var color = state.colorById[id];
  480. datasets.push({
  481. label: member.name,
  482. data: kpiValues(member),
  483. backgroundColor: color,
  484. borderColor: color
  485. });
  486. }
  487. replaceArray(charts.kpi.data.datasets, datasets);
  488. charts.kpi.update('none');
  489. }
  490. function renderDefection() {
  491. if (!charts.defection) return;
  492. var datasets = [];
  493. for (var i = 0; i < state.selectedIds.length; i++) {
  494. var id = state.selectedIds[i];
  495. var member = state.memberCache[id];
  496. if (!member) continue;
  497. var vals = kpiValues(member);
  498. var partyColor = PARTY_COLORS[member.party] || '#888';
  499. datasets.push({
  500. label: member.name,
  501. data: [{ x: vals[0], y: vals[1] }],
  502. backgroundColor: partyColor,
  503. borderColor: partyColor,
  504. pointBackgroundColor: partyColor,
  505. pointRadius: 8,
  506. pointHoverRadius: 10
  507. });
  508. }
  509. replaceArray(charts.defection.data.datasets, datasets);
  510. charts.defection.update('none');
  511. }
  512. function voteDistValues(member) {
  513. var mv = (member && member.metrics && member.metrics.member) || {};
  514. // Some procedural votes use Aye/No instead of Yea/Nay — sum them for display.
  515. var yea = (Number(mv.Yea) || 0) + (Number(mv.Aye) || 0);
  516. var nay = (Number(mv.Nay) || 0) + (Number(mv.No) || 0);
  517. var present = Number(mv.Present) || 0;
  518. var nv = Number(mv['Not Voting']) || 0;
  519. return [yea, nay, present, nv];
  520. }
  521. function renderVoteDist() {
  522. if (!charts.voteDist) return;
  523. var datasets = [];
  524. for (var i = 0; i < state.selectedIds.length; i++) {
  525. var id = state.selectedIds[i];
  526. var member = state.memberCache[id];
  527. if (!member) continue;
  528. var color = state.colorById[id];
  529. datasets.push({
  530. label: member.name,
  531. data: voteDistValues(member),
  532. backgroundColor: color,
  533. borderColor: color
  534. });
  535. }
  536. replaceArray(charts.voteDist.data.datasets, datasets);
  537. charts.voteDist.update('none');
  538. }
  539. // ---- URL state ----
  540. function parseIdsParam() {
  541. var params = new URLSearchParams(window.location.search);
  542. var raw = params.get('ids');
  543. if (!raw) return [];
  544. var parts = raw.split(',');
  545. var out = [];
  546. for (var i = 0; i < parts.length && out.length < MAX_SELECTED; i++) {
  547. var id = parts[i];
  548. if (isValidId(id) && out.indexOf(id) < 0) out.push(id);
  549. }
  550. return out;
  551. }
  552. function pushUrlState() {
  553. if (state.skipPush) { state.skipPush = false; return; }
  554. var u;
  555. if (state.selectedIds.length === 0) {
  556. u = window.location.pathname;
  557. } else {
  558. u = '?ids=' + state.selectedIds.join(',');
  559. }
  560. history.pushState({ ids: state.selectedIds.slice() }, '', u);
  561. }
  562. function initFromURL() {
  563. refreshFilter();
  564. var ids = parseIdsParam();
  565. if (ids.length === 0) {
  566. renderPills();
  567. renderAllCharts();
  568. return;
  569. }
  570. // Sequentially add (each fetch independent; order preserved via URL parse).
  571. state.skipPush = true;
  572. var pending = ids.slice();
  573. var loaded = [];
  574. function next() {
  575. if (pending.length === 0) {
  576. renderPills();
  577. renderAllCharts();
  578. return;
  579. }
  580. var id = pending.shift();
  581. var version = state.manifest.version || '';
  582. var url = state.BASE + 'members/' + encodeURIComponent(id) + '.json?v=' + encodeURIComponent(version);
  583. fetch(url, { credentials: 'omit' })
  584. .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
  585. .then(function (member) {
  586. state.memberCache[id] = member;
  587. state.colorById[id] = pickColor();
  588. state.selectedIds.push(id);
  589. loaded.push(id);
  590. })
  591. .catch(function () { /* skip broken id */ })
  592. .then(next);
  593. }
  594. next();
  595. }
  596. function onPopState() {
  597. var ids = parseIdsParam();
  598. // Reset selection and reload from URL.
  599. state.selectedIds = [];
  600. state.memberCache = {};
  601. state.colorById = {};
  602. state.skipPush = true;
  603. if (ids.length === 0) {
  604. renderPills();
  605. renderAllCharts();
  606. return;
  607. }
  608. var pending = ids.slice();
  609. function next() {
  610. if (pending.length === 0) {
  611. renderPills();
  612. renderAllCharts();
  613. return;
  614. }
  615. var id = pending.shift();
  616. var version = state.manifest.version || '';
  617. var url = state.BASE + 'members/' + encodeURIComponent(id) + '.json?v=' + encodeURIComponent(version);
  618. fetch(url, { credentials: 'omit' })
  619. .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
  620. .then(function (member) {
  621. state.memberCache[id] = member;
  622. state.colorById[id] = pickColor();
  623. state.selectedIds.push(id);
  624. })
  625. .catch(function () {})
  626. .then(next);
  627. }
  628. next();
  629. }
  630. })();