diff --git a/site/app.js b/site/app.js
new file mode 100644
index 0000000..8bd03f5
--- /dev/null
+++ b/site/app.js
@@ -0,0 +1,663 @@
+import { parseRoadmap, renderInline, TYPES } from './parser.js';
+import { linkDiagram } from './diagram.js';
+import { createMapView } from './mapview.js';
+import { CREDITS, IMPORTANCE, areaDots, areaLabel, h, plural } from './dom.js';
+
+const SOURCES = [
+ 'README.md',
+ '../README.md',
+ 'https://raw.githubusercontent.com/m3y54m/Embedded-Engineering-Roadmap/master/README.md',
+];
+const REPO_URL = 'https://github.com/m3y54m/Embedded-Engineering-Roadmap';
+const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
+const narrowScreen = window.matchMedia('(max-width: 960px)');
+
+const els = {
+ diagram: document.getElementById('diagram'),
+ outline: document.getElementById('outline'),
+ panel: document.getElementById('panel'),
+ status: document.getElementById('status'),
+ search: document.getElementById('search'),
+ results: document.getElementById('search-results'),
+ theme: document.getElementById('theme'),
+ tabs: [...document.querySelectorAll('[data-view]')],
+};
+
+const state = {
+ data: null,
+ selected: null,
+ includeSub: null,
+ filters: { types: new Set(), beginner: false, gem: false },
+ results: [],
+ active: -1,
+};
+
+// ---------- small helpers ----------
+
+const normalize = (text) => text.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
+
+function* descendants(node) {
+ yield node;
+ for (const child of node.children) yield* descendants(child);
+}
+
+function store(key, value) {
+ try {
+ if (value === undefined) return localStorage.getItem(key);
+ localStorage.setItem(key, value);
+ } catch {
+ // Storage can be unavailable (private mode, blocked cookies); preferences just won't persist.
+ }
+ return null;
+}
+
+// ---------- colors ----------
+
+function assignHues(data) {
+ const [first, ...rest] = data.groups;
+ const start = rest.length ? first.topics : [];
+ const learning = rest.length ? rest.flatMap((g) => g.topics) : data.root.children;
+ start.forEach((t, i) => { t.hue = 168 + (start.length > 1 ? (i * 40) / (start.length - 1) : 0); });
+ learning.forEach((t, i) => { t.hue = (214 + (i * 300) / Math.max(1, learning.length)) % 360; });
+ data.root.children.forEach((top, index) => {
+ top.index = index;
+ for (const n of descendants(top)) n.top = top;
+ });
+}
+
+function colorOf(node) {
+ if (!node.top) return 'var(--accent)';
+ const dark = document.documentElement.dataset.theme !== 'light';
+ const step = Math.min(node.depth - 1, 3);
+ const alt = node.top.index % 2;
+ const light = dark ? [54, 44, 37, 32][step] + alt * 7 : [62, 73, 81, 87][step] - alt * 6;
+ const sat = dark ? [70, 56, 46, 40][step] : [72, 64, 56, 50][step];
+ return `hsl(${node.top.hue.toFixed(1)} ${sat}% ${light}%)`;
+}
+
+function swatch(node) {
+ const el = h('span', { class: 'swatch', 'aria-hidden': 'true' });
+ el.style.background = colorOf(node);
+ return el;
+}
+
+function truncate(text, max) {
+ if (text.length <= max) return text;
+ return max < 4 ? '' : `${text.slice(0, max - 1).trimEnd()}…`;
+}
+
+// ---------- navigation ----------
+
+function navigate(node, { push = true, resource = null, reveal = false } = {}) {
+ if (state.selected !== node) state.includeSub = null;
+ state.selected = node;
+ if (resource) resetFilters(false);
+ if (state.mapView) state.mapView.select(node === state.data.root ? null : node);
+ renderPanel(node);
+ syncOutline();
+ if (resource) revealResource(resource);
+ const root = state.data.root;
+ document.title = node === root ? 'Embedded Engineering Roadmap · Explorer' : `${node.title} · Embedded Engineering Roadmap`;
+ if (push) {
+ const hash = node === root ? '#/' : `#/${node.slug}`;
+ if (location.hash !== hash) history.pushState(null, '', hash);
+ }
+ if (reveal && narrowScreen.matches) openSheet(true);
+}
+
+function routeFromHash() {
+ const slug = decodeURIComponent(location.hash.replace(/^#\/?/, ''));
+ navigate((slug && state.data.bySlug.get(slug)) || state.data.root, { push: false });
+}
+
+// ---------- details panel ----------
+
+function section(title, ...content) {
+ return h('section', { class: 'panel-section' }, title ? h('h3', {}, title) : null, ...content);
+}
+
+function crumbs(node) {
+ const chain = [];
+ for (let n = node.parent; n; n = n.parent) chain.unshift(n);
+ if (!chain.length) return null;
+ const root = state.data.root;
+ return h('nav', { class: 'crumbs', 'aria-label': 'Topic path' }, chain.flatMap((n, i) => [
+ i ? h('span', { 'aria-hidden': 'true' }, '›') : null,
+ h('button', { type: 'button', onclick: () => navigate(n) }, n === root ? 'Overview' : n.title),
+ ]));
+}
+
+function description(blocks) {
+ const wrap = h('div', { class: 'desc' });
+ for (const block of blocks) {
+ if (block.type === 'p') {
+ const p = h('p');
+ p.innerHTML = renderInline(block.text);
+ wrap.append(p);
+ } else {
+ const ul = h('ul');
+ for (const item of block.items) {
+ const li = h('li');
+ li.innerHTML = renderInline(item);
+ ul.append(li);
+ }
+ wrap.append(ul);
+ }
+ }
+ return wrap;
+}
+
+function topicChips(nodes) {
+ return h('div', { class: 'chips' }, nodes.map((n) => h('button', {
+ type: 'button',
+ class: 'chip-btn',
+ onclick: () => navigate(n, { reveal: true }),
+ }, swatch(n), n.title, h('span', { class: 'n' }, n.total))));
+}
+
+function related(node) {
+ const items = [...node.links]
+ .sort((a, b) => b.weight - a.weight)
+ .map((link) => {
+ const other = link.source === node ? link.target : link.source;
+ const reasons = [];
+ if (link.shared.length) reasons.push(plural(link.shared.length, 'shared resource'));
+ const outgoing = link.mentions.filter((m) => m.topic === node);
+ const incoming = link.mentions.filter((m) => m.topic !== node);
+ if (outgoing.length) reasons.push(`mentioned here: “${truncate(outgoing[0].text, 60)}”`);
+ else if (incoming.length) reasons.push(`mentions this topic: “${truncate(incoming[0].text, 60)}”`);
+ const title = [...link.shared.map((s) => `Shared: ${s}`), ...link.mentions.map((m) => `${m.topic.title}: ${m.text}`)].join('\n');
+ return h('li', {}, h('button', { type: 'button', title, onclick: () => navigate(other, { reveal: true }) },
+ swatch(other),
+ h('span', {}, other.title),
+ h('span', { class: 'why' }, reasons.join(' · '))));
+ });
+ return h('ul', { class: 'related' }, items);
+}
+
+function passes(r) {
+ const f = state.filters;
+ return (!f.types.size || f.types.has(r.type)) && (!f.beginner || r.beginner) && (!f.gem || r.gem);
+}
+
+function resetFilters(rerender = true) {
+ state.filters = { types: new Set(), beginner: false, gem: false };
+ if (rerender) rerenderPanel();
+}
+
+function filterBar(entries) {
+ const f = state.filters;
+ const present = Object.keys(TYPES).filter((type) => entries.some((r) => r.type === type));
+ const button = (label, pressed, onclick) => h('button', { type: 'button', class: 'filter', 'aria-pressed': String(pressed), onclick }, label);
+ const active = f.types.size || f.beginner || f.gem;
+ const hasBeginner = entries.some((r) => r.beginner);
+ const hasGem = entries.some((r) => r.gem);
+ if (present.length < 2 && !hasBeginner && !hasGem && !active) return null;
+ return h('div', { class: 'filters', role: 'group', 'aria-label': 'Filter resources' },
+ present.length > 1 ? present.map((type) => button(`${TYPES[type].icon} ${TYPES[type].label}`, f.types.has(type), () => {
+ if (f.types.has(type)) f.types.delete(type);
+ else f.types.add(type);
+ rerenderPanel();
+ })) : null,
+ hasBeginner ? button('👶 Beginner', f.beginner, () => { f.beginner = !f.beginner; rerenderPanel(); }) : null,
+ hasGem ? button('💎 Essential', f.gem, () => { f.gem = !f.gem; rerenderPanel(); }) : null,
+ active ? h('button', { type: 'button', class: 'filter reset', onclick: () => resetFilters() }, 'Clear filters') : null);
+}
+
+function resourceItem(r) {
+ const type = TYPES[r.type];
+ return h('li', { class: 'res', 'data-url': r.url },
+ h('span', { class: 'res-icon', title: type.label, 'aria-label': type.label, role: 'img' }, type.icon),
+ h('div', { class: 'res-body' },
+ h('a', { class: 'res-title', href: r.url, target: '_blank', rel: 'noopener noreferrer' }, r.title),
+ h('div', { class: 'res-meta' },
+ h('span', {}, r.domain),
+ r.beginner ? h('span', { class: 'badge beginner' }, 'Beginner') : null,
+ r.gem ? h('span', { class: 'badge gem' }, 'Essential') : null),
+ r.note ? h('p', { class: 'res-note' }, r.note) : null,
+ r.sharedWith.length ? h('div', { class: 'res-also' }, 'Also listed in ', r.sharedWith.flatMap((t, i) => [
+ i ? ', ' : null,
+ h('button', { type: 'button', class: 'link-btn', onclick: () => navigate(t, { reveal: true }) }, t.title),
+ ])) : null));
+}
+
+function resources(node) {
+ const hasSub = node.children.length > 0 && node.total > node.resources.length;
+ const include = hasSub && (state.includeSub ?? node.resources.length === 0);
+ const groups = include
+ ? [...descendants(node)].filter((n) => n.resources.length).map((n) => ({ node: n, list: n.resources }))
+ : [{ node, list: node.resources }];
+ const all = groups.flatMap((g) => g.list);
+ const shown = all.filter(passes).length;
+
+ const sec = h('section', { class: 'panel-section' },
+ h('div', { class: 'res-head' },
+ h('h3', {}, 'Resources'),
+ h('span', { class: 'count' }, shown === all.length ? plural(all.length, 'item') : `${shown} of ${all.length}`)));
+ if (hasSub) {
+ const box = h('input', { type: 'checkbox', checked: include });
+ box.addEventListener('change', () => {
+ state.includeSub = box.checked;
+ rerenderPanel();
+ });
+ sec.append(h('label', { class: 'toggle' }, box, `Include subtopics (${plural(node.total, 'resource')})`));
+ }
+ if (!all.length) {
+ sec.append(h('p', { class: 'empty' }, 'This topic only groups its subtopics. Pick one above.'));
+ return sec;
+ }
+ const filters = filterBar(all);
+ if (filters) sec.append(filters);
+ if (!shown) {
+ sec.append(h('div', { class: 'empty' }, 'No resources match these filters. ',
+ h('button', { type: 'button', class: 'link-btn', onclick: () => resetFilters() }, 'Clear filters')));
+ return sec;
+ }
+ for (const group of groups) {
+ const list = group.list.filter(passes);
+ if (!list.length) continue;
+ if (include) {
+ sec.append(h('div', { class: 'res-group' },
+ h('button', { type: 'button', onclick: () => navigate(group.node, { reveal: true }) }, group.node.title)));
+ }
+ sec.append(h('ul', { class: 'res-list' }, list.map(resourceItem)));
+ }
+ return sec;
+}
+
+function areaSummary() {
+ const boxes = state.plan ? state.plan.boxes.filter((b) => !b.header) : [];
+ const count = (test) => boxes.filter(test).length;
+ const tile = (areas, n, label) => h('div', { class: 'stat' }, areaDots(areas), h('b', {}, n), h('span', {}, label));
+ return h('div', { class: 'stats areas' },
+ tile(['SOFTWARE'], count((b) => b.areas.length === 1 && b.areas[0] === 'SOFTWARE'), 'Software only'),
+ tile(['SOFTWARE', 'HARDWARE'], count((b) => b.areas.includes('SOFTWARE') && b.areas.includes('HARDWARE') && !b.areas.includes('SOFT SKILLS')), 'Software ∩ Hardware'),
+ tile(['HARDWARE'], count((b) => b.areas.length === 1 && b.areas[0] === 'HARDWARE'), 'Hardware only'));
+}
+
+function overview(root) {
+ const { stats, groups } = state.data;
+ return [
+ section(null, description(root.description)),
+ section(null, h('div', { class: 'stats' },
+ h('div', { class: 'stat' }, h('b', {}, stats.topics), h('span', {}, 'topics')),
+ h('div', { class: 'stat' }, h('b', {}, stats.resources), h('span', {}, 'resources')),
+ h('div', { class: 'stat' }, h('b', {}, stats.links), h('span', {}, 'connections')))),
+ state.plan ? section('Diagram topics by area', areaSummary()) : null,
+ ...groups.map((g) => section(g.title, topicChips(g.topics))),
+ section('How to explore', h('ul', { class: 'howto' },
+ h('li', {}, 'Map follows the roadmap diagram: topics sit in Software, Hardware or both, and are colored by importance.'),
+ h('li', {}, 'Use the Areas chips to highlight a single area or only the Software ∩ Hardware cross-section.'),
+ h('li', {}, 'Hover or select a topic to see lines to the topics it shares resources with (solid) or is mentioned by (dotted).'),
+ h('li', {}, 'Outline lists every topic of the README; use it to browse topics that are not on the map.'),
+ h('li', {}, 'Press ', h('kbd', {}, '/'), ' to search topics and resources.'))),
+ section(null, h('p', { class: 'res-note' }, 'Built from the ',
+ h('a', { href: `${REPO_URL}#readme`, target: '_blank', rel: 'noopener noreferrer' }, 'roadmap README'),
+ ' and the map definition in the repository.')),
+ section('Credits', credits()),
+ ].filter(Boolean);
+}
+
+function credits() {
+ const external = (href, ...children) => h('a', { href, target: '_blank', rel: 'noopener noreferrer' }, ...children);
+ const build = state.build;
+ return h('div', { class: 'credits' },
+ h('img', { class: 'credits-logo', src: 'assets/logo.svg', alt: '', width: 48, height: 48 }),
+ h('div', { class: 'credits-body' },
+ h('p', {}, 'Creator & maintainer: ', external(CREDITS.authorUrl, h('strong', {}, CREDITS.author))),
+ build ? h('p', {}, 'Revision ', h('strong', {}, build.version), ` · last update ${build.date}`) : null,
+ h('p', {}, 'Licensed under the ', external(CREDITS.licenseUrl, CREDITS.licenseName), '.'),
+ external(CREDITS.licenseUrl, h('img', { class: 'credits-badge', src: 'assets/cc-by-sa.svg', alt: CREDITS.license, width: 88, height: 31 }))));
+}
+
+function renderPanel(node) {
+ const root = state.data.root;
+ const panel = els.panel;
+ panel.replaceChildren();
+ panel.append(h('header', { class: 'panel-head' },
+ h('button', { type: 'button', class: 'icon-btn sheet-close', 'aria-label': 'Close details', onclick: () => openSheet(false) }, '✕'),
+ crumbs(node),
+ h('h2', { class: 'panel-title' },
+ node.icon ? h('span', { class: 'title-icon', 'aria-hidden': 'true' }, node.icon) : node === root ? null : swatch(node),
+ node === root ? 'Embedded Engineering Roadmap' : node.title),
+ node === root ? null : h('div', { class: 'meta' },
+ node.areas && node.areas.length ? h('span', { class: 'pill area-pill' }, areaDots(node.areas), areaLabel(node.areas)) : null,
+ node.importance ? h('span', { class: `pill imp-pill ${node.importance}` }, IMPORTANCE[node.importance]) : null,
+ h('span', { class: 'pill' }, plural(node.total, 'resource')),
+ node.children.length ? h('span', { class: 'pill' }, plural(node.children.length, 'subtopic')) : null,
+ node.links.length ? h('span', { class: 'pill' }, `${node.links.length} connected`) : null)));
+
+ if (node === root) {
+ panel.append(...overview(root));
+ return;
+ }
+ if (node.description.length) panel.append(section('About', description(node.description)));
+ if (node.children.length) panel.append(section('Subtopics', topicChips(node.children)));
+ if (node.links.length) panel.append(section('Connected topics', related(node)));
+ panel.append(resources(node));
+}
+
+function rerenderPanel() {
+ const top = els.panel.scrollTop;
+ renderPanel(state.selected);
+ els.panel.scrollTop = top;
+}
+
+function revealResource(resource) {
+ const item = [...els.panel.querySelectorAll('.res')].find((li) => li.dataset.url === resource.url);
+ if (!item) return;
+ item.scrollIntoView({ block: 'center', behavior: reducedMotion.matches ? 'auto' : 'smooth' });
+ item.classList.remove('flash');
+ void item.offsetWidth;
+ item.classList.add('flash');
+}
+
+function openSheet(open) {
+ els.panel.classList.toggle('open', open);
+ if (open) els.panel.focus({ preventScroll: true });
+}
+
+// ---------- outline view ----------
+
+function buildOutline() {
+ const { groups, stats } = state.data;
+ const item = (node) => {
+ const button = h('button', {
+ type: 'button',
+ class: 'tree-btn',
+ 'data-id': node.id,
+ onclick: (e) => {
+ e.preventDefault();
+ navigate(node, { reveal: true });
+ },
+ }, swatch(node), h('span', { class: 'tree-title' }, node.title),
+ node.areas && node.areas.length ? areaDots(node.areas) : null,
+ h('span', { class: 'tree-count' }, node.total));
+ if (!node.children.length) return h('li', {}, button);
+ const details = h('details', {}, h('summary', {}, button), h('ul', { class: 'tree' }, node.children.map(item)));
+ return h('li', {}, details);
+ };
+ els.outline.replaceChildren(
+ h('h2', {}, 'All topics'),
+ h('p', { class: 'res-note' }, `${stats.topics} topics and ${stats.resources} resources. Select a topic to see its details; use the arrows to expand.`),
+ ...groups.map((g) => h('section', { class: 'outline-group' }, h('h3', {}, g.title), h('ul', { class: 'tree' }, g.topics.map(item)))),
+ );
+}
+
+function syncOutline() {
+ for (const btn of els.outline.querySelectorAll('.tree-btn[aria-current]')) btn.removeAttribute('aria-current');
+ const btn = state.selected && els.outline.querySelector(`.tree-btn[data-id="${CSS.escape(state.selected.id)}"]`);
+ if (!btn) return;
+ btn.setAttribute('aria-current', 'true');
+ for (let el = btn.parentElement; el && el !== els.outline; el = el.parentElement) {
+ if (el.tagName === 'DETAILS') el.open = true;
+ }
+}
+
+const VIEWS = ['map', 'list'];
+
+function setView(view) {
+ if (!VIEWS.includes(view) || (view === 'map' && !state.mapView)) view = state.mapView ? 'map' : 'list';
+ els.diagram.hidden = view !== 'map';
+ els.outline.hidden = view !== 'list';
+ for (const tab of els.tabs) {
+ tab.setAttribute('aria-selected', String(tab.dataset.view === view));
+ if (tab.dataset.view === 'map') tab.hidden = !state.mapView;
+ }
+ store('roadmap-view', view);
+ if (view === 'map') state.mapView.show();
+ if (view === 'list') {
+ syncOutline();
+ const current = els.outline.querySelector('.tree-btn[aria-current]');
+ if (current) current.scrollIntoView({ block: 'center' });
+ }
+}
+
+// ---------- search ----------
+
+function buildIndex() {
+ state.index = {
+ topics: state.data.topics.map((node) => ({
+ node,
+ title: normalize(node.title),
+ text: normalize([node.title, ...node.path].join(' ')),
+ })),
+ resources: state.data.topics.flatMap((node) => node.resources.map((r) => ({
+ node,
+ r,
+ text: normalize(`${r.title} ${r.domain}`),
+ }))),
+ };
+}
+
+function runSearch(query) {
+ const tokens = normalize(query).split(/\s+/).filter(Boolean);
+ if (!tokens.length) return [];
+ const match = (entry) => tokens.every((t) => entry.text.includes(t));
+ const topics = state.index.topics
+ .filter(match)
+ .map((e) => ({ ...e, score: e.title.startsWith(tokens[0]) ? 0 : e.title.includes(tokens[0]) ? 1 : 2 }))
+ .sort((a, b) => a.score - b.score || a.node.depth - b.node.depth)
+ .slice(0, 6)
+ .map((e) => ({ kind: 'topic', node: e.node }));
+ const seen = new Set();
+ const resourceHits = state.index.resources
+ .filter((e) => match(e) && !seen.has(e.r.url) && seen.add(e.r.url))
+ .slice(0, 8)
+ .map((e) => ({ kind: 'resource', node: e.node, r: e.r }));
+ return [...topics, ...resourceHits];
+}
+
+function renderResults() {
+ const box = els.results;
+ const query = els.search.value.trim();
+ box.replaceChildren();
+ if (!query) {
+ closeResults();
+ return;
+ }
+ const results = state.results;
+ if (!results.length) {
+ box.append(h('div', { class: 'result-empty' }, `No topics or resources match “${query}”.`));
+ }
+ let lastKind = '';
+ results.forEach((res, i) => {
+ if (res.kind !== lastKind) {
+ box.append(h('div', { class: 'result-group', role: 'presentation' }, res.kind === 'topic' ? 'Topics' : 'Resources'));
+ lastKind = res.kind;
+ }
+ const isTopic = res.kind === 'topic';
+ const option = h('div', {
+ id: `result-${i}`,
+ class: 'result',
+ role: 'option',
+ 'aria-selected': String(i === state.active),
+ },
+ isTopic ? swatch(res.node) : h('span', { 'aria-hidden': 'true' }, TYPES[res.r.type].icon),
+ h('div', { class: 'result-main' },
+ h('div', { class: 'result-title' }, isTopic ? res.node.title : res.r.title, isTopic && res.node.areas && res.node.areas.length ? areaDots(res.node.areas) : null),
+ h('div', { class: 'result-sub' }, isTopic
+ ? (res.node.path.length ? res.node.path.join(' › ') : plural(res.node.total, 'resource'))
+ : `${res.r.domain} · ${res.node.title}`)));
+ option.addEventListener('pointerdown', (e) => {
+ e.preventDefault();
+ choose(i);
+ });
+ box.append(option);
+ });
+ box.hidden = false;
+ els.search.setAttribute('aria-expanded', 'true');
+ els.search.setAttribute('aria-activedescendant', state.active >= 0 ? `result-${state.active}` : '');
+}
+
+function closeResults() {
+ els.results.hidden = true;
+ els.search.setAttribute('aria-expanded', 'false');
+ els.search.removeAttribute('aria-activedescendant');
+ state.active = -1;
+}
+
+function choose(index) {
+ const res = state.results[index];
+ if (!res) return;
+ closeResults();
+ els.search.value = '';
+ els.search.blur();
+ if (res.kind === 'topic') navigate(res.node, { reveal: true });
+ else navigate(res.node, { resource: res.r, reveal: true });
+}
+
+function wireSearch() {
+ els.search.addEventListener('input', () => {
+ state.results = runSearch(els.search.value);
+ state.active = state.results.length ? 0 : -1;
+ renderResults();
+ });
+ els.search.addEventListener('keydown', (e) => {
+ const count = state.results.length;
+ if (e.key === 'ArrowDown' && count) {
+ e.preventDefault();
+ state.active = (state.active + 1) % count;
+ renderResults();
+ document.getElementById(`result-${state.active}`)?.scrollIntoView({ block: 'nearest' });
+ } else if (e.key === 'ArrowUp' && count) {
+ e.preventDefault();
+ state.active = (state.active - 1 + count) % count;
+ renderResults();
+ document.getElementById(`result-${state.active}`)?.scrollIntoView({ block: 'nearest' });
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ choose(state.active >= 0 ? state.active : 0);
+ } else if (e.key === 'Escape') {
+ e.stopPropagation();
+ if (els.search.value) {
+ els.search.value = '';
+ closeResults();
+ } else {
+ els.search.blur();
+ }
+ }
+ });
+ els.search.addEventListener('focus', () => {
+ if (els.search.value.trim()) renderResults();
+ });
+ els.search.addEventListener('blur', () => window.setTimeout(closeResults, 120));
+}
+
+// ---------- theme, loading, startup ----------
+
+function applyTheme(theme) {
+ document.documentElement.dataset.theme = theme;
+ if (!state.data) return;
+ buildOutline();
+ syncOutline();
+ rerenderPanel();
+}
+
+async function loadMarkdown() {
+ for (const source of SOURCES) {
+ try {
+ const response = await fetch(source, { cache: 'no-cache' });
+ if (!response.ok) continue;
+ const text = await response.text();
+ if (text.includes('# Embedded Systems Engineering Roadmap')) return text;
+ } catch {
+ // Try the next source.
+ }
+ }
+ throw new Error('README.md could not be loaded');
+}
+
+async function loadMap() {
+ try {
+ const response = await fetch('map.json', { cache: 'no-cache' });
+ return response.ok ? await response.json() : null;
+ } catch {
+ return null;
+ }
+}
+
+// Revision and date, written by CI next to the deployed site; absent in local previews.
+async function loadBuild() {
+ try {
+ const response = await fetch('build.json', { cache: 'no-cache' });
+ return response.ok ? await response.json() : null;
+ } catch {
+ return null;
+ }
+}
+
+function isTyping(el) {
+ return el instanceof HTMLElement && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
+}
+
+function wireGlobal() {
+ els.theme.addEventListener('click', () => {
+ const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
+ store('roadmap-theme', next);
+ applyTheme(next);
+ });
+ for (const tab of els.tabs) tab.addEventListener('click', () => setView(tab.dataset.view));
+ document.getElementById('print').addEventListener('click', () => window.print());
+ // Printing always produces the diagram, whatever view is open.
+ let viewBeforePrint = null;
+ window.addEventListener('beforeprint', () => {
+ if (!state.mapView) return;
+ viewBeforePrint = store('roadmap-view');
+ setView('map');
+ state.mapView.prepareForPrint();
+ });
+ window.addEventListener('afterprint', () => {
+ if (state.mapView) state.mapView.afterPrint();
+ if (viewBeforePrint && viewBeforePrint !== 'map') setView(viewBeforePrint);
+ viewBeforePrint = null;
+ });
+ window.addEventListener('popstate', () => routeFromHash());
+ document.addEventListener('keydown', (e) => {
+ if (isTyping(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
+ if (e.key === '/') {
+ e.preventDefault();
+ els.search.focus();
+ } else if (e.key === 'Escape') {
+ if (els.panel.classList.contains('open')) openSheet(false);
+ else if (state.selected && state.selected.parent) navigate(state.selected.parent);
+ }
+ });
+}
+
+async function init() {
+ applyTheme(store('roadmap-theme') || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'));
+ let markdown;
+ const diagramRequest = loadMap();
+ const buildRequest = loadBuild();
+ try {
+ markdown = await loadMarkdown();
+ } catch {
+ els.status.replaceChildren('The roadmap could not be loaded. ',
+ h('a', { href: `${REPO_URL}#readme` }, 'Read it on GitHub instead'), '.');
+ return;
+ }
+ state.data = parseRoadmap(markdown);
+ assignHues(state.data);
+ const diagram = await diagramRequest;
+ state.build = await buildRequest;
+ if (diagram) {
+ state.plan = linkDiagram(diagram, state.data);
+ state.mapView = createMapView({
+ root: els.diagram,
+ plan: state.plan,
+ data: state.data,
+ build: state.build,
+ onSelect: (topic) => navigate(topic, { reveal: true }),
+ });
+ }
+ buildOutline();
+ buildIndex();
+ wireSearch();
+ wireGlobal();
+ els.status.hidden = true;
+ routeFromHash();
+ setView(store('roadmap-view') || 'map');
+}
+
+init();
diff --git a/site/assets/cc-by-sa.svg b/site/assets/cc-by-sa.svg
new file mode 100644
index 0000000..f850297
--- /dev/null
+++ b/site/assets/cc-by-sa.svg
@@ -0,0 +1,199 @@
+
+
+
diff --git a/site/assets/logo.svg b/site/assets/logo.svg
new file mode 100644
index 0000000..cefe34c
--- /dev/null
+++ b/site/assets/logo.svg
@@ -0,0 +1,10 @@
+
diff --git a/site/diagram.js b/site/diagram.js
new file mode 100644
index 0000000..63980b8
--- /dev/null
+++ b/site/diagram.js
@@ -0,0 +1,144 @@
+// Links the topics drawn on the map (map.json) to README topics, so every topic knows
+// which areas (Software, Hardware, Soft skills) it sits in and how important the map marks it.
+
+// Map labels whose README heading is worded differently.
+const ALIASES = {
+ 'ADC / DAC': ['ADC', 'DAC'],
+ 'Buildroot / Yocto': ['Buildroot', 'Yocto'],
+ 'TDD & Unit Testing': ['Test Driven Development (TDD)', 'Unit Testing'],
+ 'Threading / Parallelism': ['Multithreading & Parallel Processing'],
+ 'Device Drivers': ['Linux Device Drivers'],
+ 'Real-Time OS': ['Real-Time Operating Systems'],
+ 'Interfaces & Protocols': ['Interfaces, Protocols & Communication Technologies'],
+ Basic: ['Basic Protocols'],
+ 'High-Speed': ['High-Speed Protocols'],
+ Wireless: ['Wireless Protocols'],
+ Industrial: ['Industrial Protocols'],
+ Automotive: ['Automotive Protocols'],
+ Network: ['Network Protocols / Socket Programming'],
+ 'TCP/IP': ['Network Protocols / Socket Programming'],
+ UDP: ['Network Protocols / Socket Programming'],
+ Cellular: ['Cellular Communication'],
+ MQTT: ['CoAP & MQTT'],
+ CoAP: ['CoAP & MQTT'],
+ 'LTE-M / 5G': ['LTE-M & NB-IoT'],
+ 'NB-IoT': ['LTE-M & NB-IoT'],
+ 'Basic Math & Calculus': ['Basic Calculus'],
+ 'SDLC Models': ['Software Development Life Cycle (SDLC) Models'],
+ 'Version Control': ['Version Control Systems'],
+ AUTOSAR: ['AUTOSAR Architecture'],
+ // Drawn on the map but without their own README section: open the protocol family instead.
+ Profinet: ['Industrial Protocols'],
+ LIN: ['Automotive Protocols'],
+ MOST: ['Automotive Protocols'],
+ FlexRay: ['Automotive Protocols'],
+ UWB: ['Wireless Protocols'],
+};
+
+export const AREA_ORDER = ['SOFTWARE', 'HARDWARE', 'SOFT SKILLS'];
+export const IMPORTANCE_LEVELS = ['required', 'recommended', 'possible'];
+const RANK = { required: 3, recommended: 2, possible: 1 };
+
+const key = (text) => text.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
+
+function topicIndex(topics) {
+ const index = new Map();
+ const add = (k, topic) => {
+ if (k && !index.has(k)) index.set(k, topic);
+ };
+ const byDepth = [...topics].sort((a, b) => a.depth - b.depth);
+ for (const t of byDepth) add(key(t.title), t);
+ for (const t of byDepth) {
+ const base = t.title.replace(/\s*\([^)]*\)\s*/g, ' ').trim();
+ add(key(base), t);
+ const inner = t.title.match(/\(([^)]+)\)/);
+ if (inner && /^[A-Z0-9-]{2,6}$/.test(inner[1])) add(key(inner[1]), t);
+ add(key(base.split(' / ')[0]), t);
+ }
+ return index;
+}
+
+export function linkDiagram(map, data) {
+ const index = topicIndex(data.topics);
+ const softSkills = data.topics.find((t) => t.depth === 1 && key(t.title) === 'soft skills');
+ const resolve = (text, areas = []) => {
+ if (ALIASES[text]) return ALIASES[text].map((title) => index.get(key(title))).filter(Boolean);
+ const topic = index.get(key(text));
+ if (topic) return [topic];
+ if (softSkills && areas.includes('SOFT SKILLS')) return [softSkills];
+ return [];
+ };
+
+ // A cluster's areas are those of the band it sits in; a group's are the areas all its clusters share.
+ const clusterAreas = new Map();
+ const clusters = {};
+ const boxes = [];
+ for (const band of map.bands) {
+ for (const [cluster, spec] of Object.entries(band.clusters)) {
+ clusterAreas.set(cluster, band.areas);
+ clusters[cluster] = { header: spec.header, caption: spec.caption, per: spec.per };
+ if (spec.header) boxes.push({ text: spec.header, importance: null, areas: band.areas, cluster, header: true });
+ for (const [text, importance] of spec.topics) boxes.push({ text, importance, areas: band.areas, cluster, header: false });
+ }
+ }
+ boxes.forEach((b, i) => Object.assign(b, { id: `box-${i}`, topics: resolve(b.text, b.areas) }));
+ const regions = Object.entries(map.groups).map(([name, clusters]) => {
+ const areas = AREA_ORDER.filter((a) => clusters.every((c) => (clusterAreas.get(c) || []).includes(a)));
+ return { name, clusters, areas, topics: resolve(name) };
+ });
+
+ const boxOf = new Map();
+ for (const t of data.topics) {
+ t.areas = [];
+ t.importance = null;
+ t.boxes = [];
+ }
+ for (const box of boxes) {
+ for (const t of box.topics) {
+ t.boxes.push(box);
+ if (!boxOf.has(t)) boxOf.set(t, box);
+ for (const area of box.areas) if (!t.areas.includes(area)) t.areas.push(area);
+ if (box.importance && (!t.importance || RANK[box.importance] > RANK[t.importance])) t.importance = box.importance;
+ }
+ }
+ for (const region of regions) {
+ for (const t of region.topics) {
+ for (const area of region.areas) if (!t.areas.includes(area)) t.areas.push(area);
+ }
+ }
+ // Subtopics that are not drawn in the diagram inherit the areas of their closest drawn ancestor.
+ for (const t of data.topics) {
+ if (!t.areas.length) for (let p = t.parent; p && !t.areas.length; p = p.parent) t.areas = [...(p.areas || [])];
+ t.areas.sort((a, b) => AREA_ORDER.indexOf(a) - AREA_ORDER.indexOf(b));
+ }
+
+ // The box that stands for a topic on the map: its own, or the closest ancestor's.
+ const boxFor = (topic) => {
+ for (let t = topic; t; t = t.parent) if (boxOf.has(t)) return boxOf.get(t);
+ return null;
+ };
+
+ const pairs = new Map();
+ for (const link of data.links) {
+ const a = boxFor(link.source);
+ const b = boxFor(link.target);
+ if (!a || !b || a === b) continue;
+ const [p, q] = a.id < b.id ? [a, b] : [b, a];
+ const id = `${p.id}|${q.id}`;
+ if (!pairs.has(id)) pairs.set(id, { a: p, b: q, weight: 0, shared: 0, links: [] });
+ const pair = pairs.get(id);
+ pair.weight += link.weight;
+ pair.shared += link.shared.length;
+ pair.links.push(link);
+ }
+
+ return {
+ regions,
+ clusters,
+ boxes,
+ boxFor,
+ connections: [...pairs.values()],
+ unmatched: boxes.filter((b) => !b.header && !b.topics.length).map((b) => b.text),
+ invalid: boxes.filter((b) => !b.header && !IMPORTANCE_LEVELS.includes(b.importance)).map((b) => `${b.text}: ${b.importance}`),
+ };
+}
diff --git a/site/dom.js b/site/dom.js
new file mode 100644
index 0000000..86ce686
--- /dev/null
+++ b/site/dom.js
@@ -0,0 +1,57 @@
+// DOM helpers shared by the views.
+
+export const SVG_NS = 'http://www.w3.org/2000/svg';
+
+export const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
+
+export const CREDITS = {
+ author: 'Meysam Parvizi',
+ authorUrl: 'https://github.com/m3y54m',
+ repo: 'github.com/m3y54m/Embedded-Engineering-Roadmap',
+ license: 'CC BY-SA 4.0',
+ licenseName: 'Creative Commons Attribution-ShareAlike 4.0 International License',
+ licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
+};
+
+export const GITHUB_MARK = 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z';
+
+export function h(tag, props = {}, ...children) {
+ const el = document.createElement(tag);
+ for (const [key, value] of Object.entries(props)) {
+ if (value == null || value === false) continue;
+ if (key === 'class') el.className = value;
+ else if (key.startsWith('on')) el.addEventListener(key.slice(2), value);
+ else el.setAttribute(key, value === true ? '' : value);
+ }
+ for (const child of children.flat(Infinity)) {
+ if (child == null || child === false || child === '') continue;
+ el.append(child instanceof Node ? child : document.createTextNode(String(child)));
+ }
+ return el;
+}
+
+export function svg(tag, attrs = {}, parent = null) {
+ const el = document.createElementNS(SVG_NS, tag);
+ for (const [key, value] of Object.entries(attrs)) if (value != null) el.setAttribute(key, value);
+ if (parent) parent.append(el);
+ return el;
+}
+
+export const AREAS = {
+ SOFTWARE: { label: 'Software', cls: 'sw' },
+ HARDWARE: { label: 'Hardware', cls: 'hw' },
+ 'SOFT SKILLS': { label: 'Soft skills', cls: 'ss' },
+};
+
+export const IMPORTANCE = {
+ required: 'Required',
+ recommended: 'Recommended',
+ possible: 'Possibility',
+};
+
+export const areaClass = (area) => (AREAS[area] ? AREAS[area].cls : 'xx');
+export const areaLabel = (areas) => areas.map((a) => (AREAS[a] ? AREAS[a].label : a)).join(' ∩ ');
+
+export function areaDots(areas) {
+ return h('span', { class: 'area-dots', 'aria-hidden': 'true' }, areas.map((a) => h('i', { class: `dot ${areaClass(a)}` })));
+}
diff --git a/site/fonts/OFL.txt b/site/fonts/OFL.txt
new file mode 100644
index 0000000..26ff1f5
--- /dev/null
+++ b/site/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2019 The Sofia Sans Project Authors (https://github.com/lettersoup/Sofia-Sans)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/site/fonts/SofiaSansSemiCondensed.woff2 b/site/fonts/SofiaSansSemiCondensed.woff2
new file mode 100644
index 0000000..d4c51c9
Binary files /dev/null and b/site/fonts/SofiaSansSemiCondensed.woff2 differ
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..affd77d
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+ Embedded Engineering Roadmap · Explorer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading roadmap…
+
+
+
+
+
+
+
+
diff --git a/site/layout.js b/site/layout.js
new file mode 100644
index 0000000..9bb2e3c
--- /dev/null
+++ b/site/layout.js
@@ -0,0 +1,167 @@
+// Print-friendly layout (A4 portrait proportions) for the roadmap map.
+// Topics come from map.json; this file only decides where each cluster goes. Three horizontal bands
+// make the areas explicit: Software only (top), Software ∩ Hardware (middle), Hardware only
+// (bottom). Soft skills are not technical, so they get their own area below the others. The
+// Real-Time OS cluster sits where the Operating Systems and Microcontrollers groups overlap.
+
+// Minimum distance between any two boxes, texts or lines.
+export const SPACE = 10;
+const COL = 170;
+const GUTTER = 26;
+const LEFT = 86;
+const TOP = 168;
+const BOX_H = 26;
+const BOX_H2 = 42;
+const HEAD_H = 20;
+const HEAD = HEAD_H + SPACE;
+const GAP = 2 * SPACE;
+const FOOTER_H = 56;
+// Rough width of one character of box text, used to decide when a label wraps to two lines.
+const CHAR_W = 6.3;
+const TEXT_PAD = 8;
+
+export const PAGE_WIDTH = LEFT * 2 + COL * 5 + GUTTER * 4;
+
+const colX = (i) => LEFT + i * (COL + GUTTER);
+const spanW = (n) => n * COL + (n - 1) * GUTTER;
+
+// Cluster ids from map.json that this layout knows where to place.
+export const PLACED = [
+ 'languages', 'fundamentals', 'debugging', 'sdlc', 'vcs', 'build', 'testing', 'specialized', 'osBase', 'linux',
+ 'rtos', 'mcu', 'bridge', 'basic', 'wireless', 'highSpeed', 'industrial', 'cellular', 'network', 'automotive',
+ 'electronics', 'equipment', 'prototyping', 'fpga', 'soft',
+];
+
+function place(cluster, x, y, w) {
+ const items = [];
+ let cy = y;
+ if (cluster.headerBox || cluster.caption) {
+ items.push({
+ kind: cluster.headerBox ? 'header' : 'caption',
+ box: cluster.headerBox || null,
+ text: cluster.headerBox ? cluster.headerBox.text : cluster.caption,
+ x, y: cy, w, h: HEAD_H,
+ });
+ cy += HEAD;
+ }
+ const per = cluster.per || 1;
+ const bw = (w - (per - 1) * SPACE) / per;
+ for (let i = 0; i < cluster.boxes.length; i += per) {
+ const row = cluster.boxes.slice(i, i + per);
+ // Every box in a row gets the same height so the row stays aligned.
+ const h = row.some((b) => b.text.length * CHAR_W + 2 * TEXT_PAD > bw && b.text.includes(' ')) ? BOX_H2 : BOX_H;
+ row.forEach((box, j) => items.push({ kind: 'box', box, text: box.text, x: x + j * (bw + SPACE), y: cy, w: bw, h }));
+ cy += h + SPACE;
+ }
+ return { items, bottom: cy - SPACE };
+}
+
+export function buildLayout(plan) {
+ const clusters = {};
+ for (const id of PLACED) {
+ const spec = plan.clusters[id] || {};
+ const own = plan.boxes.filter((b) => b.cluster === id);
+ clusters[id] = { ...spec, headerBox: own.find((b) => b.header) || null, boxes: own.filter((b) => !b.header) };
+ }
+ // Topics in clusters this layout cannot place yet; the tests report them.
+ const missing = plan.boxes.filter((b) => !PLACED.includes(b.cluster)).map((b) => `${b.cluster}: ${b.text}`);
+
+ const items = [];
+ const add = (result) => {
+ items.push(...result.items);
+ return result.bottom;
+ };
+ const stack = (ids, x, y, w) => ids.reduce((top, id, i) => add(place(clusters[id], x, i ? top + GAP : top, w)), y);
+ const c = [0, 1, 2, 3, 4].map(colX);
+ const wide = spanW(2);
+ // Group tags are 22 high and centered on the group's top border.
+ const TAG = 11;
+ const belowTag = (y) => y + TAG + SPACE;
+
+ // Software only.
+ const languagesBottom = add(place(clusters.languages, c[0], TOP, COL));
+ const fundamentalsBottom = add(place(clusters.fundamentals, c[1], TOP, COL));
+ const col3 = stack(['debugging', 'sdlc', 'vcs'], c[2], TOP, COL);
+ const col4 = stack(['build', 'testing'], c[3], TOP, COL);
+ const col5 = add(place(clusters.specialized, c[4], TOP, COL));
+
+ // OS outline is 2 spaces outside its content, Microcontrollers 1 space, and the Interfaces
+ // outline keeps a full space from both inside the gutter between them.
+ const leftW = wide + GUTTER - 4 * SPACE;
+ const osTop = Math.max(languagesBottom, fundamentalsBottom) + GAP + TAG;
+ let osY = add(place(clusters.osBase, c[0], belowTag(osTop), leftW));
+ osY = add(place(clusters.linux, c[0], osY + GAP, leftW));
+
+ // Software ∩ Hardware.
+ const crossTop = Math.max(osY, col3, col4, col5) + GAP;
+ const groupTop = crossTop + GAP;
+ const rtosBottom = add(place(clusters.rtos, c[0], belowTag(groupTop), leftW));
+ const os = { x: c[0] - 2 * SPACE, y: osTop, w: leftW + 4 * SPACE, h: rtosBottom + SPACE - osTop };
+ const mcuBottom = add(place(clusters.mcu, c[0], os.y + os.h + SPACE, leftW));
+ const mcu = { x: c[0] - SPACE, y: groupTop, w: leftW + 2 * SPACE, h: mcuBottom + SPACE - groupTop };
+
+ const subW = (wide - 2 * SPACE) / 3;
+ const sub = [0, 1, 2].map((i) => c[2] + i * (subW + SPACE));
+ const ifContent = belowTag(groupTop);
+ const ifBottom = Math.max(
+ stack(['basic', 'wireless'], sub[0], ifContent, subW),
+ stack(['highSpeed', 'industrial', 'cellular'], sub[1], ifContent, subW),
+ stack(['network', 'automotive'], sub[2], ifContent, subW),
+ );
+ const interfaces = { x: c[2] - SPACE, y: groupTop, w: wide + 2 * SPACE, h: ifBottom + SPACE - groupTop };
+ const bridgeBottom = add(place(clusters.bridge, c[4], groupTop, COL));
+ const crossBottom = Math.max(mcu.y + mcu.h, interfaces.y + interfaces.h, bridgeBottom) + GAP;
+
+ // Hardware only.
+ const hwTop = crossBottom + GAP;
+ const hwBottom = Math.max(
+ add(place(clusters.electronics, c[0], hwTop, wide)),
+ add(place(clusters.equipment, c[2], hwTop, COL)),
+ add(place(clusters.prototyping, c[3], hwTop, COL)),
+ add(place(clusters.fpga, c[4], hwTop + HEAD, COL)),
+ );
+
+ const areaX = 24;
+ const areaW = PAGE_WIDTH - 2 * areaX;
+ const swTop = TOP - 40;
+ const hwEnd = hwBottom + GAP;
+
+ // Soft skills: a separate area under the technical ones, tall enough for its margin label.
+ const softTop = hwEnd + GAP;
+ const softRows = Math.ceil(clusters.soft.boxes.length / clusters.soft.per);
+ const softH = Math.max(132, softRows * (BOX_H2 + SPACE) - SPACE + 2 * GAP);
+ const soft = { x: areaX, y: softTop, w: areaW, h: softH };
+ const softContentH = place(clusters.soft, 0, 0, spanW(5)).bottom;
+ add(place(clusters.soft, c[0], softTop + (softH - softContentH) / 2, spanW(5)));
+ const softEnd = softTop + softH;
+
+ const regionByName = new Map(plan.regions.map((r) => [r.name, r]));
+ const groupTopics = (name) => (regionByName.get(name) || { topics: [] }).topics;
+ const footer = { x: areaX, y: softEnd + GAP, w: areaW, h: FOOTER_H };
+
+ return {
+ width: PAGE_WIDTH,
+ height: footer.y + footer.h + 16,
+ titleY: 58,
+ footer,
+ areas: {
+ SOFTWARE: { x: areaX, y: swTop, w: areaW, h: crossBottom - swTop },
+ HARDWARE: { x: areaX, y: crossTop, w: areaW, h: hwEnd - crossTop },
+ 'SOFT SKILLS': soft,
+ },
+ cross: { x: areaX, y: crossTop, w: areaW, h: crossBottom - crossTop },
+ bands: [
+ { label: 'SOFTWARE', cls: 'sw', y0: swTop, y1: crossTop },
+ { label: 'SOFTWARE ∩ HARDWARE', cls: 'both', y0: crossTop, y1: crossBottom },
+ { label: 'HARDWARE', cls: 'hw', y0: crossBottom, y1: hwEnd },
+ { label: 'SOFT SKILLS', cls: 'ss', y0: softTop, y1: softEnd },
+ ],
+ groups: [
+ { name: 'Operating Systems', cls: 'g-os', rect: os, topics: groupTopics('Operating Systems') },
+ { name: 'Microcontrollers', cls: 'g-mcu', rect: mcu, topics: groupTopics('Microcontrollers') },
+ { name: 'Interfaces & Protocols', cls: 'g-ifc', rect: interfaces, topics: groupTopics('Interfaces & Protocols') },
+ ],
+ items,
+ missing,
+ };
+}
diff --git a/site/map.css b/site/map.css
new file mode 100644
index 0000000..22f668d
--- /dev/null
+++ b/site/map.css
@@ -0,0 +1,516 @@
+/* Map view: the roadmap diagram. */
+
+.map-view {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.map-top {
+ position: relative;
+ z-index: 2;
+ display: grid;
+ gap: 8px;
+ padding: 12px 16px 10px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+ backdrop-filter: blur(12px);
+}
+
+.start-strip,
+.map-toolbar {
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 6px;
+ overflow-x: auto;
+ scrollbar-width: thin;
+ padding-bottom: 2px;
+}
+
+.start-strip > *,
+.map-toolbar > * {
+ flex: none;
+}
+
+.map-toolbar-label {
+ min-width: 74px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.start-chip,
+.map-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 30px;
+ padding: 4px 12px;
+ border: 1px solid var(--border-strong);
+ border-radius: 999px;
+ background: var(--surface-2);
+ color: var(--text);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.start-chip:hover,
+.map-chip:hover {
+ border-color: var(--accent);
+ background: var(--chip-hover);
+}
+
+.map-chip > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.map-chip[aria-pressed="true"] {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: var(--bg);
+}
+
+.map-chip.clear {
+ border-style: dashed;
+ background: transparent;
+}
+
+.map-toolbar-sep {
+ width: 1px;
+ height: 22px;
+ margin: 0 4px;
+ background: var(--border-strong);
+}
+
+.map-summary {
+ margin-left: 4px;
+ color: var(--muted);
+ font-size: 12.5px;
+}
+
+.dot.both {
+ background: linear-gradient(90deg, var(--sw) 50%, var(--hw) 50%);
+}
+
+.imp-swatch {
+ display: inline-block;
+ width: 16px;
+ height: 11px;
+ border: 1px solid var(--box-edge);
+ border-radius: 3px;
+}
+
+.imp-swatch.required { background: var(--imp-required); }
+.imp-swatch.recommended { background: var(--imp-recommended); }
+.imp-swatch.possible { background: var(--imp-possible); border-color: var(--border-strong); }
+
+.map-canvas {
+ position: relative;
+ min-height: 0;
+}
+
+.diagram {
+ display: block;
+ width: 100%;
+ height: 100%;
+ font-family: var(--font-display);
+ touch-action: none;
+ user-select: none;
+ cursor: grab;
+}
+
+.diagram.panning {
+ cursor: grabbing;
+}
+
+.page-bg {
+ fill: transparent;
+}
+
+.page-title {
+ fill: var(--text);
+ font-size: 34px;
+ font-weight: 900;
+ letter-spacing: -0.01em;
+}
+
+.legend-text {
+ fill: var(--text);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.legend-note {
+ fill: var(--muted);
+ font-size: 12px;
+ font-style: italic;
+}
+
+.legend-imp { stroke: var(--box-edge); stroke-width: 1; }
+.legend-imp.required { fill: var(--imp-required); }
+.legend-imp.recommended { fill: var(--imp-recommended); }
+.legend-imp.possible { fill: var(--imp-possible); stroke: var(--border-strong); }
+.legend-area { fill: none; stroke-width: 2.5; }
+.legend-area.sw { stroke: var(--sw); }
+.legend-area.hw { stroke: var(--hw); }
+.legend-area.ss { stroke: var(--ss); }
+.legend-hatch { fill: url(#cross-hatch); stroke: var(--border-strong); }
+
+.area-fill {
+ stroke: none;
+ fill-opacity: var(--area-tint);
+}
+
+.area-fill.sw { fill: var(--sw); }
+.area-fill.hw { fill: var(--hw); }
+.area-fill.ss { fill: var(--ss); fill-opacity: calc(var(--area-tint) * 1.6); }
+
+.area-line {
+ fill: none;
+ stroke-width: 3;
+}
+
+.area-line.sw { stroke: var(--sw); }
+.area-line.hw { stroke: var(--hw); }
+.area-line.ss { stroke: var(--ss); stroke-width: 2.5; }
+
+.overlap-fill {
+ fill: url(#cross-hatch);
+}
+
+.hatch-a,
+.hatch-b {
+ stroke-width: 2.4;
+ stroke-opacity: 0.2;
+}
+
+.hatch-a { stroke: var(--sw); }
+.hatch-b { stroke: var(--hw); }
+
+.band-label {
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.22em;
+}
+
+.band-label.sw { fill: var(--sw-text); }
+.band-label.hw { fill: var(--hw-text); }
+.band-label.both { fill: var(--text); }
+.band-label.ss { fill: var(--ss-text); }
+
+.group-line {
+ fill: none;
+ stroke-width: 2;
+}
+
+.group-line.g-os { stroke: var(--g-os); }
+.group-line.g-mcu { stroke: var(--g-mcu); }
+.group-line.g-ifc { stroke: var(--g-ifc); }
+
+.tag-bg {
+ fill: var(--surface-solid);
+ stroke-width: 2;
+}
+
+.tag-text {
+ font-size: 14px;
+ font-weight: 800;
+ pointer-events: none;
+}
+
+.group-tag.g-os .tag-bg { stroke: var(--g-os); }
+.group-tag.g-mcu .tag-bg { stroke: var(--g-mcu); }
+.group-tag.g-ifc .tag-bg { stroke: var(--g-ifc); }
+.group-tag.g-os .tag-text { fill: var(--g-os); }
+.group-tag.g-mcu .tag-text { fill: var(--g-mcu); }
+.group-tag.g-ifc .tag-text { fill: var(--g-ifc); }
+
+.cluster-caption {
+ fill: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+}
+
+.box .box-bg {
+ stroke: var(--box-edge);
+ stroke-width: 1;
+ transition: filter 0.15s, stroke 0.15s;
+}
+
+.box.required .box-bg { fill: var(--imp-required); }
+.box.recommended .box-bg { fill: var(--imp-recommended); }
+.box.possible .box-bg { fill: var(--imp-possible); stroke: var(--border-strong); }
+
+.box-text {
+ font-size: 14px;
+ font-weight: 600;
+ pointer-events: none;
+}
+
+.box.required .box-text { fill: var(--imp-required-text); }
+.box.recommended .box-text { fill: var(--imp-recommended-text); }
+.box.possible .box-text { fill: var(--imp-possible-text); }
+
+.box .header-text {
+ fill: var(--text);
+ font-size: 15px;
+ font-weight: 800;
+}
+
+.target {
+ cursor: pointer;
+ outline: none;
+}
+
+.box.target:hover .box-bg,
+.box.lit .box-bg {
+ filter: brightness(1.06);
+ stroke: var(--accent);
+ stroke-width: 2.2;
+}
+
+.box.header.target:hover .header-text,
+.box.header.lit .header-text,
+.group-tag.target:hover .tag-text {
+ text-decoration: underline;
+}
+
+.group-tag.target:hover .tag-bg {
+ fill: var(--chip-hover);
+}
+
+.box.selected .box-bg {
+ stroke: var(--text);
+ stroke-width: 3;
+}
+
+.box.header.selected .header-text {
+ fill: var(--accent);
+}
+
+.box.target:focus-visible .box-bg,
+.group-tag.target:focus-visible .tag-bg {
+ stroke: var(--accent);
+ stroke-width: 3.5;
+}
+
+.box.header.target:focus-visible .header-text {
+ fill: var(--accent);
+ text-decoration: underline;
+}
+
+.layer-boxes .box {
+ transition: opacity 0.2s;
+}
+
+.diagram.filtering .box:not(.match, .header) {
+ opacity: 0.15;
+}
+
+.diagram.has-focus:not(.filtering) .box:not(.lit, .header) {
+ opacity: 0.35;
+}
+
+.link,
+.link-all {
+ fill: none;
+ stroke: var(--accent);
+ stroke-linecap: round;
+ pointer-events: none;
+}
+
+.link.mention,
+.link-all.mention {
+ stroke-dasharray: 5 5;
+}
+
+.link-all {
+ stroke-opacity: 0.4;
+}
+
+.layer-linksAll {
+ opacity: 0;
+ transition: opacity 0.25s;
+}
+
+.diagram.show-all .layer-linksAll {
+ opacity: 1;
+}
+
+.diagram.show-all.has-focus .layer-linksAll {
+ opacity: 0.2;
+}
+
+.link-end {
+ fill: var(--accent);
+}
+
+.credit-main {
+ fill: var(--text);
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.credit-text {
+ fill: var(--muted);
+ font-size: 12.5px;
+ font-weight: 600;
+}
+
+.credit-strong {
+ fill: var(--text);
+ font-weight: 800;
+}
+
+.credit-icon {
+ fill: var(--text);
+}
+
+.page-credits a:hover text {
+ text-decoration: underline;
+}
+
+.zoom-controls {
+ position: absolute;
+ right: 16px;
+ bottom: 16px;
+ display: grid;
+ justify-items: center;
+ gap: 6px;
+ padding: 6px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface);
+ backdrop-filter: blur(10px);
+}
+
+.zoom-controls .icon-btn {
+ width: 34px;
+ height: 34px;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.zoom-level {
+ color: var(--muted);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 960px) {
+ .map-top {
+ padding: 8px 10px;
+ }
+
+ .start-strip,
+ .map-toolbar {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+
+ .start-chip,
+ .map-chip,
+ .map-toolbar-label,
+ .map-summary {
+ flex: none;
+ }
+
+ .zoom-controls {
+ bottom: 12px;
+ right: 12px;
+ }
+}
+
+/* Printing: the diagram alone on an A4 page, in print colors. */
+
+@page {
+ size: A4 portrait;
+ margin: 8mm;
+}
+
+@media print {
+ html[data-theme] {
+ color-scheme: light;
+ --bg: #ffffff;
+ --text: #0f172a;
+ --muted: #475569;
+ --surface-solid: #ffffff;
+ --border-strong: rgba(15, 23, 42, 0.35);
+ --chip-hover: #ffffff;
+ --accent: #3b4fd8;
+ --sw: #2b9fbf;
+ --hw: #e8a400;
+ --ss: #12b85a;
+ --sw-text: #0b6a85;
+ --hw-text: #8a5b00;
+ --ss-text: #0b7a3b;
+ --g-os: #6d4fb0;
+ --g-mcu: #b2453c;
+ --g-ifc: #4b5567;
+ --area-tint: 0.06;
+ --imp-required: #fff04a;
+ --imp-required-text: #1d1a00;
+ --imp-recommended: #fbd7bb;
+ --imp-recommended-text: #2b1407;
+ --imp-possible: #f1f3f6;
+ --imp-possible-text: #1f2937;
+ --box-edge: rgba(15, 23, 42, 0.75);
+ }
+
+ html,
+ body {
+ height: auto;
+ overflow: visible;
+ background: #ffffff;
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+
+ .topbar,
+ .panel,
+ .map-top,
+ .zoom-controls,
+ .tooltip,
+ #outline,
+ .status {
+ display: none !important;
+ }
+
+ .app,
+ .layout,
+ .stage,
+ .map-view,
+ .map-canvas {
+ display: block !important;
+ position: static !important;
+ height: auto !important;
+ overflow: visible !important;
+ }
+
+ .diagram {
+ width: 100%;
+ height: auto;
+ max-height: 280mm;
+ }
+
+ .diagram .box,
+ .diagram .box-bg {
+ opacity: 1 !important;
+ filter: none !important;
+ }
+
+ .layer-links,
+ .layer-linksAll {
+ display: none;
+ }
+}
diff --git a/site/map.json b/site/map.json
new file mode 100644
index 0000000..b98a97c
--- /dev/null
+++ b/site/map.json
@@ -0,0 +1,266 @@
+{
+ "bands": [
+ {
+ "label": "SOFTWARE",
+ "areas": ["SOFTWARE"],
+ "clusters": {
+ "languages": {
+ "header": "Programming Languages",
+ "per": 2,
+ "topics": [
+ ["C", "required"],
+ ["C++", "recommended"],
+ ["Python", "recommended"],
+ ["Assembly", "possible"],
+ ["Rust", "possible"]
+ ]
+ },
+ "fundamentals": {
+ "header": "Programming Fundamentals",
+ "topics": [
+ ["Algorithms & Data Structures", "required"],
+ ["Design Patterns", "required"],
+ ["State Machines", "required"],
+ ["Memory Management", "required"]
+ ]
+ },
+ "debugging": {
+ "header": "Debugging",
+ "topics": [
+ ["JTAG / SWD", "required"],
+ ["GDB", "required"],
+ ["OpenOCD", "possible"]
+ ]
+ },
+ "sdlc": {
+ "header": "SDLC Models",
+ "per": 2,
+ "topics": [
+ ["Agile / SCRUM", "recommended"],
+ ["V-Model", "recommended"]
+ ]
+ },
+ "vcs": {
+ "header": "Version Control",
+ "per": 2,
+ "topics": [
+ ["Git", "required"],
+ ["SVN", "possible"]
+ ]
+ },
+ "build": {
+ "header": "Build System",
+ "topics": [
+ ["Compilers / GCC", "required"],
+ ["Make / CMake", "required"],
+ ["Bash Scripting", "recommended"],
+ ["Docker", "possible"]
+ ]
+ },
+ "testing": {
+ "header": "Testing",
+ "topics": [
+ ["TDD & Unit Testing", "required"],
+ ["CI/CD Pipelines", "recommended"],
+ ["SIL / HIL Testing", "possible"],
+ ["Standards & Certifications", "possible"]
+ ]
+ },
+ "specialized": {
+ "caption": "Specializations",
+ "topics": [
+ ["Embedded Security", "possible"],
+ ["Embedded GUI", "possible"],
+ ["IoT", "possible"],
+ ["Edge AI", "possible"],
+ ["AUTOSAR", "possible"]
+ ]
+ },
+ "osBase": {
+ "topics": [
+ ["Operating System Fundamentals", "recommended"]
+ ]
+ },
+ "linux": {
+ "header": "Embedded Linux",
+ "per": 2,
+ "topics": [
+ ["Linux Kernel", "recommended"],
+ ["Device Drivers", "recommended"],
+ ["U-Boot", "possible"],
+ ["Buildroot / Yocto", "possible"],
+ ["Threading / Parallelism", "possible"],
+ ["IPC", "possible"],
+ ["Qt Framework", "possible"]
+ ]
+ }
+ }
+ },
+ {
+ "label": "SOFTWARE ∩ HARDWARE",
+ "areas": ["SOFTWARE", "HARDWARE"],
+ "clusters": {
+ "rtos": {
+ "header": "Real-Time OS",
+ "per": 2,
+ "topics": [
+ ["RTOS Basics", "required"],
+ ["FreeRTOS", "recommended"],
+ ["Zephyr", "recommended"],
+ ["QNX", "possible"],
+ ["µC/OS", "possible"],
+ ["RT-Thread", "possible"]
+ ]
+ },
+ "mcu": {
+ "per": 2,
+ "topics": [
+ ["GPIO", "required"],
+ ["ADC / DAC", "required"],
+ ["Timers / Counters", "required"],
+ ["PWM", "required"],
+ ["Watchdog", "required"],
+ ["Interrupts", "required"],
+ ["DMA", "required"],
+ ["Clock Management", "required"],
+ ["Power Management", "recommended"],
+ ["Bootloader / DFU", "recommended"]
+ ]
+ },
+ "bridge": {
+ "caption": "Hardware-facing software",
+ "topics": [
+ ["Memory Technologies & File Systems", "recommended"],
+ ["Hardware Simulation / Emulation", "recommended"],
+ ["Sensors & Actuators", "possible"],
+ ["Digital Signal Processing", "possible"],
+ ["Control Theory", "possible"]
+ ]
+ },
+ "basic": {
+ "header": "Basic",
+ "topics": [
+ ["UART", "required"],
+ ["I2C", "required"],
+ ["SPI", "required"]
+ ]
+ },
+ "wireless": {
+ "header": "Wireless",
+ "topics": [
+ ["Bluetooth", "recommended"],
+ ["Wi-Fi", "recommended"],
+ ["LoRa", "possible"],
+ ["Zigbee", "possible"],
+ ["Thread", "possible"],
+ ["Matter", "possible"],
+ ["UWB", "possible"]
+ ]
+ },
+ "highSpeed": {
+ "header": "High-Speed",
+ "topics": [
+ ["Ethernet", "recommended"],
+ ["USB", "recommended"],
+ ["PCIe", "possible"]
+ ]
+ },
+ "industrial": {
+ "header": "Industrial",
+ "topics": [
+ ["Modbus", "recommended"],
+ ["Profinet", "possible"],
+ ["EtherCAT", "possible"]
+ ]
+ },
+ "cellular": {
+ "header": "Cellular",
+ "topics": [
+ ["GSM / LTE", "possible"],
+ ["LTE-M / 5G", "possible"],
+ ["NB-IoT", "possible"]
+ ]
+ },
+ "network": {
+ "header": "Network",
+ "topics": [
+ ["MQTT", "recommended"],
+ ["CoAP", "possible"],
+ ["TCP/IP", "possible"],
+ ["UDP", "possible"]
+ ]
+ },
+ "automotive": {
+ "header": "Automotive",
+ "topics": [
+ ["CAN", "recommended"],
+ ["LIN", "possible"],
+ ["MOST", "possible"],
+ ["FlexRay", "possible"]
+ ]
+ }
+ }
+ },
+ {
+ "label": "HARDWARE",
+ "areas": ["HARDWARE"],
+ "clusters": {
+ "electronics": {
+ "header": "Electronics",
+ "per": 2,
+ "topics": [
+ ["Basic Math & Calculus", "required"],
+ ["Principles of Electric Circuits", "required"],
+ ["Electronics Fundamentals", "required"],
+ ["Digital Design", "required"],
+ ["Computer Architecture", "required"]
+ ]
+ },
+ "equipment": {
+ "header": "Using Test Equipment",
+ "topics": [
+ ["Multimeter", "required"],
+ ["Logic / Protocol Analyzer", "required"],
+ ["Oscilloscope", "recommended"]
+ ]
+ },
+ "prototyping": {
+ "header": "Prototyping Skills",
+ "topics": [
+ ["Breadboarding", "recommended"],
+ ["Hardware Design Basics", "recommended"],
+ ["PCB Design / EMC", "recommended"],
+ ["Soldering / Rework", "recommended"]
+ ]
+ },
+ "fpga": {
+ "topics": [
+ ["FPGA Development", "possible"]
+ ]
+ }
+ }
+ },
+ {
+ "label": "SOFT SKILLS",
+ "areas": ["SOFT SKILLS"],
+ "clusters": {
+ "soft": {
+ "per": 3,
+ "topics": [
+ ["Communication Skills", "required"],
+ ["Problem-Solving Skills & Critical Thinking", "required"],
+ ["Teamwork & Collaborative Abilities", "required"],
+ ["Organizational & Time Management Skills", "required"],
+ ["Being Self-Driven and Independent", "required"],
+ ["Adaptability & Patience", "required"]
+ ]
+ }
+ }
+ }
+ ],
+ "groups": {
+ "Operating Systems": ["osBase", "linux", "rtos"],
+ "Microcontrollers": ["rtos", "mcu"],
+ "Interfaces & Protocols": ["basic", "wireless", "highSpeed", "industrial", "cellular", "network", "automotive"]
+ }
+}
diff --git a/site/mapview.js b/site/mapview.js
new file mode 100644
index 0000000..dec528b
--- /dev/null
+++ b/site/mapview.js
@@ -0,0 +1,572 @@
+// Interactive roadmap diagram: Software / Hardware / Soft skills areas with their cross-sections,
+// importance colors, filters and connections between topics. Laid out by layout.js to fit A4.
+import { AREAS, CREDITS, GITHUB_MARK, IMPORTANCE, areaDots, areaLabel, h, plural, svg } from './dom.js';
+import { SPACE, buildLayout } from './layout.js';
+
+const MIN_ZOOM = 0.6;
+const MAX_ZOOM = 6;
+
+function intersect(a, b) {
+ const x = Math.max(a.x, b.x);
+ const y = Math.max(a.y, b.y);
+ const w = Math.min(a.x + a.w, b.x + b.w) - x;
+ const hh = Math.min(a.y + a.h, b.y + b.h) - y;
+ return w > 0 && hh > 0 ? { x, y, w, h: hh } : null;
+}
+
+const center = (r) => [r.x + r.w / 2, r.y + r.h / 2];
+const rect = (r, attrs, parent) => svg('rect', { x: r.x, y: r.y, width: r.w, height: r.h, ...attrs }, parent);
+
+function splitInTwo(text) {
+ const words = text.split(' ');
+ let best = [text];
+ let score = Infinity;
+ for (let i = 1; i < words.length; i += 1) {
+ const a = words.slice(0, i).join(' ');
+ const b = words.slice(i).join(' ');
+ if (Math.max(a.length, b.length) < score) {
+ score = Math.max(a.length, b.length);
+ best = [a, b];
+ }
+ }
+ return best;
+}
+
+export function createMapView({ root, plan, data, build, onSelect }) {
+ const layout = buildLayout(plan);
+ const state = { selected: null, hover: null, areas: new Set(), importance: new Set(), showAll: false, view: null, drag: null };
+ const boxEls = new Map();
+ const rectOf = new Map();
+ const legendParts = [];
+
+ // ---------- toolbar ----------
+ const chip = (label, cls, pressed, onclick, title) => h('button', {
+ type: 'button', class: `map-chip ${cls}`, 'aria-pressed': String(pressed), onclick, title,
+ }, label);
+ const toolbar = h('div', { class: 'map-toolbar', role: 'toolbar', 'aria-label': 'Highlight topics' });
+ const summary = h('span', { class: 'map-summary', role: 'status' });
+
+ function renderToolbar() {
+ const cross = state.areas.size === 2 && state.areas.has('SOFTWARE') && state.areas.has('HARDWARE');
+ toolbar.replaceChildren(...[
+ h('span', { class: 'map-toolbar-label' }, 'Highlight'),
+ Object.entries(AREAS).map(([area, info]) => chip(h('span', {}, h('i', { class: `dot ${info.cls}` }), info.label),
+ '', state.areas.has(area) && !cross, () => {
+ state.areas = state.areas.has(area) && !cross ? new Set() : new Set([area]);
+ refresh();
+ }, `Topics in ${info.label}`)),
+ chip(h('span', {}, h('i', { class: 'dot both' }), 'Software ∩ Hardware'), 'cross', cross, () => {
+ state.areas = cross ? new Set() : new Set(['SOFTWARE', 'HARDWARE']);
+ refresh();
+ }, 'Topics that belong to both Software and Hardware'),
+ h('span', { class: 'map-toolbar-sep', 'aria-hidden': 'true' }),
+ Object.entries(IMPORTANCE).map(([level, label]) => chip(h('span', {}, h('i', { class: `imp-swatch ${level}` }), label),
+ '', state.importance.has(level), () => toggle(state.importance, level))),
+ h('span', { class: 'map-toolbar-sep', 'aria-hidden': 'true' }),
+ chip('Show all connections', 'links', state.showAll, () => { state.showAll = !state.showAll; refresh(); },
+ 'Lines between topics that share resources or mention each other'),
+ state.areas.size || state.importance.size ? chip('Clear', 'clear', false, () => {
+ state.areas.clear();
+ state.importance.clear();
+ refresh();
+ }) : null,
+ summary,
+ ].flat().filter(Boolean));
+ }
+
+ function toggle(set, value) {
+ if (set.has(value)) set.delete(value);
+ else set.add(value);
+ refresh();
+ }
+
+ // README topics that the diagram does not cover.
+ const extra = data.root.children.filter((t) => !t.areas.length);
+ const strip = h('nav', { class: 'start-strip', 'aria-label': 'Getting started topics' },
+ h('span', { class: 'map-toolbar-label' }, 'Start here'),
+ extra.map((t) => h('button', { type: 'button', class: 'start-chip', onclick: () => onSelect(t) },
+ t.icon ? h('span', { 'aria-hidden': 'true' }, t.icon) : null, t.title)));
+
+ // ---------- svg ----------
+ const chart = svg('svg', {
+ class: 'diagram',
+ role: 'group',
+ 'aria-label': 'Roadmap diagram. Tab moves between topics, Enter opens one.',
+ preserveAspectRatio: 'xMidYMid meet',
+ });
+ const defs = svg('defs', {}, chart);
+ const hatch = svg('pattern', { id: 'cross-hatch', width: 10, height: 10, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, defs);
+ svg('line', { x1: 0, y1: 0, x2: 0, y2: 10, class: 'hatch-a' }, hatch);
+ svg('line', { x1: 5, y1: 0, x2: 5, y2: 10, class: 'hatch-b' }, hatch);
+
+ const layers = {};
+ for (const name of ['page', 'areas', 'overlap', 'outlines', 'groups', 'linksAll', 'links', 'boxes', 'labels']) {
+ layers[name] = svg('g', { class: `layer-${name}` }, chart);
+ }
+
+ rect({ x: 0, y: 0, w: layout.width, h: layout.height }, { class: 'page-bg' }, layers.page);
+
+ // Title and legend, so the diagram also stands on its own when printed.
+ const title = svg('text', { x: 24, y: layout.titleY, class: 'page-title' }, layers.labels);
+ title.textContent = 'Embedded Systems Engineering Roadmap';
+ const legend = svg('g', { class: 'page-legend' }, layers.labels);
+ const legendItems = [
+ ...Object.entries(IMPORTANCE).map(([level, label]) => ({ kind: 'imp', cls: level, label })),
+ { kind: 'area', cls: 'sw', label: 'Software' },
+ { kind: 'area', cls: 'hw', label: 'Hardware' },
+ { kind: 'hatch', cls: '', label: 'Software ∩ Hardware' },
+ { kind: 'area', cls: 'ss', label: 'Soft skills' },
+ ];
+ for (const item of legendItems) {
+ const y = layout.titleY + 22;
+ const cls = item.kind === 'imp' ? `legend-imp ${item.cls}` : item.kind === 'area' ? `legend-area ${item.cls}` : 'legend-hatch';
+ const key = rect({ x: 0, y, w: 22, h: 13 }, { rx: item.kind === 'imp' ? 3 : 4, class: cls }, legend);
+ const t = svg('text', { x: 0, y: y + 11, class: 'legend-text' }, legend);
+ t.textContent = item.label;
+ legendParts.push({ key, t, label: item.label });
+ }
+
+ function layoutLegend() {
+ let x = 26;
+ for (const part of legendParts) {
+ part.key.setAttribute('x', x);
+ part.t.setAttribute('x', x + 28);
+ x += 28 + ((part.t.getComputedTextLength && part.t.getComputedTextLength()) || part.label.length * 6.5) + 20;
+ }
+ }
+ const note = svg('text', { x: 24, y: layout.titleY + 54, class: 'legend-note' }, layers.labels);
+ note.textContent = 'Colors show the average importance of each skill for an embedded systems engineer.';
+
+ // Areas and their cross-sections.
+ const sw = layout.areas.SOFTWARE;
+ const hw = layout.areas.HARDWARE;
+ const ss = layout.areas['SOFT SKILLS'];
+ rect(sw, { rx: 28, class: 'area-fill sw' }, layers.areas);
+ rect(hw, { rx: 28, class: 'area-fill hw' }, layers.areas);
+ rect(ss, { rx: 28, class: 'area-fill ss' }, layers.areas);
+ rect(layout.cross, { class: 'overlap-fill' }, layers.overlap);
+ rect(sw, { rx: 28, class: 'area-line sw' }, layers.outlines);
+ rect(hw, { rx: 28, class: 'area-line hw' }, layers.outlines);
+ rect(ss, { rx: 28, class: 'area-line ss' }, layers.outlines);
+
+ // Band names run down the left margin; every band reads as one area or a cross-section.
+ for (const band of layout.bands) {
+ const cy = (band.y0 + band.y1) / 2;
+ const t = svg('text', {
+ x: 0, y: 0, class: `band-label ${band.cls}`, 'text-anchor': 'middle', 'dominant-baseline': 'central',
+ transform: `translate(${sw.x + 18} ${cy.toFixed(1)}) rotate(-90)`,
+ }, layers.labels);
+ t.textContent = band.label;
+ }
+
+ const tags = [];
+ function tag(text, cls, r, topic = null) {
+ const g = svg('g', { class: `tag ${cls}` }, layers.labels);
+ const bg = svg('rect', { class: 'tag-bg', height: 22, rx: 11 }, g);
+ const label = svg('text', { class: 'tag-text', 'dominant-baseline': 'central' }, g);
+ label.textContent = text;
+ const entry = { g, bg, label, text, r };
+ tags.push(entry);
+ if (topic) wireTarget(g, topic, null, `${text} group`);
+ return entry;
+ }
+
+ // Group labels all sit on the top-left of their own border.
+ for (const group of layout.groups) {
+ rect(group.rect, { rx: 16, class: `group-line ${group.cls}` }, layers.groups);
+ tag(group.name, `group-tag ${group.cls}`, group.rect, group.topics[0] || null);
+ }
+
+ function layoutTags() {
+ for (const t of tags) {
+ const width = (t.label.getComputedTextLength && t.label.getComputedTextLength()) || t.text.length * 7;
+ const x = t.r.x + 22;
+ const y = t.r.y - 11;
+ t.bg.setAttribute('x', x.toFixed(1));
+ t.bg.setAttribute('y', y.toFixed(1));
+ t.bg.setAttribute('width', (width + 22).toFixed(1));
+ t.label.setAttribute('x', (x + 11).toFixed(1));
+ t.label.setAttribute('y', (y + 11).toFixed(1));
+ }
+ }
+
+ // Credits: where the resources are, who maintains the roadmap, its license and revision.
+ const { x: fx, y: fy, w: fw } = layout.footer;
+ const fr = fx + fw;
+ const credits = svg('g', { class: 'page-credits' }, layers.labels);
+ const link = (href) => svg('a', { href, target: '_blank', rel: 'noopener noreferrer' }, credits);
+ const icon = svg('svg', { x: fx, y: fy + 2, width: 16, height: 16, viewBox: '0 0 16 16' }, credits);
+ svg('path', { d: GITHUB_MARK, class: 'credit-icon' }, icon);
+ const repo = svg('text', { x: fx + 24, y: fy + 15, class: 'credit-main' }, link(`https://${CREDITS.repo}`));
+ repo.textContent = `Learning resources for every topic: ${CREDITS.repo}`;
+ const creditLine = (y, label, value) => {
+ const t = svg('text', { x: fx, y, class: 'credit-text' }, credits);
+ t.append(label);
+ svg('tspan', { class: 'credit-strong' }, t).textContent = value;
+ };
+ creditLine(fy + 35, 'Creator & Maintainer: ', CREDITS.author);
+ if (build) creditLine(fy + 53, 'Revision: ', `${build.version} · Last update: ${build.date}`);
+ const logoSize = 52;
+ const badge = { w: 103, h: 36 };
+ svg('image', { href: 'assets/logo.svg', x: fr - logoSize, y: fy, width: logoSize, height: logoSize }, credits);
+ const badgeX = fr - logoSize - SPACE - badge.w;
+ svg('image', { href: 'assets/cc-by-sa.svg', x: badgeX, y: fy + (logoSize - badge.h) / 2, width: badge.w, height: badge.h }, link(CREDITS.licenseUrl));
+ const licenseText = svg('text', { x: badgeX - 14, y: fy + 22, 'text-anchor': 'end', class: 'credit-text' }, link(CREDITS.licenseUrl));
+ licenseText.textContent = 'This work is licensed under the Creative Commons';
+ const licenseText2 = svg('text', { x: badgeX - 14, y: fy + 40, 'text-anchor': 'end', class: 'credit-text' }, link(CREDITS.licenseUrl));
+ licenseText2.textContent = 'Attribution-ShareAlike 4.0 International License.';
+
+ // ---------- topics ----------
+ for (const item of layout.items) {
+ const box = item.box;
+ if (item.kind === 'caption') {
+ const t = svg('text', { x: item.x, y: item.y + 15, class: 'cluster-caption' }, layers.boxes);
+ t.textContent = item.text.toUpperCase();
+ continue;
+ }
+ const topic = box.topics[0] || null;
+ const g = svg('g', { class: `box ${item.kind === 'header' ? 'header' : box.importance}`, 'data-box': box.id }, layers.boxes);
+ rectOf.set(box, item);
+ if (item.kind === 'header') {
+ const t = svg('text', { x: item.x, y: item.y + 16, class: 'box-text header-text' }, g);
+ t.textContent = item.text;
+ boxEls.set(box, { g, text: t, width: item.w, header: true });
+ } else {
+ rect(item, { rx: 5, class: 'box-bg' }, g);
+ const lines = item.h >= 36 ? splitInTwo(item.text) : [item.text];
+ const t = svg('text', { x: item.x + item.w / 2, y: item.y + item.h / 2, 'text-anchor': 'middle', class: 'box-text' }, g);
+ lines.forEach((line, i) => {
+ const span = svg('tspan', { x: item.x + item.w / 2, dy: i === 0 ? `${0.36 - (lines.length - 1) * 0.55}em` : '1.1em' }, t);
+ span.textContent = line;
+ });
+ boxEls.set(box, { g, text: t, width: item.w - 10 });
+ }
+ for (const t of box.topics) {
+ if (!t.layoutBoxes) t.layoutBoxes = [];
+ t.layoutBoxes.push(box);
+ }
+ if (topic) wireTarget(g, topic, box);
+ }
+
+ function describe(topic, box) {
+ const parts = [box ? box.text : topic.title];
+ if (topic.areas.length) parts.push(areaLabel(topic.areas));
+ const importance = (box && box.importance) || topic.importance;
+ if (importance) parts.push(IMPORTANCE[importance]);
+ parts.push(plural(topic.total, 'resource'));
+ return parts.join(', ');
+ }
+
+ function wireTarget(el, topic, box = null, label = null) {
+ el.setAttribute('tabindex', '0');
+ el.setAttribute('role', 'button');
+ el.setAttribute('aria-label', label ? `${label}, ${plural(topic.total, 'resource')}` : describe(topic, box));
+ el.classList.add('target');
+ el.addEventListener('pointerenter', (e) => hover(topic, box, e));
+ el.addEventListener('pointermove', moveTip);
+ el.addEventListener('pointerleave', () => hover(null));
+ el.addEventListener('focus', () => hover(topic, box));
+ el.addEventListener('blur', () => hover(null));
+ el.addEventListener('click', () => {
+ if (state.drag && state.drag.moved) return;
+ onSelect(topic);
+ });
+ el.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onSelect(topic);
+ }
+ });
+ }
+
+ // ---------- tooltip ----------
+ const tip = h('div', { class: 'tooltip', hidden: true });
+ function hover(topic, box = null, e = null) {
+ state.hover = topic;
+ drawLinks();
+ if (!topic || !e) {
+ tip.hidden = true;
+ return;
+ }
+ const importance = (box && box.importance) || topic.importance;
+ tip.replaceChildren(...[
+ h('strong', {}, topic.title),
+ topic.areas.length ? h('div', { class: 'tip-areas' }, areaDots(topic.areas), areaLabel(topic.areas)) : null,
+ h('div', { class: 'tip-meta' }, [importance ? IMPORTANCE[importance] : null, plural(topic.total, 'resource'),
+ topic.links && topic.links.length ? `${topic.links.length} connected` : null].filter(Boolean).join(' · ')),
+ h('div', { class: 'tip-hint' }, 'Click for details and resources'),
+ ].filter(Boolean));
+ tip.hidden = false;
+ moveTip(e);
+ }
+
+ function moveTip(e) {
+ if (tip.hidden) return;
+ const bounds = root.getBoundingClientRect();
+ let x = e.clientX - bounds.left + 16;
+ let y = e.clientY - bounds.top + 16;
+ if (x + tip.offsetWidth > bounds.width - 8) x = e.clientX - bounds.left - tip.offsetWidth - 16;
+ if (y + tip.offsetHeight > bounds.height - 8) y = e.clientY - bounds.top - tip.offsetHeight - 16;
+ tip.style.transform = `translate(${Math.max(8, x)}px, ${Math.max(8, y)}px)`;
+ }
+
+ // ---------- connections ----------
+ const connections = plan.connections.filter((c) => rectOf.has(c.a) && rectOf.has(c.b));
+
+ function curve(a, b) {
+ const [x1, y1] = center(rectOf.get(a));
+ const [x2, y2] = center(rectOf.get(b));
+ const mx = (x1 + x2) / 2;
+ const my = (y1 + y2) / 2;
+ const bend = 0.2;
+ return `M${x1.toFixed(1)} ${y1.toFixed(1)}Q${(mx - (y2 - y1) * bend).toFixed(1)} ${(my + (x2 - x1) * bend).toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
+ }
+
+ function drawConnection(connection, layer, cls) {
+ svg('path', {
+ d: curve(connection.a, connection.b),
+ class: `${cls} ${connection.shared ? 'shared' : 'mention'}`,
+ 'stroke-width': (1.4 + Math.min(3, Math.sqrt(connection.weight) * 0.7)).toFixed(2),
+ }, layer);
+ if (cls !== 'link') return;
+ for (const box of [connection.a, connection.b]) {
+ const [cx, cy] = center(rectOf.get(box));
+ svg('circle', { cx: cx.toFixed(1), cy: cy.toFixed(1), r: 3, class: 'link-end' }, layer);
+ }
+ }
+
+ for (const connection of connections) drawConnection(connection, layers.linksAll, 'link-all');
+
+ const isWithin = (node, topic) => {
+ for (let n = node; n; n = n.parent) if (n === topic) return true;
+ return false;
+ };
+
+ function drawLinks() {
+ layers.links.replaceChildren();
+ const topic = state.hover || state.selected;
+ const lit = new Set();
+ if (topic) {
+ const own = new Set(plan.boxes.filter((b) => rectOf.has(b) && b.topics.some((t) => isWithin(t, topic))));
+ const fallback = plan.boxFor(topic);
+ if (fallback && rectOf.has(fallback)) own.add(fallback);
+ for (const b of own) lit.add(b);
+ for (const connection of connections) {
+ if (!(own.has(connection.a) || own.has(connection.b))) continue;
+ if (!connection.links.some((l) => isWithin(l.source, topic) || isWithin(l.target, topic))) continue;
+ drawConnection(connection, layers.links, 'link');
+ lit.add(connection.a);
+ lit.add(connection.b);
+ }
+ }
+ chart.classList.toggle('has-focus', Boolean(state.hover));
+ for (const [box, el] of boxEls) {
+ el.g.classList.toggle('lit', lit.has(box));
+ el.g.classList.toggle('selected', Boolean(state.selected) && box.topics.includes(state.selected));
+ }
+ }
+
+ // ---------- filters ----------
+ function matches(box) {
+ if (!rectOf.has(box) || boxEls.get(box).header) return false;
+ for (const area of state.areas) if (!box.areas.includes(area)) return false;
+ if (state.areas.size === 1 && box.areas.filter((a) => a !== 'SOFT SKILLS').length > 1 && !state.areas.has('SOFT SKILLS')) {
+ // A single area means "only this area", so the cross-section stands apart.
+ return false;
+ }
+ return !state.importance.size || state.importance.has(box.importance);
+ }
+
+ function refresh() {
+ const filtering = state.areas.size > 0 || state.importance.size > 0;
+ chart.classList.toggle('filtering', filtering);
+ chart.classList.toggle('show-all', state.showAll);
+ let count = 0;
+ for (const [box, el] of boxEls) {
+ const match = filtering && matches(box);
+ if (match) count += 1;
+ el.g.classList.toggle('match', match);
+ }
+ summary.textContent = filtering ? `${plural(count, 'topic')}` : '';
+ renderToolbar();
+ }
+
+ // ---------- pan and zoom ----------
+ const page = { w: layout.width, h: layout.height };
+ const MAX_SCALE = 1.5;
+ let fitMode = 'width';
+
+ function canvasSize() {
+ const box = chart.getBoundingClientRect();
+ return box.width > 0 && box.height > 0 ? { w: box.width, h: box.height } : null;
+ }
+
+ // The view always has the canvas' aspect ratio, so the drawing fills it without letterboxing.
+ function setView(view) {
+ const size = canvasSize();
+ const v = { ...view };
+ v.h = size ? v.w * (size.h / size.w) : v.h || v.w * (page.h / page.w);
+ const slackX = Math.max(0, page.w - v.w);
+ const slackY = Math.max(0, page.h - v.h);
+ v.x = slackX ? Math.min(Math.max(v.x, -40), slackX + 40) : (page.w - v.w) / 2;
+ v.y = slackY ? Math.min(Math.max(v.y, -40), slackY + 40) : (page.h - v.h) / 2;
+ state.view = v;
+ chart.setAttribute('viewBox', `${v.x.toFixed(1)} ${v.y.toFixed(1)} ${v.w.toFixed(1)} ${v.h.toFixed(1)}`);
+ if (size) zoomLevel.textContent = `${Math.round((size.w / v.w) * 100)}%`;
+ }
+
+ function fitWidth() {
+ const size = canvasSize();
+ const width = size ? Math.max(page.w + 40, size.w / MAX_SCALE) : page.w;
+ fitMode = 'width';
+ setView({ x: (page.w - width) / 2, y: -20, w: width });
+ fitButton.title = 'Show the whole page';
+ }
+
+ function fitPage() {
+ const size = canvasSize();
+ const aspect = size ? size.h / size.w : page.h / page.w;
+ const width = Math.max(page.w, (page.h + 40) / aspect) + 40;
+ fitMode = 'page';
+ setView({ x: (page.w - width) / 2, y: 0, w: width });
+ fitButton.title = 'Fit the width';
+ }
+
+ const toggleFit = () => (fitMode === 'width' ? fitPage() : fitWidth());
+
+ function toChart(clientX, clientY) {
+ const matrix = chart.getScreenCTM();
+ if (!matrix) return [0, 0];
+ const p = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
+ return [p.x, p.y];
+ }
+
+ function zoomAt(factor, clientX, clientY) {
+ const v = state.view;
+ const size = canvasSize();
+ if (!size) return;
+ const scale = (size.w / v.w) * factor;
+ if (scale < (size.w / page.w) * MIN_ZOOM || scale > MAX_ZOOM) return;
+ const [px, py] = clientX == null ? [v.x + v.w / 2, v.y + v.h / 2] : toChart(clientX, clientY);
+ fitMode = 'custom';
+ setView({ x: px - (px - v.x) / factor, y: py - (py - v.y) / factor, w: v.w / factor });
+ }
+
+ chart.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ if (e.ctrlKey || e.metaKey) {
+ zoomAt(Math.exp(-e.deltaY * 0.0025), e.clientX, e.clientY);
+ return;
+ }
+ const size = canvasSize();
+ if (!size) return;
+ const unit = state.view.w / size.w;
+ const dx = e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX;
+ const dy = e.shiftKey && !e.deltaX ? 0 : e.deltaY;
+ setView({ ...state.view, x: state.view.x + dx * unit, y: state.view.y + dy * unit });
+ }, { passive: false });
+ chart.addEventListener('pointerdown', (e) => {
+ if (e.button !== 0) return;
+ state.drag = { x: e.clientX, y: e.clientY, view: { ...state.view }, moved: false, id: e.pointerId };
+ });
+ chart.addEventListener('pointermove', (e) => {
+ const drag = state.drag;
+ if (!drag || drag.id !== e.pointerId) return;
+ const dx = e.clientX - drag.x;
+ const dy = e.clientY - drag.y;
+ if (!drag.moved && Math.hypot(dx, dy) < 5) return;
+ if (!drag.moved) {
+ drag.moved = true;
+ chart.setPointerCapture(e.pointerId);
+ chart.classList.add('panning');
+ }
+ const scale = drag.view.w / chart.getBoundingClientRect().width;
+ fitMode = 'custom';
+ setView({ ...drag.view, x: drag.view.x - dx * scale, y: drag.view.y - dy * scale });
+ });
+ const endDrag = (e) => {
+ if (!state.drag || state.drag.id !== e.pointerId) return;
+ chart.classList.remove('panning');
+ // The click that follows a drag still needs to see `moved`.
+ window.setTimeout(() => { state.drag = null; }, 0);
+ };
+ chart.addEventListener('pointerup', endDrag);
+ chart.addEventListener('pointercancel', endDrag);
+ chart.addEventListener('dblclick', toggleFit);
+
+ const zoomLevel = h('span', { class: 'zoom-level', 'aria-live': 'polite' }, '100%');
+ const fitButton = h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Toggle between fitting the width and the whole page', title: 'Show the whole page', onclick: toggleFit }, '⤢');
+ const zoomControls = h('div', { class: 'zoom-controls' },
+ h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Zoom in', title: 'Zoom in (Ctrl + scroll)', onclick: () => zoomAt(1.25) }, '+'),
+ zoomLevel,
+ h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Zoom out', title: 'Zoom out (Ctrl + scroll)', onclick: () => zoomAt(1 / 1.25) }, '−'),
+ fitButton);
+
+ root.replaceChildren(
+ h('div', { class: 'map-top' }, strip, toolbar),
+ h('div', { class: 'map-canvas' }, chart, zoomControls),
+ tip,
+ );
+ fitWidth();
+ refresh();
+ layoutLegend();
+ layoutTags();
+ // Tag and legend widths depend on the web font, so measure again once it has loaded.
+ document.fonts.load('800 14px "Sofia Sans Semi Condensed"').then(() => requestAnimationFrame(relayout), () => {});
+
+ // Keep the same part of the diagram in view when the window or panel changes size.
+ new ResizeObserver(() => {
+ if (!canvasSize() || state.beforePrint) return;
+ if (fitMode === 'width') fitWidth();
+ else if (fitMode === 'page') fitPage();
+ else setView(state.view);
+ }).observe(chart);
+
+ // Long labels are squeezed to fit their box once real text widths are known.
+ function fitText() {
+ for (const [, el] of boxEls) {
+ const spans = el.header ? [el.text] : [...el.text.querySelectorAll('tspan')];
+ for (const span of spans) {
+ span.removeAttribute('textLength');
+ span.removeAttribute('lengthAdjust');
+ if (span.getComputedTextLength() > el.width) {
+ span.setAttribute('textLength', el.width.toFixed(1));
+ span.setAttribute('lengthAdjust', 'spacingAndGlyphs');
+ }
+ }
+ }
+ }
+
+ function relayout() {
+ layoutLegend();
+ layoutTags();
+ fitText();
+ }
+
+ return {
+ select(topic) {
+ state.selected = topic;
+ drawLinks();
+ },
+ show() {
+ if (fitMode === 'width') fitWidth();
+ relayout();
+ },
+ prepareForPrint() {
+ state.hover = null;
+ tip.hidden = true;
+ state.beforePrint = { view: { ...state.view }, fitMode };
+ state.view = { x: 0, y: 0, w: page.w, h: page.h };
+ chart.setAttribute('viewBox', `0 0 ${page.w} ${page.h}`);
+ relayout();
+ drawLinks();
+ },
+ afterPrint() {
+ if (!state.beforePrint) return;
+ fitMode = state.beforePrint.fitMode;
+ setView(state.beforePrint.view);
+ state.beforePrint = null;
+ },
+ layout,
+ };
+}
diff --git a/site/parser.js b/site/parser.js
new file mode 100644
index 0000000..813ed52
--- /dev/null
+++ b/site/parser.js
@@ -0,0 +1,299 @@
+// Turns the roadmap README into a topic tree with descriptions, resources and cross-links.
+// Pure module: no DOM access, so it runs in the browser and under `node --test`.
+
+export const TYPES = {
+ book: { icon: '📘', label: 'Book' },
+ video: { icon: '🎞️', label: 'Video' },
+ article: { icon: '📝', label: 'Article' },
+ link: { icon: '🔗', label: 'Link' },
+ audio: { icon: '🎧', label: 'Audio book' },
+};
+
+const TYPE_BY_SYMBOL = { '📘': 'book', '🎞': 'video', '📝': 'article', '🔗': 'link', '🎧': 'audio' };
+// Heading prefixes the README uses only to mark the heading level.
+const LEVEL_MARKERS = new Set(['✳', '🔵', '🔶', '🔸']);
+const SYMBOLS = /^[\p{Extended_Pictographic}\uFE0F\u200D\u200C\u20E3\s]+/u;
+const INVISIBLE = /[\uFE0F\u200D\u200C\u20E3\s]/u;
+const URL_PART = String.raw`https?:\/\/[^\s()]+(?:\([^\s()]*\)[^\s()]*)*`;
+const HEADING = /^(#{1,6})\s+(.+)$/;
+const RESOURCE = new RegExp(String.raw`^\s*[-*]\s+\[(.+)\]\((${URL_PART})\)(.*)$`, 'u');
+const HEADING_LINK = new RegExp(String.raw`^\[(.+)\]\((${URL_PART})\)$`, 'u');
+const BULLET = /^\s*[-*]\s+(.+)$/;
+const SKIP = /^(\s*>|