mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-11-09 10:39:41 -06:00
196 lines
12 KiB
JavaScript
196 lines
12 KiB
JavaScript
(function () {
|
|
const embedded = document.getElementById('adp-data');
|
|
const svg = document.querySelector('.adp-map');
|
|
if (!embedded) return;
|
|
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
const list = document.getElementById('crs-list'), empty = document.getElementById('crs-empty');
|
|
const q = document.getElementById('crs-q'), chips = [...document.querySelectorAll('.crs-chip')];
|
|
let rows = [], readers = [], early = [], schools = [], kind = 'all', view = 'courses';
|
|
const tabs = [...document.querySelectorAll('.crs-tab')];
|
|
const intro = document.getElementById('crs-intro');
|
|
// "IIT" should find "Indian Institute of Technology, Madras": match on the
|
|
// name's initials as well as the name.
|
|
const initials = n => n.replace(/[(),]/g, ' ').split(/\s+/)
|
|
.filter(w => w && !/^(of|the|and|at|de|da|di|la|e|in|for)$/i.test(w)).map(w => w[0]).join('').toLowerCase();
|
|
const hayOf = (...parts) => { const t = parts.filter(Boolean).join(' '); return (t + ' ' + initials(t)).toLowerCase(); };
|
|
const rBox = document.getElementById('crs-readers');
|
|
const ADD = 'https://github.com/harvard-edge/cs249r_book/issues/new?template=add_course.yml';
|
|
|
|
const mark = s => `<span class="adp-mark${s.logo ? '' : ' adp-mark--mono'}">${
|
|
s.logo ? `<img src="${esc(s.logo)}" alt="" loading="lazy" width="28" height="28">` : esc(s.mono)}</span>`;
|
|
|
|
function project(lat, lon) {
|
|
const k = +svg.dataset.k, ox = +svg.dataset.ox, oy = +svg.dataset.oy;
|
|
const A1 = 1.340264, A2 = -0.081106, A3 = 0.000893, A4 = 0.003796, M = Math.sqrt(3) / 2;
|
|
const t = Math.asin(M * Math.sin(lat * Math.PI / 180)), t2 = t * t, t6 = t2 * t2 * t2;
|
|
const x = (lon * Math.PI / 180) * Math.cos(t) / (M * (A1 + 3 * A2 * t2 + t6 * (7 * A3 + 9 * A4 * t2)));
|
|
return [ox + x * k, oy - t * (A1 + A2 * t2 + t6 * (A3 + A4 * t2)) * k];
|
|
}
|
|
|
|
// Schools view: every school we know of, A to Z within each country, so a
|
|
// student can see at a glance whether theirs is here. Teaching schools link
|
|
// back to their courses; reader schools are marked as such.
|
|
function drawSchools(needle) {
|
|
document.getElementById('directory-title').parentElement.hidden = false;
|
|
document.querySelector('.crs-mapwrap').hidden = false;
|
|
const shown = schools.filter(x => !needle || x.hay.includes(needle));
|
|
const groups = new Map();
|
|
shown.forEach(x => { if (!groups.has(x.country)) groups.set(x.country, []); groups.get(x.country).push(x); });
|
|
const nc = new Set(schools.map(x => x.country)).size;
|
|
intro.hidden = false;
|
|
// Counts follow the complete refreshed list, independent of search filters.
|
|
intro.innerHTML = `${schools.length} institutions across ${nc} countries, represented through instructor-submitted courses, public syllabi, GitHub affiliations, and referrals from university websites. ` +
|
|
`Don't see yours? <a href="${ADD}" target="_blank" rel="noopener">Add a course</a> or visit the <a href="https://mlsysbook.ai/instructors/">Instructor Hub</a>.`;
|
|
list.innerHTML = [...groups.entries()]
|
|
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))
|
|
.map(([country, xs]) => `<h2 class="crs-country">${esc(country)} <span>${xs.length}</span></h2><ul class="crs-schools">` +
|
|
xs.sort((a, b) => a.name.localeCompare(b.name)).map(x => {
|
|
const inner = `${mark(x)}<span class="crs-sname">${esc(x.name)}</span>` +
|
|
`<span class="crs-badge crs-badge--${x.status}">${x.status === 'teach' ? 'Course listed' : x.status === 'early' ? 'Early adopter' : 'Community engagement'}</span>`;
|
|
return x.status === 'teach'
|
|
? `<li><button type="button" class="crs-srow" data-name="${esc(x.name)}">${inner}</button></li>`
|
|
: `<li><div class="crs-srow">${inner}</div></li>`;
|
|
}).join('') + `</ul>`).join('');
|
|
rBox.hidden = true;
|
|
document.getElementById('crs-early').hidden = true;
|
|
empty.hidden = shown.length > 0;
|
|
empty.innerHTML = `“${esc(q.value.trim())}” isn't on the list yet. If a course there uses the book, <a href="${ADD}" target="_blank" rel="noopener">add it</a>.`;
|
|
}
|
|
|
|
function draw() {
|
|
const needle = q.value.trim().toLowerCase();
|
|
const featured = document.getElementById('hosted-courses');
|
|
const cards = [...document.querySelectorAll('.course-feature')];
|
|
cards.forEach(card => {
|
|
card.hidden = view === 'schools' || (kind !== 'all' && card.dataset.kind !== kind) ||
|
|
(needle && !card.dataset.search.includes(needle));
|
|
});
|
|
featured.hidden = cards.every(card => card.hidden);
|
|
document.getElementById('crs-chips').hidden = view === 'schools';
|
|
tabs.forEach(t => t.setAttribute('aria-selected', String(t.dataset.view === view)));
|
|
document.getElementById('directory-heading').textContent = view === 'schools'
|
|
? 'Where people are learning.' : 'Learn and teach with MLSysBook.';
|
|
document.getElementById('directory-description').textContent = view === 'schools'
|
|
? 'Explore the institutions connected to the MLSysBook learning community, through teaching and engagement with the book and its resources.'
|
|
: 'Explore readings, experiments, and teaching plans for machine learning systems. Follow a learning path or adapt the materials for a course.';
|
|
document.getElementById('directory-title').textContent = view === 'schools'
|
|
? 'Our learning community' : 'Community courses and workshops';
|
|
document.querySelector('#directory-title + span').textContent = view === 'schools'
|
|
? 'Institutions represented in teaching & engagement' : 'Instructor-maintained offerings';
|
|
if (view === 'schools') return drawSchools(needle);
|
|
intro.hidden = true;
|
|
const shown = rows.filter(r => (kind === 'all' || r.kind === kind) &&
|
|
(!needle || r.hay.includes(needle)));
|
|
document.getElementById('directory-title').parentElement.hidden = shown.length === 0;
|
|
document.querySelector('.crs-mapwrap').hidden = shown.length === 0;
|
|
// Grouped by country, largest first: most visitors scan for their own.
|
|
const groups = new Map();
|
|
shown.forEach(r => { if (!groups.has(r.s.country)) groups.set(r.s.country, []); groups.get(r.s.country).push(r); });
|
|
list.innerHTML = [...groups.entries()]
|
|
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))
|
|
.map(([country, rs]) => `<h2 class="crs-country">${esc(country)} <span>${rs.length}</span></h2>` + rs.map(r => `
|
|
<a class="crs-row" href="${esc(r.url)}" target="_blank" rel="noopener">
|
|
${mark(r.s)}
|
|
<span class="crs-course"><b>${esc(r.title)}</b>${r.term ? `<span class="crs-term">${esc(r.term)}</span>` : ''}</span>
|
|
<span class="crs-school">${esc(r.s.name)}<span>${esc(r.s.city)}, ${esc(r.s.country)}</span></span>
|
|
<span class="crs-kind crs-kind--${r.kind}">${r.kind === 'workshop' ? 'Workshop' : 'Course'}</span>
|
|
<span class="crs-go" aria-hidden="true">↗</span>
|
|
</a>`).join('')).join('');
|
|
// Readers: only on a search, and only by school name, so typing a
|
|
// country lists its courses without a wall of reader schools.
|
|
const hits = needle.length >= 3 ? readers.filter(r => r.hay.includes(needle)).slice(0, 12) : [];
|
|
rBox.hidden = !hits.length;
|
|
if (hits.length) {
|
|
const one = hits.length === 1 ? hits[0].name : null;
|
|
document.getElementById('crs-readers-title').textContent = shown.length
|
|
? 'Readers study here too'
|
|
: (one ? `Readers at ${one} use the book` : 'Readers at these schools use the book');
|
|
document.getElementById('crs-readers-note').innerHTML = shown.length
|
|
? 'These schools have readers but no listed course yet.'
|
|
: `No course there is listed yet, but readers study there. If a class uses the book, <a href="${ADD}" target="_blank" rel="noopener">add it</a>; if not, <a href="https://mlsysbook.ai/instructors/">point your instructor to the Instructor Hub</a>.`;
|
|
document.getElementById('crs-readers-list').innerHTML =
|
|
hits.map(r => `<li>${esc(r.name)}<small>${esc(r.country)}</small></li>`).join('');
|
|
}
|
|
// Early adopters: shown in full when browsing, filtered when searching.
|
|
const eShown = early.filter(e => !needle || e.hay.includes(needle));
|
|
document.getElementById('crs-early').hidden = !eShown.length || !['all', 'course'].includes(kind);
|
|
document.getElementById('crs-early-list').innerHTML = eShown.map(({s}) =>
|
|
`<li>${mark(s)}<span>${esc(s.name)}<small>${esc(s.city)}, ${esc(s.country)}</small></span></li>`).join('');
|
|
empty.hidden = shown.length > 0 || hits.length > 0 || (eShown.length > 0 && ['all', 'course'].includes(kind)) || !featured.hidden;
|
|
empty.innerHTML = `Nothing for “${esc(q.value.trim())}” yet. Know a course that uses the book? <a href="${ADD}" target="_blank" rel="noopener">Add it</a>.`;
|
|
}
|
|
|
|
function render(data) {
|
|
const t = data.teaching || [];
|
|
rows = [];
|
|
t.forEach(s => (s.courses || []).forEach(c => rows.push({
|
|
s, kind: s.kind === 'workshop' ? 'workshop' : 'course', title: c.title, term: c.term, url: c.url,
|
|
hay: hayOf(s.name, s.short) + ' ' + [s.city, s.country, c.title, c.term].join(' ').toLowerCase()
|
|
})));
|
|
rows.sort((a, b) => a.s.name.localeCompare(b.s.name));
|
|
readers = (data.readers || []).map(r => ({...r, hay: hayOf(r.name)}));
|
|
|
|
early = t.filter(s => s.kind === 'acknowledged').map(s => ({s, hay: hayOf(s.name, s.short) + ' ' + [s.city, s.country].join(' ').toLowerCase()}));
|
|
const seen = new Set();
|
|
schools = t.map(s => ({name: s.name, country: s.country, logo: s.logo, mono: s.mono,
|
|
status: s.kind === 'acknowledged' ? 'early' : 'teach', hay: hayOf(s.name, s.short) + ' ' + s.country.toLowerCase()}))
|
|
.concat((data.readers || []).map(r => ({name: r.name, country: r.country, logo: r.logo, mono: initials(r.name).slice(0, 4).toUpperCase(),
|
|
status: 'readers', hay: hayOf(r.name) + ' ' + r.country.toLowerCase()})))
|
|
.filter(x => { const k = x.name.toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
|
|
draw();
|
|
|
|
if (svg) {
|
|
svg.querySelector('.adp-pins').innerHTML = t.slice().sort((a, b) => b.latlon[0] - a.latlon[0]).map(s => {
|
|
const [x, y] = project(s.latlon[0], s.latlon[1]);
|
|
return `<g class="adp-pin adp-pin--${s.kind}" role="button" tabindex="0" aria-label="Filter courses at ${esc(s.name)}" data-name="${esc(s.name)}" transform="translate(${x.toFixed(1)},${y.toFixed(1)})">` +
|
|
`<title>${esc(s.name)}, ${esc(s.country)}</title><circle r="6"/></g>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
function setView(v, push) {
|
|
view = v === 'schools' ? 'schools' : 'courses';
|
|
if (push) {
|
|
try { const u = new URL(location.href); view === 'schools' ? u.searchParams.set('view', 'schools') : u.searchParams.delete('view'); history.replaceState(null, '', u); } catch (e) {}
|
|
}
|
|
draw();
|
|
}
|
|
tabs.forEach(t => t.addEventListener('click', () => setView(t.dataset.view, true)));
|
|
list.addEventListener('click', e => {
|
|
const b = e.target.closest('button.crs-srow');
|
|
if (!b) return;
|
|
q.value = b.dataset.name; setView('courses', true);
|
|
list.scrollIntoView({behavior: 'smooth', block: 'start'});
|
|
});
|
|
try { if (new URLSearchParams(location.search).get('view') === 'schools') view = 'schools'; } catch (e) {}
|
|
|
|
// ?school= pre-fills the search, so "find your school" can be shared as a
|
|
// link. (Not ?q=, which Quarto's site search claims for its own highlight.)
|
|
try { const pre = new URLSearchParams(location.search).get('school'); if (pre) q.value = pre; } catch (e) {}
|
|
q.addEventListener('input', draw);
|
|
chips.forEach(c => c.addEventListener('click', () => {
|
|
kind = c.dataset.kind;
|
|
chips.forEach(x => x.setAttribute('aria-pressed', String(x === c)));
|
|
draw();
|
|
}));
|
|
if (svg) svg.addEventListener('keydown', e => {
|
|
if ((e.key === 'Enter' || e.key === ' ') && e.target.closest('.adp-pin')) {
|
|
e.preventDefault();
|
|
e.target.dispatchEvent(new MouseEvent('click', {bubbles: true}));
|
|
}
|
|
});
|
|
if (svg) svg.addEventListener('click', e => {
|
|
const pin = e.target.closest('.adp-pin');
|
|
if (!pin) return;
|
|
e.preventDefault();
|
|
q.value = pin.dataset.name; draw();
|
|
list.scrollIntoView({behavior: 'smooth', block: 'start'});
|
|
});
|
|
|
|
try { render(JSON.parse(embedded.textContent)); } catch (e) { /* shell stays */ }
|
|
fetch('../adopters.json?t=' + Date.now())
|
|
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
.then(render)
|
|
.catch(err => console.warn('[mlsysbook] adopters refresh failed, showing build-time list:', err));
|
|
})();
|